Packages
attesto_phoenix
2.0.2
2.1.0
2.0.2
2.0.1
2.0.0
1.4.0
1.3.0
1.2.0
1.1.0
1.0.0
0.20.0
0.19.1
0.19.0
0.18.0
0.17.0
0.16.0
0.15.0
0.14.2
0.14.1
0.14.0
0.13.5
0.13.4
0.13.3
0.13.2
0.13.1
0.13.0
0.12.0
0.11.0
0.10.0
0.9.5
0.9.4
0.9.3
0.9.2
0.9.1
0.9.0
0.8.0
0.7.7
0.7.6
0.7.5
0.7.4
0.7.3
0.7.2
0.7.1
0.7.0
0.6.23
0.6.22
0.6.21
0.6.20
0.6.19
0.6.18
0.6.17
0.6.16
0.6.15
0.6.14
0.6.13
0.6.12
0.6.11
0.6.10
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.2
0.6.1
0.6.0
Phoenix/Ecto OAuth 2.0 / OIDC authorization server layer over attesto: authorization, token, PAR, revocation, discovery, JWKS, UserInfo, protected-resource plugs, and Ecto-backed token stores.
Current section
Files
Jump to
Current section
Files
guides/identity_assertion_grant.md
# Identity Assertion grant (ID-JAG / MCP Enterprise-Managed Authorization)
The **Identity Assertion JWT Authorization Grant** (ID-JAG,
[`draft-ietf-oauth-identity-assertion-authz-grant-04`](https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/))
is the grant behind **MCP Enterprise-Managed Authorization (EMA)**. It lets an
enterprise IdP centrally provision access to a resource application with no
browser redirect and no consent screen.
The flow has two token steps; attesto is the **resource application's**
authorization server and implements only the second:
1. *(not attesto's job)* The client performs an RFC 8693 token exchange **at the
IdP**, trading the user's ID token / SAML assertion for an **ID-JAG**: a
short-lived JWT, signed by the IdP, asserting one user for one resource
application.
2. *(attesto's job)* The client presents that ID-JAG to attesto's token endpoint
as an RFC 7523 §4 JWT-bearer authorization grant and receives a normal access
token:
```http
POST /oauth/token
Authorization: Basic <client credentials> # the grant requires client auth
Content-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
&assertion=<the ID-JAG JWT>
&scope=mcp:read # optional; bounded by the assertion's scope claim
```
This is **not** `private_key_jwt` client authentication (RFC 7523 §3, which
asserts the *client's* identity) and **not** the
`urn:ietf:params:oauth:grant-type:token-exchange` grant (RFC 8693, which runs at
the IdP).
## What attesto validates
`Attesto.IdentityAssertion` verifies the assertion and the token core maps every
failure to RFC 6749 §5.2 `invalid_grant` (a missing `assertion` parameter is
`invalid_request`):
- JOSE header `typ` is `oauth-id-jag+jwt`.
- the signature verifies against the **trusted issuer's** JWKS.
- `iss` is a configured trusted issuer (an unconfigured issuer is denied without
revealing the trusted set).
- `aud` is exactly this server's issuer identifier.
- the required `iss`, `sub`, `aud`, `client_id`, `jti`, `exp`, `iat` claims are
present; `exp`/`iat`/`nbf` are within skew and the assertion is not expired.
- the `client_id` claim matches the **authenticated** client.
- the `jti` has not been replayed.
The asserted `scope` claim (when present) is the **ceiling** on what the issued
token may carry; your `:authorize_scope` policy narrows from there.
## Configuration
The feature is **off by default**. Enable it under `:jwt_bearer`:
```elixir
config :my_app, AttestoPhoenix.Config,
# ... issuer, keystore, repo, the usual callbacks ...
jwt_bearer: [
enabled: true,
issuers: %{
# A trusted enterprise IdP with STATIC keys:
"https://idp.example.com" => [
jwks: %{"keys" => [%{"kty" => "RSA", "kid" => "...", "n" => "...", "e" => "AQAB"}]},
allowed_algs: ["RS256", "ES256"] # optional; defaults to all supported
],
# ...or one whose keys are fetched (and cached) from its JWKS URI:
"https://idp.other.com" => [
jwks_uri: "https://idp.other.com/.well-known/jwks.json"
]
},
assertion_max_lifetime_seconds: 300 # optional ceiling on exp - iat
],
resolve_jwt_bearer_subject: &MyApp.AuthZ.resolve_jwt_bearer_subject/1
```
When enabled, `urn:ietf:params:oauth:grant-type:jwt-bearer` is added to
`grant_types_supported` (both discovery documents and the token endpoint honour
it). Config validation fails closed at boot if you enable the grant without a
trusted-issuer source or without the subject-resolution callback.
### `:jwt_bearer` options
| key | meaning |
| --- | --- |
| `:enabled` | turns the grant on (default `false`) |
| `:issuers` | `%{issuer_url => issuer_opts}`; `issuer_opts` carries `:jwks` (static), `:jwks_uri` (fetched + cached), `:allowed_algs`, and an optional `:audience` override (defaults to the AS issuer) |
| `:assertion_max_lifetime_seconds` | reject an assertion whose `exp - iat` exceeds this (default `300`) |
| `:jwks_resolver` | optional `(issuer, issuer_opts) -> {:ok, jwks}`; full host control, bypasses `:jwks`/`:jwks_uri` |
| `:jwks_fetcher` / `:jwks_cache` | the SSRF-guarded remote-JWKS fetch + cache for `:jwks_uri` issuers (reused from the CIMD seam; default `Req` + the Ecto cache) |
`jti` replay reuses the configured `:replay_check` (the same store as DPoP),
namespaced so an ID-JAG `jti` never collides with a DPoP proof's. In a cluster,
set `:replay_check` to `{AttestoPhoenix.Store.EctoReplayCheck, :check_and_record}`
as you would for DPoP.
## Wiring the subject-resolution callback
The asserted `sub` is the IdP's identifier for the user; you map it to your
local subject. The callback receives the **validated** claims (signature, trust,
`client_id` binding, `jti` replay already checked) and returns the local subject
or denies. It is also installable as `resolve_jwt_bearer_subject/1` on an
`AttestoPhoenix.PrincipalStore` module.
```elixir
def resolve_jwt_bearer_subject(claims) do
# `claims["sub"]` is unique when scoped with `claims["iss"]`; `claims["email"]`
# is often also present. Map to YOUR account model however you choose.
case MyApp.Accounts.fetch_by_external_id(claims["iss"], claims["sub"]) do
{:ok, user} -> {:ok, "user:#{user.id}"} # the subject the token is minted for
:error -> {:error, :no_local_account} # a deny becomes invalid_grant
end
end
```
The returned subject string is exactly what your `:build_principal` callback then
receives, so token claim-shaping is unchanged from the other grants.
A refresh token is issued only when `offline_access` is granted and a
`:refresh_store` is configured (the same policy as the authorization-code grant).