How a backend service can use the OAuth 2.0 JWT grant to obtain tokens on behalf of a user

The JWT authorisation grant (RFC 7523) is an OAuth 2.0 extension grant that has been supported by the Connect2id server since v3.5.

This grant is ideal to let trusted backend services obtain access tokens on behalf of a user. The minted access tokens include the end-user ID in the token's sub (subject) claim, so protected resources that consume the tokens can obtain the user's identity directly from the token, in a verifiable manner.

The OAuth 2.0 client credentials grant, which is also intended for services and similarly doesn't involve any end-user interaction (via the front-end), is not suitable for this case as it's intended for clients that act on their own behalf. The minted access tokens in the client credentials grant thus have the token's sub claim set to the client_id.

This grant also has the feature that the JWT can be used to also authenticate the client, so there is no need for client to authenticate explicitly.

How to register a client for the JWT grant

Here is a minimal request to register a new JWT grant client at the clients endpoint of the Connect2id server.

The scope metadata parameter must be set to the scope values that the client is allowed to request. Tokens with scope values other than those will not be issued.

An initial registration token is always required when the JWT grant is specified.

POST /clients HTTP/1.1
Host: demo.c2id.com
Content-Type: application/json
Authorization: Bearer ztucZS1ZyFKgh0tUEruUtiSTXhnexmd6

{
  "grant_types"    : [ "urn:ietf:params:oauth:grant-type:jwt-bearer" ],
  "response_types" : [],
  "scope"          : "read write admin"
}

The Connect2id server is going to respond with a registration response similar to this:

HTTP/1.1 201 Created
Content-Type: application/json
Cache-Control: no-store
Pragma: no-cache

{
  "client_id"                    : "n7gkx2t2anlig",
  "client_id_issued_at"          : 1707832352,
  "client_secret"                : "aI-10Qs4dt1xK33v5JgQCHz_V7cgIIRONDvuCzfvLPI",
  "client_secret_expires_at"     : 0,
  "grant_types"                  : [ "urn:ietf:params:oauth:grant-type:jwt-bearer" ],
  "response_types"               : [],
  "token_endpoint_auth_method"   : "client_secret_basic",
  "scope"                        : "read write admin",
  "application_type"             : "web",
  "subject_type"                 : "public",
  "id_token_signed_response_alg" : "RS256",
  "registration_client_uri"      : "https://demo.c2id.com/clients/n7gkx2t2anlig",
  "registration_access_token"    : "vBhh37SND6WQKXmwieQOfAOM8YgwbfNtOUUaj_BKpL4.YSxpLHIscCxjLGoscyx0LHNjcCxzdXJuLGlkLHNlYyxk"
}

The client must record the client_id and the client_secret that the Connect2id server provisioned to it. The client will use them to mint the JWT grants.

How to request tokens with the JWT grant

The JWT used for the grant must conform with a standard structure, or profile, specified in RFC 7523, section 2.1.

The JWT claims are these:

  • iss -- Must be set to the client_id.
  • sub -- Must be set to the user ID on which behalf the backend service is requesting an access token.
  • aud -- Must be set to the token endpoint URL or to the configured issuer URL of the Connect2id server.
  • exp -- Must be set to time when the JWT must expire, typically a minute from the current time (in seconds since the Unix epoch).
  • jti -- Optional unique identifier of the JWT for logging and audit purposes.

Since the client was provisioned with client_secret and hasn't registered any public key, the client must secure the JWT with an HMAC (choose HS256) using the UTF-8 byte representation of the client_secret. The HMAC-SHA-256 algorithm is much more efficient than RSA or even ECDSA signing, and has sufficient security for OAuth 2.0 grant usage, unless the client has to conform to a more stringent security profile.

Example JWT header:

{
  "alg" : "HS256"
}

Example JWT claims:

{
  "iss" : "n7gkx2t2anlig",
  "sub" : "alice",
  "aud" : "https://demo.c2id.com",
  "exp" : 1707833581,
  "jti" : "P0an8csati7_JzhLPvav-ZPF_-ZaI8HEdAwq9xSF6ZA"
}

With the minted JWT make a token request to the Connect2id server.

The following form parameters are required:

  • grant_type -- Must be set to urn:ietf:params:oauth:grant-type:jwt-bearer.
  • assertion -- Must be set to the serialised JWT that the client minted.
  • scope -- Must be set to the scope values (as a space separated list) that the client is requesting for the access token that is going to be issued by the Connect2id server. It may contain a subset of the scope values that client was registered with, or all of them, depending on the required authorisation scope.
POST /token HTTP/1.1
Host: demo.c2id.com
Content-Type: application/x-www-form-urlencoded

grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer
&assertion=eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiI3Z3h5Yjc0dzJyZ3djIiwic3ViIjoiYWxp...
&scope=read+write

The Connect2id server is going to respond like this:

HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
Cache-Control: no-store
Pragma: no-cache

{
  "access_token" : "2YotnFZFEjr1zCsicMWpAA",
  "token_type"   : "Bearer",
  "expires_in"   : 300,
  "scope"        : "read write"
}

Let's inspect the returned access token to verify the client ID, subject and the scope.

The claims follow the c2id-1.1 access token JWT profile:

{
  "iss" : "https://demo.c2id.com",
  "sub" : "alice",
  "cid" : "f36m42tfqjkn6",
  "scp" : [ "read", "write" ],
  "iat" : 1707835948,
  "exp" : 1707836248,
  "jti" : "wXGh_2BNHtY"
}

What's available in the default JWT grant handler plugin

To be able to support customisation of the JWT grant handling the Connect2id server provides a special plugin interface. The default plugin should be sufficient for the majority of client use cases and makes it possible to configure the encoding and audience of the issued access tokens, or whether to automatically include selected client metadata fields into the issued access token.

Example code

Here is example that registers a client for the JWT grant and makes a token request.

The code uses the OAuth 2.0 / OpenID Connect SDK. The JWT grants can also be minted directly, with the help of the popular Nimbus JOSE+JWT library.

// Register the client
var clientMetadata = new OIDCClientMetadata();
clientMetadata.setGrantTypes(Collections.singleton(GrantType.JWT_BEARER));
clientMetadata.setResponseTypes(Set.of());
clientMetadata.setScope(new Scope("read", "write", "admin"));
clientMetadata.applyDefaults();

HTTPRequest httpRequest = new OIDCClientRegistrationRequest(
    clientsURL,
    clientMetadata,
    API_TOKEN)
    .toHTTPRequest();
HTTPResponse httpResponse = httpRequest.send();
ClientRegistrationResponse regResponse = ClientRegistrationResponse.parse(httpResponse);
if (! regResponse.indicatesSuccess()) {
    // Handle error...
}

ClientInformation clientInfo = regResponse
    .toSuccessResponse()
    .getClientInformation();

// The client must record the provisioned client_id and client_secret
ClientID clientID = clientInfo.getID();
Secret clientSecret = clientInfo.getSecret();

// Make a token request for the end-user alice
var subject = new Subject("alice");

var jwtClaims = new JWTAssertionDetails(
    new Issuer(clientID),
    subject,
    new Audience(tokenEndpointURL));
SignedJWT jwtAssertion = JWTAssertionFactory.create(
    jwtClaims,
    JWSAlgorithm.HS256,
    clientInfo.getSecret());
httpRequest = new TokenRequest(
    tokenEndpointURL,
    new JWTBearerGrant(jwtAssertion),
    new Scope("read", "write"))
    .toHTTPRequest();
httpResponse = httpRequest.send();

TokenResponse tokenResponse = TokenResponse.parse(httpResponse);
if (! tokenResponse.indicatesSuccess()) {
    // Handle error...
}

BearerAccessToken token = tokenResponse
    .toSuccessResponse()
    .getTokens()
    .getBearerAccessToken();
assertEquals(new Scope("read", "write"), token.getScope());