OAUTH 2.0 · LAB
Rotate a refresh token family, tune its reuse grace, and end it
Rotate Ava's refresh token, narrow and widen scope, see strict reuse end the family, allow a few seconds of grace for a lost response, then restore strict mode.
ReadyUses your lab tenant
The lesson
Builds on: Scopes and audiences.
New to the labs? Start with the lab toolkit and the shared cast and names every lab uses.
Your progress
Press Start before you begin. Only events your tenant records after that count, in the order below. Checking reads your tenant's Audit, so you need Audit read access in it.
Sign in to start this lab and check your progress. Log in or create an account.
Start a refresh token family for Ava
Recorded as
oauth.tokensucceeded forlab-printerabout[email protected].Be refused a wider scope on refresh
Recorded as
oauth.tokenrejected (scope_not_granted) forlab-printer.Present a used refresh token with grace 0
Recorded as
oauth.tokenrejected (refresh_replayed) forlab-printer.Retry a lost refresh inside the reuse grace
Recorded as
oauth.tokensucceeded (refresh_reuse_grace) forlab-printer.Set a new password for Ava
Recorded as
tenant.users.credentials.setsucceeded.See the family end after the password change
Recorded as
oauth.tokenrejected (refresh_revoked) forlab-printer.
Request console
Requests in this lab can be sent from this page to your tenant: open one and choose Send. Fill in the values below first. They stay in this page's memory and are gone when you leave; secrets are never stored or sent anywhere except the request you send.
Setup
Choose Lab Photos as the lab tenant and press Start.
In Flow policy, allow the refresh token grant and save. On
lab-printer, allow the refresh token grant too, and confirmphotos.shareis in its Assigned scopes.In Access Token Management, create a manager
lab-tmp-refresh: Signed JWT, the current ES256 key, Maximum lifetime600, Refresh token lifetime in seconds86400(the idle lifetime), Sign-in limit for refresh tokens in seconds604800(the absolute lifetime), Refresh token reuse grace in seconds0. Under Assign a client, assign it tolab-printer.Use the variables and helpers from Present an access token correctly, and extend
exchangeto keepREFRESH=$(jq -r '.refresh_token // empty' <<<"$RESP"). Add a refresh helper and an introspection helper:
refresh() { # $1 = refresh token, $2 = optional narrower scope
RESP=$(curl -s -u "$CLIENT_ID:$CLIENT_SECRET" "$ISSUER/oauth/token" -d grant_type=refresh_token \
--data-urlencode "refresh_token=$1" ${2:+--data-urlencode "scope=$2"})
TOKEN=$(jq -r '.access_token // empty' <<<"$RESP"); REFRESH=$(jq -r '.refresh_token // empty' <<<"$RESP")
jq 'del(.access_token, .refresh_token, .id_token)' <<<"$RESP"
}
active() { curl -s -u "$CLIENT_ID:$CLIENT_SECRET" "$ISSUER/oauth/introspect" --data-urlencode "token=$1" | jq '{active, scope, exp}'; }
Walkthrough
Start a family.
authorize "openid photos.read photos.share offline_access", sign in as Ava,exchange. The response includes a refresh token. Keep it:RT1=$REFRESH.
Why it matters: a refresh token is issued only when the client asks for continued access and both Flow policy and the client allow the grant. The tenant stores only a hash of it, so a copy of its database holds nothing that could be presented.
Rotate. Run
refresh "$RT1", thenRT2=$REFRESH. You get a new access token and a new refresh token.
POST$ISSUER/oauth/token
Open in console
POST $ISSUER/oauth/token HTTP/1.1
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token&refresh_token=$RT1Why it matters: rotation replaces the token in one step and remembers the old one as used.
Compare the two:
active "$RT1"shows{"active": false};active "$RT2"is active withexpabout a day ahead.
Why it matters: each successful refresh moves the idle deadline forward, and no token in the family outlives the absolute deadline of seven days.
Narrow, then come back.
refresh "$RT2" photos.readgives an access token with onlyphotos.read; keepRT3=$REFRESH. Thenrefresh "$RT3"with no scope: the access token is back toopenid photos.read photos.share.
Why it matters: a narrow request today does not shrink what the replacement refresh token may ask for next time.
Try to widen:
refresh "$REFRESH" "photos.read photos.delete". Returns400 invalid_scope; Audit reasonscope_not_granted.
Why it matters: a refresh token can never add scope. That needs Ava's approval again.
Present a used token while the reuse grace is
0. Runrefresh "$RT2". Returns400 invalid_grantsaying every token from that sign-in was revoked. Then try the newest refresh token: alsoinvalid_grant(refresh_revoked).active "$TOKEN"on the newest access token shows{"active": false}.
Why it matters: the server cannot tell a client mistake from a stolen copy, so it ends the family, including the access tokens it can still reach.
Recover from a lost response with a short grace. Edit
lab-tmp-refreshand set Refresh token reuse grace in seconds to10. Save. Start a fresh family (step 1) and keepRT1=$REFRESH. Now refresh and throw the response away, as if the connection dropped:
curl -s -o /dev/null -u "$CLIENT_ID:$CLIENT_SECRET" "$ISSUER/oauth/token" -d grant_type=refresh_token --data-urlencode "refresh_token=$RT1"
refresh "$RT1" # retry within 10 seconds
The retry succeeds with new tokens. Audit records oauth.token succeeded with reason refresh_reuse_grace. Wait 15 seconds and run refresh "$RT1" once more: invalid_grant, refresh_replayed, and the family ends.
Why it matters: no standard defines a grace period. It saves a person from reconnecting after a network fault, and its cost is real: a copy presented inside the window is not detected either. That is why it stays a few seconds: any refresh token rotated within that many seconds can be presented again, and after that every reuse is a replay.
Restore: set Refresh token reuse grace in seconds on lab-tmp-refresh back to 0, so any second use is treated as theft again.
End a family from an account event. Start a fresh family (step 1). In User Management, set a new password for Ava. Then
refresh "$REFRESH":invalid_grant(refresh_revoked).
Why it matters: a password reset is one of the events that should end families. A person removing the printer from a connected applications page would end the grant the same way.
Note: there is no connected applications page for people yet (G40), so this lab uses an administrator's password change as the account event.
Present the token as a different client. Using
lab-print-orderscredentials, send arefresh_tokengrant with a currentlab-printerrefresh token. It is refused withunauthorized_client, because that client has no refresh grant. Even if it had one, the lookup is per client and would find nothing.
Why it matters: a refresh token is bound to the client it was issued to. Any other client presenting it is refused.
Break it
Run two refreshes of the same token at the same moment, with the grace at 0:
for i in 1 2; do curl -s -u "$CLIENT_ID:$CLIENT_SECRET" "$ISSUER/oauth/token" -d grant_type=refresh_token --data-urlencode "refresh_token=$REFRESH" | jq -c '{error, error_description}' & done; wait
One succeeds and the other is treated as reuse, which ends the family. The replacement is conditional on the token still being current, inside one transaction, so the family cannot split into two branches. This is why a client runs one refresh per connection at a time.
Check your work
Press Check my progress. The checks look for, in order: the first family for Ava, scope_not_granted, refresh_replayed with grace 0, a success with refresh_reuse_grace, tenant.users.credentials.set for Ava, and refresh_revoked after it.
Audit also shows tenant.oauth.managers.update twice for the grace change and its restore.
Cleanup
Confirm
lab-tmp-refreshshows a reuse grace of0.Assign Default access tokens back to
lab-printer, then deletelab-tmp-refresh.Store Ava's new password; later labs sign in with it. Run
unset TOKEN REFRESH RT1 RT2 RT3 RESP.