Use case — Identity Assertion JWT Authorization Grant (ID-JAG)
Experimental
ID-JAG tracks an IETF draft (draft-ietf-oauth-identity-assertion-authz-grant-06). Everything on this page is marked Experimental in the library's godoc and listed in api/experimental.txt: it may change in a minor release when the draft changes on the wire, and it is outside the SemVer promise. Pin the library version if you depend on it.
A user signs in to an application at their company's identity provider (IdP). The application then needs to call a different organisation's API on that user's behalf, without sending the user through a second consent screen. ID-JAG solves that by chaining two grants you may already know: the IdP vouches for the user with a short-lived signed assertion (the ID-JAG), and the other organisation's authorization server redeems it for an access token. This library can play either role, or both.
For the grant the resource side uses, read JWT bearer grant first. This page covers what ID-JAG adds.
The identity provider side
op.WithIDJAGIssuance makes the OP answer a token-exchange request for an ID-JAG. The caller presents an ID Token this OP issued, and your op.IDJAGIssuancePolicy decides whether, and for what, an assertion is minted.
import (
"context"
"time"
"github.com/libraz/go-oidc-provider/op"
"github.com/libraz/go-oidc-provider/op/storeadapter/inmem"
)
type idjagPolicy struct{}
func (idjagPolicy) Allow(ctx context.Context, req op.IDJAGRequest) (*op.IDJAGDecision, error) {
// Only issue for authorization servers you have a relationship with.
if req.Audience != "https://as.partner.example" {
return nil, op.ErrIDJAGUnknownAudience
}
if req.Client.ID != "agent-portal" {
return nil, op.ErrIDJAGDenied
}
return &op.IDJAGDecision{
// The client's identifier at the partner's server, not here.
ClientID: "acme-agent-portal",
Scope: req.Scope,
Resource: req.Resource,
TTL: 2 * time.Minute,
}, nil
}
st := inmem.New()
provider, err := op.New(
op.WithIssuer("https://idp.example.com"),
op.WithStore(st),
op.WithUserStore(st.Users()),
op.WithKeyset(myKeyset),
op.WithCookieKeys(myCookieKey),
// The authorization_code grant needs a login flow.
op.WithLoginFlow(op.LoginFlow{Primary: op.PrimaryPassword{Store: st.UserPasswords()}}),
// See "Interop" below: the draft's mandatory algorithm is RS256.
op.WithSigningAlgorithm(op.AlgRS256),
op.WithIDJAGIssuance(idjagPolicy{}),
op.WithStaticClients(op.ConfidentialClient{
ID: "agent-portal",
Secret: portalSecret,
AuthMethod: op.AuthClientSecretBasic,
RedirectURIs: []string{"https://portal.example.com/callback"},
GrantTypes: []string{"authorization_code", "urn:ietf:params:oauth:grant-type:token-exchange"},
Scopes: []string{"openid"},
}),
)op.WithIDJAGIssuance enables the token-exchange grant type on its own; it does not need op.WithTokenExchangeGrant. The policy is required and non-nil, and the option may be given once. A token-exchange request that asks for the ID-JAG token type without this option is refused with invalid_request.
The request and the response
curl -s \
-u agent-portal:<secret> \
-d 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \
-d "subject_token=$ID_TOKEN" \
-d 'subject_token_type=urn:ietf:params:oauth:token-type:id_token' \
-d 'requested_token_type=urn:ietf:params:oauth:token-type:id-jag' \
-d 'audience=https://as.partner.example' \
-d 'scope=docs:read' \
-d 'resource=https://api.partner.example' \
https://idp.example.com/oidc/tokenThe response carries the assertion in access_token, because RFC 8693 reuses that member for any issued token:
{
"access_token": "<the ID-JAG>",
"issued_token_type": "urn:ietf:params:oauth:token-type:id-jag",
"token_type": "N_A",
"expires_in": 120,
"scope": "docs:read"
}token_type is N_A because an ID-JAG is not an access token, even when the request carried a DPoP proof. No refresh token is issued. The assertion has the JOSE typ oauth-id-jag+jwt, is signed with the OP's signing algorithm, and carries iss, sub, aud (the requested audience), client_id (the policy's ClientID), jti, iat, exp, plus scope, resource, email, auth_time, acr, amr and cnf when they apply. cnf follows the exchange request's DPoP proof (jkt) or client certificate (x5t#S256), one method only.
What the OP enforces before the policy
- The client authenticates; a public client is refused with
invalid_client. subject_token_typeis theid_tokenURN, the token is an ID Token this OP issued to the calling client, and its grant is still live, so withdrawn consent closes the exchange.- Exactly one
audience.actor_tokenandactor_token_typeare refused withinvalid_request. - A client registered for pairwise subjects cannot obtain an ID-JAG, because the grant record cannot be matched to the projected
sub.
scope, audience and resource name the other server's namespace, so the OP does not compare them with the calling client's registration. Your policy is the only gate on them.
Decision and errors
| Policy returns | Wire response |
|---|---|
*op.IDJAGDecision | Issued. ClientID is required. Every Resource value must be one the request named, verbatim. |
op.ErrIDJAGUnknownAudience | invalid_target |
op.ErrIDJAGDenied, or (nil, nil) | access_denied |
Any other error, an empty ClientID, or a Resource the request did not name | server_error |
An empty Resource when the request named some | invalid_target |
IDJAGDecision.TTL of zero, or anything over five minutes, yields five minutes. Issuance is recorded as audit.TokenExchangeIDJAGIssued (token_exchange.idjag_issued), with audience, aud_client_id, granted_scope, resource, ttl_seconds and the assertion's jti in extras.
The resource authorization server side
The redeeming side is the JWT bearer grant with the issuer's Profile set to op.AssertionProfileIDJAG:
st := inmem.New()
provider, err := op.New(
op.WithIssuer("https://as.partner.example"),
op.WithStore(st),
op.WithUserStore(st.Users()),
op.WithKeyset(myKeyset),
op.WithCookieKeys(myCookieKey),
// No end user signs in here, so drop the default authorization_code grant.
op.WithGrants(grant.ClientCredentials),
op.WithJWTBearerGrant(op.JWTBearerGrant{
Issuers: []op.AssertionIssuer{{
Issuer: "https://idp.example.com",
JWKSURI: "https://idp.example.com/jwks",
Profile: op.AssertionProfileIDJAG,
// The identifiers the IdP's policy writes into client_id.
AllowedClients: []string{"acme-agent-portal"},
}},
ResolveSubject: func(ctx context.Context, a op.AssertionSubject) (string, error) {
// Claims beyond RFC 7523, such as email, are in a.Claims.
email, _ := a.Claims["email"].(string)
local, ok := lookupAccountByEmail(ctx, email)
if !ok {
return "", op.ErrUnknownAssertionSubject
}
return local, nil
},
}),
op.WithStaticClients(op.ConfidentialClient{
ID: "acme-agent-portal",
Secret: portalSecret,
AuthMethod: op.AuthClientSecretBasic,
GrantTypes: []string{"urn:ietf:params:oauth:grant-type:jwt-bearer"},
Scopes: []string{"docs:read"},
Resources: []string{"https://api.partner.example"},
}),
)The client redeems the assertion exactly as in the JWT bearer grant, with the ID-JAG as assertion. On top of the plain checks, the ID-JAG profile requires:
| Claim | Rule |
|---|---|
typ header | oauth-id-jag+jwt. |
aud | Exactly this OP's issuer, as a string or a one-element array. The token endpoint URL, accepted by the plain profile, is not accepted here. |
iat | Required. |
client_id | Equal to the authenticated client. |
scope | Optional. Every requested scope must be inside it, and the issued scope is narrowed to it. |
resource | Optional. Every value must be registered in the client's Resources. A request resource must be one of them; with none sent, a single claimed value becomes the audience, and several need the request to pick one (invalid_target otherwise). |
cnf | Optional. When present it carries exactly one of jkt or x5t#S256, matching the DPoP proof or client certificate on the redemption. |
tenant, aud_tenant | Refused: multi-tenancy is not supported. |
A failure of any of these is invalid_grant with the audit reason idjag. The jti is single-use here as well, so a client needing a new access token goes back to the IdP for a new ID-JAG, and replicas must share the ConsumedJTIStore.
Interop
Discovery. An OP with op.WithIDJAGIssuance publishes identity_chaining_requested_token_types_supported with the ID-JAG token type URN. An OP that trusts at least one issuer under op.AssertionProfileIDJAG publishes authorization_grant_profiles_supported with urn:ietf:params:oauth:grant-profile:id-jag. The profile is never advertised when no issuer is configured for it. id_jag_signing_alg_values_supported is not published.
The signing algorithm. The draft makes RS256 mandatory to implement, while the OP signs with ES256 by default. A resource authorization server that implements only the baseline cannot verify ES256 ID-JAGs, so an IdP serving one needs op.WithSigningAlgorithm(op.AlgRS256). There is no ID-JAG-specific setting; the switch moves every token the OP issues to RS256. Design judgment 34 records why.
Audit. The IdP records audit.TokenExchangeIDJAGIssued; the resource server records audit.JWTBearerGranted with profile set to the ID-JAG profile, or audit.JWTBearerRejected with a reason such as idjag. See the audit event catalog.
Read next
- JWT bearer grant: the grant the resource side runs on, including the plain profile.
- Token Exchange: the grant the identity provider side extends.
- Design judgments: why ID-JAG follows the OP's single signing algorithm.