Context
Surfaced while drafting #4339 (org-agnostic /oauth2/callback). Not part of that change — filed separately.
Problem
The state to OAuthSession mapping for the OAuth authorization-code callback flows lives in oauthClientStateStore with a hard 1-minute TTL:
auth/api/iam/user.go:43 — oAuthFlowTimeout = time.Minute
auth/api/iam/user.go:152 — oauthClientStateStore() = GetStore(oAuthFlowTimeout, ...)
storage/session.go:102 — Put sets store.WithExpiration(opts.ttl) once; no refresh on read, so it is an absolute expiry from the moment the node redirects the user out.
That 1 minute has to cover the entire interactive round-trip: redirect to the external IdP, the user authenticates (password, possibly MFA, consent), then redirect back to the callback. For a human login (e.g. AET / OpenIddict) that is far too tight — the callback then fails with invalid_request "invalid or expired state" (api.go:293).
Both auth-code callback flows write to this same store, so both inherit the 1-minute window:
| Flow |
Store write |
Interactive? |
| OpenID4VCI auth-code |
openid4vci.go:105 oauthClientStateStore().Put(state, ...) |
yes (user authorizes at external issuer) |
| OpenID4VP authorization-code (user) |
user.go:121 oauthClientStateStore().Put(ClientState, ...) |
yes (user login) |
Proposal
Use a timeout that matches the flow type:
- Frontend / interactive flows (the two above): ~5 minutes.
- Back-end / service-to-service flows (e.g. RFC021 VP-based access tokens): keep ~1 minute.
If a single store can't cleanly distinguish the two, split into two named constants/stores (e.g. oauthFrontendFlowTimeout = 5 min vs the existing oAuthFlowTimeout = 1 min) and pick per flow.
Considerations
- A longer TTL widens the replay window for a leaked
state, but state is a single-use unguessable nonce consumed on first callback — 5 min is standard for auth-code flows.
- Confirm no other caller of
oAuthFlowTimeout relies on the 1-minute value before changing it (grep shows only oauthClientStateStore).
Related
Context
Surfaced while drafting #4339 (org-agnostic
/oauth2/callback). Not part of that change — filed separately.Problem
The state to
OAuthSessionmapping for the OAuth authorization-code callback flows lives inoauthClientStateStorewith a hard 1-minute TTL:auth/api/iam/user.go:43—oAuthFlowTimeout = time.Minuteauth/api/iam/user.go:152—oauthClientStateStore()=GetStore(oAuthFlowTimeout, ...)storage/session.go:102—Putsetsstore.WithExpiration(opts.ttl)once; no refresh on read, so it is an absolute expiry from the moment the node redirects the user out.That 1 minute has to cover the entire interactive round-trip: redirect to the external IdP, the user authenticates (password, possibly MFA, consent), then redirect back to the callback. For a human login (e.g. AET / OpenIddict) that is far too tight — the callback then fails with
invalid_request"invalid or expired state" (api.go:293).Both auth-code callback flows write to this same store, so both inherit the 1-minute window:
openid4vci.go:105oauthClientStateStore().Put(state, ...)user.go:121oauthClientStateStore().Put(ClientState, ...)Proposal
Use a timeout that matches the flow type:
If a single store can't cleanly distinguish the two, split into two named constants/stores (e.g.
oauthFrontendFlowTimeout= 5 min vs the existingoAuthFlowTimeout= 1 min) and pick per flow.Considerations
state, butstateis a single-use unguessable nonce consumed on first callback — 5 min is standard for auth-code flows.oAuthFlowTimeoutrelies on the 1-minute value before changing it (grep shows onlyoauthClientStateStore).Related
stateas the sole binding, making this window load-bearing)