# 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).