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_accessamong 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."
}