The Identity Assertion JWT Authorization Grant (ID-JAG,
draft-ietf-oauth-identity-assertion-authz-grant-04)
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:
(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.
(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:
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
typisoauth-id-jag+jwt. - the signature verifies against the trusted issuer's JWKS.
issis a configured trusted issuer (an unconfigured issuer is denied without revealing the trusted set).audis exactly this server's issuer identifier.- the required
iss,sub,aud,client_id,jti,exp,iatclaims are present;exp/iat/nbfare within skew and the assertion is not expired. - the
client_idclaim matches the authenticated client. - the
jtihas 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:
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/1When 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 after sender
binding, resource, scope, and host policy pass, and returns the local subject or
denies. The atomic jti replay claim follows the callback because the store seam
has no non-consuming reservation operation; a validly signed replay can reach
the callback before rejection, so any side effects must be idempotent. It is
also installable as resolve_jwt_bearer_subject/1 on an
AttestoPhoenix.PrincipalStore module.
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
endThe 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).