Require step-up authentication
Require step-up authentication
Some operations need more than a valid token. Deleting an organization, paying an invoice or reading a vault secret should need a login with a second factor, and often a recent one. RFC 9470 standardises the round trip:
- The resource server gets a token whose login falls short and answers
401witherror="insufficient_user_authentication", naming what it needs:acr_values(the assurance level) and/ormax_age(how many seconds old the login may be). - The client sends the person back to
/authorizewith those two parameters. /authorizechecks the person's session against them. If the login is too old, the person signs in again. If it is too weak, they add a second factor.- The token endpoint issues a token whose
acrandauth_timedescribe the login that actually happened, and the resource server accepts it.
This package provides one value object, AuthenticationRequirement, that both ends use.
Both ends then apply the same rules, so the authorization server never issues a token
that the resource server will refuse again.
| Piece | Who |
|---|---|
acr and auth_time on access tokens, ID tokens and introspection |
this package |
max_age validation at POST /oauth/par |
this package |
AuthenticationRequirement and BearerChallenge |
this package |
| The resource server's check and its 401 | you, with the two classes above |
The /authorize decision and the sign-in / second-factor screens |
you, with assessSession() |
The vocabulary
acr values are the classes in discovery's acr_values_supported:
| Value | Meaning |
|---|---|
urn:cbox-id:aal1 |
A single factor (password, social sign-in, SSO). |
urn:cbox-id:aal2 |
A second factor was used at login: the session's amr holds mfa, otp or passkey. |
aal2 satisfies an aal1 requirement. If a request lists several classes, the strongest
one this server asserts is required. Values this server does not assert are ignored, as
OIDC treats acr_values. A requirement that names only foreign values therefore demands
no class at all. Name a class from acr_values_supported.
The token's acr is always derived from the login's amr, never copied from what
was requested. A token cannot claim a higher level than the person reached.
1. The resource server: check and challenge
Validate the token as you already do (live, audienced to you, right scope). Then ask whether the login behind it is good enough:
use Cbox\Id\OAuthServer\Enums\AuthenticationContextClass;
use Cbox\Id\OAuthServer\Support\BearerChallenge;
use Cbox\Id\OAuthServer\ValueObjects\AuthenticationRequirement;
// A second factor, within the last five minutes.
$requirement = AuthenticationRequirement::of(AuthenticationContextClass::Aal2, maxAge: 300);
$assessment = $requirement->assessToken($introspection); // the Introspection you validated
if (! $assessment->isSatisfied()) {
return response()->json(['error' => 'insufficient_user_authentication'], 401,
$assessment->challenge(BearerChallenge::for($resource))->headers());
}
The header follows RFC 9470 §3. Its max_age is quoted, as in the RFC's own example:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://acme.example/.well-known/oauth-protected-resource/mcp", error="insufficient_user_authentication", error_description="The requested authentication context (urn:cbox-id:aal2) was not met.", acr_values="urn:cbox-id:aal2", max_age="300"
assessToken() reads acr and auth_time from the token's claims, which is the same
Introspection the package's introspector returns. It fails closed:
- With
max_age, a token withoutauth_timecounts as too old. - With an
acrrequirement, a token withoutacr(aclient_credentialstoken, a device-grant token) counts as not met.
The challenge always names the whole requirement, even when only one half failed. The client turns the challenge into its next authorization request, so it has to ask for everything at once.
To build a challenge by hand, use
BearerChallenge::for($resource)->insufficientUserAuthentication(['urn:cbox-id:aal2'], 300, 'Step up'),
or the withAcrValues() / withMaxAge() withers. Status 401 is the caller's to send.
If your resource server reads JWTs directly instead of introspecting, build the
Introspection from the verified claims, or call
$requirement->assess($claims['acr'] ?? null, $claims['auth_time'] ?? null).
Several levels on one resource
Requirements are per operation, not per resource. Reading may need nothing, while
deleting needs aal2 within five minutes:
$requirement = match ($operation) {
'tools/call:delete_project' => AuthenticationRequirement::of(AuthenticationContextClass::Aal2, 300),
'tools/call:pay_invoice' => AuthenticationRequirement::of(AuthenticationContextClass::Aal2),
default => AuthenticationRequirement::none(),
};
2. The client: re-authorize
Per RFC 9470 §4, a client that receives the challenge copies acr_values and max_age
into a new authorization request:
https://acme.example/oauth/authorize?response_type=code&client_id=…&scope=…
&code_challenge=…&code_challenge_method=S256&resource=https://acme.example/mcp
&acr_values=urn:cbox-id:aal2&max_age=300
Pushed requests (POST /oauth/par) carry them the same way. PAR refuses a max_age
that is not a non-negative integer with 400 invalid_request, so the client learns about
the mistake before anyone is sent to sign in.
3. /authorize: decide, then send the person to the right screen
/authorize is your application's. Two calls cover step-up:
use Cbox\Id\OAuthServer\Exceptions\InvalidAuthenticationRequirement;
use Cbox\Id\OAuthServer\ValueObjects\AuthenticationRequirement;
// Read once, from the pushed payload when there is one (RFC 9126: those ARE the request).
try {
$requirement = AuthenticationRequirement::fromAuthorizationRequest($pushed ?? $request);
} catch (InvalidAuthenticationRequirement $e) {
return $this->redirectError($redirectUri, $e->error, $state, $e->getMessage()); // invalid_request
}
// …after the person is known to be signed in:
$assessment = $requirement->assessSession($session); // Cbox\Id\Identity\Models\Session
if (! $assessment->isSatisfied()) {
// prompt=none, or the person already went round once and is still short: tell the client.
if ($silent || $alreadyRetried) {
return $this->redirectError($redirectUri, $assessment->authorizationError(), $state,
$assessment->errorDescription());
}
return $assessment->requiresReauthentication()
? $this->sendToSignIn($request) // max_age exceeded: a fresh sign-in
: $this->sendToSecondFactor($request); // acr not met: add a factor
}
Hold on to the requirement while the person is away. Put acr_values and max_age back
on the resumed request, or keep the pushed payload, and assess the session again
before you issue the code. A consent screen can stay open past max_age.
authorizationError() returns:
| Shortfall | Error | Remedy |
|---|---|---|
max_age exceeded, or the session cannot be dated |
login_required (OIDC Core §3.1.2.6) |
Sign in again. |
| Requested class not reached | unmet_authentication_requirements (RFC 9470 §5) |
Sign in with a second factor. |
If both fall short, the age wins: a fresh sign-in can bring the second factor with it.
insufficient_user_authentication is never the answer here, because that error belongs
to the resource server.
Then issue the code from the session, exactly as before:
$code = app(AuthorizationCodes::class)->issue(
$client->client_id, $userId, $organizationId, $redirectUri, $scopes,
$codeChallenge, 'S256', $nonce,
$session->created_at?->getTimestamp(), // → auth_time
array_values($session->amr), // → acr, derived
$resource,
sessionId: $session->id,
);
Nothing else is stored on the code. The token endpoint derives acr from the amr, so a
password-only session can never produce an aal2 token, whatever was requested.
max_age leeway
AuthenticationRequirement::DEFAULT_MAX_AGE_LEEWAY_SECONDS (60) is added to max_age on
both sides. It covers the redirect between a fresh sign-in and the resumed request, the
time until the token reaches the resource, and clock skew. Without it, max_age=0 could
never be met. Both assess*() methods take a leeway argument. If you change it, use the
same value on both sides.
prompt=login
prompt=login is not part of the requirement. It asks for a sign-in during this
request, and only your flow knows whether one happened, so handle it there as you do today.
4. The token that satisfies it
| Access token | ID token | Introspection | |
|---|---|---|---|
acr |
when the login recorded amr |
when the login recorded amr |
when the token carries it |
auth_time |
when the login is known | when the login is known | when the token carries it |
Which grants carry them:
- Authorization code, and every refresh of one: both. A refresh keeps the
original login's values (RFC 9470 §6.1, OIDC Core §12.2). A refreshed token is
never fresher than its login, so a resource with a
max_agesends the client back to/authorizeonce the login ages out, however recently the token was refreshed. - CIBA:
auth_time(the approval time), noacr. The approval records no authentication methods. - Device code, token exchange, support sessions: neither. Nobody's login is recorded on those grants, so a resource that requires step-up refuses them.
client_credentials: neither. There is no person.
Both claims are reserved, so a token-minting hook cannot add or change them.
Testing
use Cbox\Id\OAuthServer\ValueObjects\AuthenticationRequirement;
use Cbox\Id\OAuthServer\ValueObjects\Introspection;
it('demands a second factor for deletion', function (): void {
$token = Introspection::active('user_1', 'cid', ['mcp:write'], ['acr' => 'urn:cbox-id:aal1', 'auth_time' => time()]);
$assessment = AuthenticationRequirement::of('urn:cbox-id:aal2')->assessToken($token);
expect($assessment->requiresStepUp())->toBeTrue()
->and($assessment->challenge()?->header())->toContain('error="insufficient_user_authentication"');
});
Limits
- Two levels.
aal1andaal2are vendor URNs, not standard assurance-level identifiers. There is no phishing-resistant-only class, so a passkey and a one-time code both count asaal2. - No
claimsparameter. Anacrrequested as an essential claim (claims={"id_token":{"acr":{"essential":true,…}}}) is not read, and discovery advertisesclaims_parameter_supported: false. Useacr_values. - The resource server's half is code you write. The package has no route middleware for your own endpoints. The check above is three lines inside the guard you already have.
- RFC 9728 protected resource metadata has no field for a resource's authentication requirements, so nothing is advertised there. Requirements reach the client only through the challenge.