Skip to content

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.

The IdP vouches, the resource server redeems
The client holds an ID token from the IdP. It exchanges the ID token at the IdP's token endpoint for an ID-JAG, then presents the ID-JAG to the resource authorization server's token endpoint under the jwt-bearer grant and receives an access token.Clientholds an ID tokenIdPissues the ID-JAGResource ASredeems the ID-JAG1POST /tokentoken-exchange, id_token2ID-JAGN_A, five minutes at most3POST /tokenjwt-bearer, assertion=ID-JAG4access tokenno refresh token
The two servers never talk to each other: the resource authorization server trusts the IdP's signing keys and the client carries the assertion across. Step 3 is the ordinary jwt-bearer grant with one extra profile switched on.

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.

go
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 ​

sh
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/token

The response carries the assertion in access_token, because RFC 8693 reuses that member for any issued token:

json
{
  "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_type is the id_token URN, 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_token and actor_token_type are refused with invalid_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 returnsWire response
*op.IDJAGDecisionIssued. ClientID is required. Every Resource value must be one the request named, verbatim.
op.ErrIDJAGUnknownAudienceinvalid_target
op.ErrIDJAGDenied, or (nil, nil)access_denied
Any other error, an empty ClientID, or a Resource the request did not nameserver_error
An empty Resource when the request named someinvalid_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:

go
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:

ClaimRule
typ headeroauth-id-jag+jwt.
audExactly 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.
iatRequired.
client_idEqual to the authenticated client.
scopeOptional. Every requested scope must be inside it, and the issued scope is narrowed to it.
resourceOptional. 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).
cnfOptional. When present it carries exactly one of jkt or x5t#S256, matching the DPoP proof or client certificate on the redemption.
tenant, aud_tenantRefused: 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.