Description
The max_age parameter is honoured on the direct GET /oauth2/authorize path but is silently
discarded when the same request is pushed through POST /oauth2/par and redeemed via
request_uri. A relying party that uses PAR cannot force re-authentication.
Root cause
backend/internal/oauth/oauth2/par/service.go:132-149 builds oauth2model.OAuthParameters from
the pushed parameters, assigning State, ClientID, RedirectURI, RedirectURIProvided, ResponseType, StandardScopes, PermissionScopes, CodeChallenge, CodeChallengeMethod, Resources, ClaimsRequest, ClaimsLocales, Nonce, AcrValues, DPoPJkt, Prompt — but never MaxAge.
MaxAge exists on the struct (oauth2/model/parameter.go:31) and the direct path both sets it
(authz/service.go:270,324) and maps it into flow runtime data (authz/service.go:424-426). Via
PAR, runtimeData["max_age"] is therefore never populated, so
checkAssurance has no constraint to enforce.
Reproduction
One cookie jar, one SSO session 25 seconds old, both requests issued in the same wall-clock second:
T0 (real authentication) = 1786591879
DIRECT max_age=1 at t=1786591904 -> FLOW ERROR FET-1082 [session age 25s]
PAR max_age=1 at t=1786591904 -> SUCCESS, tokens issued [session age 25s]
Identical session, identical instant, identical flow — the only variable is direct vs PAR.
# with a live SSO session >= 2s old in $JAR
curl -sk -c $JAR -b $JAR "https://localhost:8090/oauth2/authorize?client_id=qa-b2-client&redirect_uri=...&response_type=code&scope=openid&max_age=1"
# -> flow errors with FET-1082
RU=$(curl -sk -u 'qa-b2-client:...' \
-d 'client_id=qa-b2-client&redirect_uri=...&response_type=code&scope=openid&max_age=1' \
https://localhost:8090/oauth2/par | jq -r .request_uri)
curl -sk -c $JAR -b $JAR "https://localhost:8090/oauth2/authorize?client_id=qa-b2-client&request_uri=$RU"
# -> flow COMPLETEs, tokens issued
Positive control: max_age=3600 on the direct path against the same reused session succeeds,
so the direct-path rejection is a genuine threshold check rather than a blanket failure.
Expected
par/service.go should copy MaxAge alongside AcrValues, so a pushed request enforces the same
constraint as the equivalent direct request.
Impact
An RP that adopts PAR (which OAuth 2.1 and FAPI encourage) silently loses the ability to require
recent authentication. The failure is silent — no error, no warning.
Description
The
max_ageparameter is honoured on the directGET /oauth2/authorizepath but is silentlydiscarded when the same request is pushed through
POST /oauth2/parand redeemed viarequest_uri. A relying party that uses PAR cannot force re-authentication.Root cause
backend/internal/oauth/oauth2/par/service.go:132-149buildsoauth2model.OAuthParametersfromthe pushed parameters, assigning
State, ClientID, RedirectURI, RedirectURIProvided, ResponseType, StandardScopes, PermissionScopes, CodeChallenge, CodeChallengeMethod, Resources, ClaimsRequest, ClaimsLocales, Nonce, AcrValues, DPoPJkt, Prompt— but neverMaxAge.MaxAgeexists on the struct (oauth2/model/parameter.go:31) and the direct path both sets it(
authz/service.go:270,324) and maps it into flow runtime data (authz/service.go:424-426). ViaPAR,
runtimeData["max_age"]is therefore never populated, socheckAssurancehas no constraint to enforce.Reproduction
One cookie jar, one SSO session 25 seconds old, both requests issued in the same wall-clock second:
Identical session, identical instant, identical flow — the only variable is direct vs PAR.
Positive control:
max_age=3600on the direct path against the same reused session succeeds,so the direct-path rejection is a genuine threshold check rather than a blanket failure.
Expected
par/service.goshould copyMaxAgealongsideAcrValues, so a pushed request enforces the sameconstraint as the equivalent direct request.
Impact
An RP that adopts PAR (which OAuth 2.1 and FAPI encourage) silently loses the ability to require
recent authentication. The failure is silent — no error, no warning.