Table of Contents

Refresh token (rotation)

A client that asked for offline_access gets a refresh token with its first tokens, and uses it to get new ones when the access token expires, without sending the user back through the browser. Each refresh token is good once: using it returns a new one in its place, and the old one is refused from then on, so a stolen refresh token stops working as soon as either party uses it. The client is the demo host's web. The test moves the host's clock (its TimeProvider) forward ten minutes before refreshing, as a client would wait for its access token to near expiry.

Follows: RFC 6749 §6, OAuth 2.0 Security BCP §4.14 (RFC 9700).

Recorded from the demo host (src/Fdyay.IdentityServer.Host) by the RefreshTokenFlow test on every CI run. A value in braces, such as {access_token_1}, is one that differs on every run; the same placeholder is the same value wherever it appears. See how these pages are made.

sequenceDiagram
    participant Client
    participant AuthorizationServer as Authorization Server
    Note over Client,AuthorizationServer: The user has signed in and the browser has brought<br/>an authorization code back, as in the authorization<br/>code flow, with offline_access among the scopes.
    Client->>AuthorizationServer: POST /connect/token
    AuthorizationServer-->>Client: 200 OK
    Client->>AuthorizationServer: POST /connect/token
    AuthorizationServer-->>Client: 200 OK
    Client->>AuthorizationServer: POST /connect/token
    AuthorizationServer-->>Client: 400 Bad Request, invalid_grant

The user has signed in and the browser has brought an authorization code back, as in the authorization code flow, with offline_access among the scopes.

1. The client redeems the code

offline_access was granted, so the response carries a refresh token.

Client → Authorization Server

POST /connect/token HTTP/1.1
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code={authorization_code_1}
&redirect_uri=https%3A%2F%2Fclient.example.com%2Fcallback
&client_id=web
&code_verifier=documented-flow-code-verifier-0123456789-abcdefghij

Authorization Server → Client

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

{
  "access_token": "{access_token_1}",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "openid api offline_access",
  "refresh_token": "{refresh_token_1}",
  "id_token": "{id_token_1}"
}

{access_token_1} decoded — the header, then the payload; the signature is not shown:

{
  "alg": "RS256",
  "kid": "{key_id_1}",
  "typ": "at+jwt"
}
{
  "aud": [
    "api-resource"
  ],
  "iss": "http://localhost",
  "iat": {iat},
  "nbf": {iat},
  "exp": {iat + 3600},
  "jti": "{jti_1}",
  "client_id": "web",
  "scope": "api offline_access openid",
  "sub": "alice",
  "auth_time": {auth_time},
  "idp": "local",
  "amr": [
    "pwd"
  ],
  "sid": "{session_id_1}"
}

{id_token_1} decoded — the header, then the payload; the signature is not shown:

{
  "alg": "RS256",
  "kid": "{key_id_1}",
  "typ": "JWT"
}
{
  "aud": [
    "web"
  ],
  "iss": "http://localhost",
  "iat": {iat},
  "nbf": {iat},
  "exp": {iat + 300},
  "jti": "{jti_2}",
  "azp": "web",
  "sub": "alice",
  "auth_time": {auth_time},
  "idp": "local",
  "amr": [
    "pwd"
  ],
  "nonce": "n-0S6_WzA2Mj",
  "sid": "{session_id_1}",
  "at_hash": "{at_hash_1}"
}

2. Ten minutes later, the client refreshes

A new refresh token in place of the one just used; a public client authenticates with nothing but its client_id, which is why the one-time use matters. The access token is the one issued with the refresh token, re-dated: the same claims and jti, a new iat and exp, as Duende's is by default. A client registered with UpdateAccessTokenClaimsOnRefresh gets one built afresh instead.

Client → Authorization Server

POST /connect/token HTTP/1.1
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token
&refresh_token={refresh_token_1}
&client_id=web

Authorization Server → Client

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

{
  "access_token": "{access_token_2}",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "openid api offline_access",
  "refresh_token": "{refresh_token_2}",
  "id_token": "{id_token_2}"
}

{access_token_2} decoded — the header, then the payload; the signature is not shown:

{
  "alg": "RS256",
  "kid": "{key_id_1}",
  "typ": "at+jwt"
}
{
  "aud": [
    "api-resource"
  ],
  "iss": "http://localhost",
  "iat": {iat},
  "nbf": {iat},
  "exp": {iat + 3600},
  "jti": "{jti_1}",
  "client_id": "web",
  "scope": "api offline_access openid",
  "sub": "alice",
  "auth_time": {auth_time},
  "idp": "local",
  "amr": [
    "pwd"
  ],
  "sid": "{session_id_1}"
}

{id_token_2} decoded — the header, then the payload; the signature is not shown:

{
  "alg": "RS256",
  "kid": "{key_id_1}",
  "typ": "JWT"
}
{
  "aud": [
    "web"
  ],
  "iss": "http://localhost",
  "iat": {iat},
  "nbf": {iat},
  "exp": {iat + 300},
  "jti": "{jti_3}",
  "azp": "web",
  "sub": "alice",
  "auth_time": {auth_time},
  "idp": "local",
  "amr": [
    "pwd"
  ],
  "sid": "{session_id_1}",
  "at_hash": "{at_hash_2}"
}

3. The old refresh token is refused

It was used once already.

Client → Authorization Server

POST /connect/token HTTP/1.1
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token
&refresh_token={refresh_token_1}
&client_id=web

Authorization Server → Client

HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=UTF-8
Cache-Control: no-store, no-cache, max-age=0
Pragma: no-cache

{
  "error": "invalid_grant",
  "error_description": "Invalid refresh token."
}