diff --git a/Makefile b/Makefile index 982e96c7d..d6178e242 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,13 @@ SHELL=/bin/bash -euo pipefail -export GO111MODULE := on export PATH := .bin:${PATH} +node_modules/.bin/prettier: package.json package-lock.json + npm i + touch node_modules/.bin/prettier + .PHONY: format -format: node_modules +format: node_modules/.bin/prettier npm exec -- prettier --write . .PHONY: format-licenses @@ -35,19 +38,16 @@ build-examples: cd code-examples/protect-page-login/dotnet && docker build --build-arg APP_DIR=01-basic -t dotnet-01-basic . cd code-examples/protect-page-login/java && mvn clean compile -licenses: .bin/licenses node_modules # checks open-source licenses +.PHONY: licenses +licenses: .bin/licenses package-lock.json .bin/licenses -.PHONY: test -test: install build-examples .bin/ory - ./src/scripts/test.sh - .bin/licenses: Makefile curl https://raw.githubusercontent.com/ory/ci/master/licenses/install | sh -.bin/ory: Makefile go.sum +.bin/ory: Makefile go.mod go build -o .bin/ory github.com/ory/cli -node_modules: package.json package-lock.json - npm ci - touch node_modules +.PHONY: test +test: install build-examples .bin/ory + ./src/scripts/test.sh diff --git a/docs/hydra/debug.mdx b/docs/hydra/debug.mdx index d999d5735..f23e4cb7c 100644 --- a/docs/hydra/debug.mdx +++ b/docs/hydra/debug.mdx @@ -37,17 +37,7 @@ If you expect an OAuth 2.0 Refresh Token but aren't receiving one, this can have ## OAuth 2.0 authorize code flow fails -The most likely cause is misconfiguration, summarized in the next sections. - -## Refresh Token flow fails - -Refresh tokens can become invalid if abuse is detected, but coding issues may also trigger this scenario, for example if a client -makes multiple requests. - -Some common examples: - -1. Replay of authorization code grant. -2. Replay of refresh token grant. +The most likely cause is misconfiguration of the OAuth 2.0 client or the redirect URL. ### Wrong or misconfigured OAuth 2.0 client @@ -104,7 +94,59 @@ ory get oauth2-client {client.id} Here you see that `http://my-url/callback` isn't in the list, which is why the request fails. -### `/oauth2/token` endpoint fails for JWKS based client +## Refresh Token flow fails + +Refresh tokens in Ory OAuth2 and Ory Hydra are single-use. When a client redeems a refresh token at `/oauth2/token`, the server +returns a new access token and a new refresh token, and invalidates the old refresh token. + +If an already-used refresh token is presented a second time, Ory treats that as a leaked token and revokes the entire token chain +for that consent, logging out both the legitimate client and any attacker. This effectively prevents abuse from refresh tokens +leaked during refresh. However, it also means that a faulty client that accidentally reuses a refresh token can cause the same +result. + +A common case of a defective client implementation is a mobile app that tries to refresh tokens in the background, but gets +suspended by the operating system before the response arrives. When it retries with the prior token on its next wake-up, it +replays a spent token and triggers reuse detection. + +Another defective implementation occurs in the browser, where separate tabs or share a refresh token by storing it in cookie or +local storage, and they all try to refresh at the same time when the access token expires. The first request rotates the token; +the rest replay a spent token. + +The failures below all stem from a client redeeming a refresh token that the server has already rotated. + +Other possible causes include: + +- **The client doesn't store the new refresh token.** Each response contains a new `refresh_token`. If the client keeps using the + original instead of replacing its stored copy, the next refresh replays a spent token. +- **Unsynchronized shared storage.** Instances read the refresh token from a shared store but write the rotated value back without + locking, so they overwrite each other and replay stale tokens. + +To fix the client, make sure each refresh token is redeemed once and the rotated token is stored before it is used again: + +- **Let the request finish even if the app is suspended.** On iOS, + [run the refresh on a background `URLSession`](), + which completes out-of-process and is delivered when the app is woken, rather than a standard session that is killed on + suspension. If a background session isn't an option, wrap the call in `beginBackgroundTask(withName:expirationHandler:)`. Other + platforms have equivalent background-completion mechanisms. +- **Store the rotated refresh token before using it.** On every response, persist the new `refresh_token` and access token before + using the new access token, and discard the previous refresh token. +- **Serialize refreshes per session.** Use a lock so only one refresh runs at a time for a session; callers that arrive during an + in-flight refresh wait for and reuse its result. +- **Don't blindly retry an inconclusive refresh.** If a refresh fails without a definitive response, re-read the current token + from the shared store and let the serialized refresh path decide whether another refresh is needed, rather than retrying with + the token you originally sent. + +If none of these mitigations solve your problem, consider sending the user through a new OAuth2 authorization code flow to obtain +a new refresh token if you inadvertently revoked the previous one. If you instructed Hydra to remember the user's consent during +their prior login+consent journey, their next journey through the login+consent flow can be zero-click. This should be your +preferred solution if the refresh token is invalidated only occasionally. + +If your client implementation genuinely cannot be fixed, [graceful refresh token rotation](./guides/graceful-token-refresh.mdx) +lets a refresh token be redeemed more than once within a short grace period. This weakens the single-use guarantee for the +duration of the grace period and typically increases latency on `/oauth2/token` due to contention on the token chain, so use it as +a migration aid rather than a replacement for correct client behavior. + +## `/oauth2/token` endpoint fails for JWKS based client When trying to get an access token for a client registered with `"token_endpoint_auth_method": "private_key_jwt"` it's possible that the provided jwt has expired. diff --git a/docs/hydra/guides/graceful-token-refresh.mdx b/docs/hydra/guides/graceful-token-refresh.mdx index 252d2b05b..28f31bd28 100644 --- a/docs/hydra/guides/graceful-token-refresh.mdx +++ b/docs/hydra/guides/graceful-token-refresh.mdx @@ -10,6 +10,24 @@ token usage. With this feature enabled, a refresh token remains valid within a d without immediate invalidation. This can be beneficial in scenarios where network issues or delayed token exchanges may otherwise disrupt session continuity. +:::warning This is a workaround, not a best practice + +Graceful refresh token rotation should only be used if a client implementation **absolutely cannot be fixed**. + +Single-use refresh tokens are a key security feature: they enable refresh token reuse detection, which lets Ory detect and shut +down stolen or replayed tokens. Enabling graceful token rotation effectively **disables this security feature** for the duration +of the grace period, because the same refresh token can be redeemed multiple times without triggering reuse detection. + +The correct fix is almost always to fix the client so that it handles refresh token rotation correctly (for example, by +serializing concurrent token refreshes and persisting the newly issued refresh token). For a detailed explanation of why clients +break with single-use refresh tokens and how to fix them, see +[Client cannot handle refresh token rotation](../debug.mdx#refresh-token-flow-fails). + +Enabling graceful token rotation also typically **increases latency on the `/oauth2/token` endpoint**, because concurrent +refreshes of the same token chain contend for the same database rows. Fixing the client avoids this contention entirely. + +::: + When enabled, using a refresh token marks it as "used" in the database and increments the "usage counter" for that token. Further, the token's expiration time is increased by the duration of the configured grace period. As long as the grace period is active and the reuse counter does not exceed the configured limit, subsequent token refreshes will return new access and refresh tokens. All