Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
"psr/log": "^3",
"psr/simple-cache": "^3",
"simplesamlphp/composer-module-installer": "^1.3",
"simplesamlphp/openid": "~0.3.8",
"simplesamlphp/openid": "~0.3.12",
"spomky-labs/base64url": "^2.0",
"symfony/cache": "^7.4",
"symfony/expression-language": "^7.4",
Expand Down
30 changes: 30 additions & 0 deletions docs/6-oidc-upgrade.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,36 @@ core login form simply ignore it, and an incorrect value can be corrected by
the user (or the login fails with invalid credentials). No error is raised if
the parameter is present but unused. This also applies to forced
re-authentication triggered by `prompt=login` or an expired `max_age`.
- Support for the `id_token_hint` parameter on the authorization endpoint
(previously ignored; it was already supported on the end session endpoint). The
parameter carries an ID Token previously issued by this OP as a hint about the
End-User's session with the requesting client. When present, it is validated
(issuer, signature, and — since the hint represents a session with the
requesting client — that the client is an audience of the hint) and, after
authentication, the subject (`sub`) that would be issued for the authenticated
End-User is compared to the subject in the `id_token_hint`. If
they differ, a `login_required` error is returned rather than issuing a
token/code for a different End-User than the one the hint identifies. This
applies to all prompt modes: with `prompt=none` it prevents a silent response
for a mismatched cookie session, and with interactive authentication it rejects
the request when a different End-User authenticated than requested (the client
can then retry, e.g. with `prompt=login`). An otherwise-valid `id_token_hint`
whose ID Token has expired is accepted (as recommended by the specification,
since a hint is commonly sent after it has expired); its signature, issuer and
`nbf`/`iat` timestamps are still validated. A malformed, wrongly-issued or
improperly-signed `id_token_hint` results in an `invalid_request` error
redirected back to the client.
- The ID Token subject (`sub`) is now resolved consistently for a given
End-User, independently of the flow, the granted scopes and the client's
`add_claims_to_id_token` setting. Previously, when a `sub` claim mapping was
configured, the mapped value was only applied if the user's claims were
released in the ID Token, so the same End-User could receive different `sub`
values depending on the flow and client. Deployments that did not customize the
`sub` claim mapping are unaffected (the user identifier attributes are used for
`sub` by default, which already produced the same value in both cases). If you
did map `sub` to a different attribute, clients relying on the previously
inconsistent value may see a changed `sub` in flows where the claims were not
released.
- Logging has been improved for authentication flows. It should now be easier
to find information about what went wrong by looking at the relevant log entries.

Expand Down
71 changes: 71 additions & 0 deletions src/Controllers/AuthorizationController.php
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,13 @@ public function __invoke(ServerRequestInterface $request): ResponseInterface
$state ??= $this->authenticationService->manageState($queryParameters);
$authorizationRequest = $this->authenticationService->getAuthorizationRequestFromState($state);

// Validate any id_token_hint against the authenticated End-User before the user is resolved and (as a side
// effect) associated with the client, so that a mismatched request leaves no relying party association
// behind (which could otherwise later receive a back-channel logout for that End-User).
if ($authorizationRequest instanceof AuthorizationRequest) {
$this->validateIdTokenHint($authorizationRequest, $state);
}

$user = $this->authenticationService->getAuthenticateUser($state);

$authorizationRequest->setUser($user);
Expand Down Expand Up @@ -180,6 +187,70 @@ protected function validatePostAuthnAuthorizationRequest(AuthorizationRequest $a
$this->validateAcr($authorizationRequest);
}

/**
* Validate the `id_token_hint` authorization request parameter (if any) against the authenticated End-User.
*
* The hint is an ID Token previously issued by this OP; its issuer and signature were already validated early
* (IdTokenHintRule) and its subject carried on the authorization request. Here, using the released post-authproc
* attributes (the same ones from which the issued subject is derived), we verify that the authenticated End-User
* matches the subject in the hint. Per OpenID Connect Core, the request must not be satisfied for a different
* End-User than the one the hint identifies; if they differ we return `login_required` (the specification's
* suggested error) rather than issuing a token/code for the wrong user. This applies to all prompt modes: with
* `prompt=none` it prevents a silent response for a mismatched cookie session, and with interactive
* authentication it rejects the request when a different End-User authenticated than the hint asked for (the
* client can then retry, e.g. with `prompt=login`).
*
* This runs before the End-User is resolved and associated with the client, so a mismatch does not leave a
* relying party association behind.
*
* @param array<array-key,mixed>|null $state
* @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException
*/
protected function validateIdTokenHint(AuthorizationRequest $authorizationRequest, ?array $state): void
{
$hintSubject = $authorizationRequest->getIdTokenHintSubject();
if ($hintSubject === null) {
return;
}

// No released attributes means no End-User to match the hint against, so the request can not be satisfied
// for the End-User the hint identifies (subjectMatchesAttributes() returns false for an empty set).
$attributes = (isset($state['Attributes']) && is_array($state['Attributes'])) ? $state['Attributes'] : [];

if ($this->authenticationService->subjectMatchesAttributes($hintSubject, $attributes)) {
return;
}

$this->loggerService->notice(
'Authorization request rejected: the authenticated End-User does not match the `id_token_hint` subject.',
['client_id' => $authorizationRequest->getClient()->getIdentifier()],
);

throw OidcServerException::loginRequired(
'Authenticated End-User does not match the id_token_hint subject.',
$this->resolveRedirectUri($authorizationRequest),
null,
$authorizationRequest->getState(),
$authorizationRequest->getResponseMode(),
);
}

/**
* Resolve the redirect URI to use for redirected error responses: the one validated for this request, or the
* client's first registered redirect URI as a fallback.
*/
protected function resolveRedirectUri(AuthorizationRequest $authorizationRequest): ?string
{
$redirectUri = $authorizationRequest->getRedirectUri();
if ($redirectUri !== null) {
return $redirectUri;
}

$clientRedirectUri = $authorizationRequest->getClient()->getRedirectUri();

return is_array($clientRedirectUri) ? ($clientRedirectUri[0] ?? null) : $clientRedirectUri;
}

/**
* @throws \Exception
*/
Expand Down
13 changes: 13 additions & 0 deletions src/Server/Grants/AuthCodeGrant.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
use SimpleSAML\Module\oidc\Server\RequestRules\Rules\CodeChallengeMethodRule;
use SimpleSAML\Module\oidc\Server\RequestRules\Rules\CodeChallengeRule;
use SimpleSAML\Module\oidc\Server\RequestRules\Rules\CodeVerifierRule;
use SimpleSAML\Module\oidc\Server\RequestRules\Rules\IdTokenHintRule;
use SimpleSAML\Module\oidc\Server\RequestRules\Rules\IssuerStateRule;
use SimpleSAML\Module\oidc\Server\RequestRules\Rules\LoginHintRule;
use SimpleSAML\Module\oidc\Server\RequestRules\Rules\MaxAgeRule;
Expand Down Expand Up @@ -861,6 +862,9 @@ public function validateAuthorizationRequestWithRequestRules(
// LoginHintRule must run before PromptRule and MaxAgeRule, which consume its result when they
// trigger re-authentication (prompt=login / expired max_age) to pre-fill the username.
LoginHintRule::class,
// IdTokenHintRule must run before PromptRule, which consumes its result to enforce that a prompt=none
// request is only satisfied for the End-User identified by the id_token_hint.
IdTokenHintRule::class,
PromptRule::class,
MaxAgeRule::class,
ScopeRule::class,
Expand Down Expand Up @@ -1000,6 +1004,15 @@ public function validateAuthorizationRequestWithRequestRules(
$this->loggerService->debug('AuthCodeGrant: Login hint present: ', ['loginHintPresent' => $loginHintPresent]);
$authorizationRequest->setLoginHint($loginHint);

// Carry the id_token_hint subject (if the hint was provided and validated by IdTokenHintRule) so that,
// after authentication, the authenticated End-User can be verified against the one the hint identifies.
$idTokenHint = $resultBag->getOrFail(IdTokenHintRule::class)->getValue();
$this->loggerService->debug(
'AuthCodeGrant: ID Token hint present: ',
['idTokenHintPresent' => $idTokenHint !== null],
);
$authorizationRequest->setIdTokenHintSubject($idTokenHint?->getSubject());


$authorizationRequest->setIsVciRequest($isVciAuthorizationCodeRequest);
$flowType = $isVciAuthorizationCodeRequest ?
Expand Down
9 changes: 9 additions & 0 deletions src/Server/Grants/ImplicitGrant.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
use SimpleSAML\Module\oidc\Server\RequestRules\Rules\AddClaimsToIdTokenRule;
use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRedirectUriRule;
use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRule;
use SimpleSAML\Module\oidc\Server\RequestRules\Rules\IdTokenHintRule;
use SimpleSAML\Module\oidc\Server\RequestRules\Rules\LoginHintRule;
use SimpleSAML\Module\oidc\Server\RequestRules\Rules\MaxAgeRule;
use SimpleSAML\Module\oidc\Server\RequestRules\Rules\PromptRule;
Expand Down Expand Up @@ -130,6 +131,9 @@ public function validateAuthorizationRequestWithRequestRules(
// LoginHintRule must run before PromptRule and MaxAgeRule, which consume its result when they
// trigger re-authentication (prompt=login / expired max_age) to pre-fill the username.
LoginHintRule::class,
// IdTokenHintRule must run before PromptRule, which consumes its result to enforce that a prompt=none
// request is only satisfied for the End-User identified by the id_token_hint.
IdTokenHintRule::class,
PromptRule::class,
MaxAgeRule::class,
RequiredOpenIdScopeRule::class,
Expand Down Expand Up @@ -201,6 +205,11 @@ public function validateAuthorizationRequestWithRequestRules(
$loginHint = $resultBag->getOrFail(LoginHintRule::class)->getValue();
$authorizationRequest->setLoginHint($loginHint);

// Carry the id_token_hint subject (if the hint was provided and validated by IdTokenHintRule) so that,
// after authentication, the authenticated End-User can be verified against the one the hint identifies.
$idTokenHint = $resultBag->getOrFail(IdTokenHintRule::class)->getValue();
$authorizationRequest->setIdTokenHintSubject($idTokenHint?->getSubject());

$responseMode = $resultBag->getOrFail(ResponseModeRule::class)->getValue();
$authorizationRequest->setResponseMode($responseMode);

Expand Down
67 changes: 59 additions & 8 deletions src/Server/RequestRules/Rules/IdTokenHintRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
use SimpleSAML\OpenID\Jwks;

/**
* @extends AbstractRule<\SimpleSAML\OpenID\Core\IdToken|null>
* @extends AbstractRule<\SimpleSAML\OpenID\Core\IdTokenHint|null>
*/
class IdTokenHintRule extends AbstractRule
{
Expand Down Expand Up @@ -52,6 +52,13 @@ public function checkRule(
): ?Result {
$state = $currentResultBag->getOrFail(StateRule::class)->getValue();

// When this rule runs in the authorization flow, the redirect URI has already been validated and is
// available in the result bag, so validation errors can be redirected back to the client (as required at
// the authorization endpoint). In the logout (end session) flow there is no ClientRedirectUriRule, so this
// resolves to null and the error is returned directly, preserving the previous behavior.
$redirectUriValue = $currentResultBag->get(ClientRedirectUriRule::class)?->getValue();
$redirectUri = is_string($redirectUriValue) ? $redirectUriValue : null;

$idTokenHintParam = $this->requestParamsResolver->getAsStringBasedOnAllowedMethods(
ParamsEnum::IdTokenHint->value,
$request,
Expand All @@ -63,52 +70,96 @@ public function checkRule(
}

if (empty($idTokenHintParam)) {
$loggerService->notice('End session request rejected: `id_token_hint` was provided but empty.');
$loggerService->notice('Request rejected: `id_token_hint` was provided but empty.');
throw OidcServerException::invalidRequest(
ParamsEnum::IdTokenHint->value,
'Received empty id_token_hint',
null,
null,
$redirectUri,
$state,
$responseMode,
);
}

$jwks = $this->jwks->jwksDecoratorFactory()->fromJwkDecorators(
...$this->moduleConfig->getProtocolSignatureKeyPairBag()->getAllPublicKeys(),
)->jsonSerialize();

$idTokenHint = $this->core->idTokenFactory()->fromToken($idTokenHintParam);
// Parsing constructs and validates the ID Token Hint (structure and required claims), throwing on any
// problem. We translate those failures into a protocol-level invalid_request error (which is redirected back
// to the client in the authorization flow) instead of letting a raw exception surface as an HTTP 500. The
// dedicated IdTokenHint abstraction deliberately does not validate the `exp` claim, so an otherwise-valid but
// expired hint is accepted (as recommended by OpenID Connect Core, since a hint is commonly sent after it has
// expired); the `nbf` and `iat` timestamps are still validated.
try {
$idTokenHint = $this->core->idTokenHintFactory()->fromToken($idTokenHintParam);
} catch (\Throwable $exception) {
$loggerService->notice(
'Request rejected: `id_token_hint` could not be parsed or validated.',
['exception' => $exception->getMessage()],
);
throw OidcServerException::invalidRequest(
ParamsEnum::IdTokenHint->value,
$exception->getMessage(),
null,
$redirectUri,
$state,
$responseMode,
);
}

if ($idTokenHint->getIssuer() !== $this->moduleConfig->getIssuer()) {
$loggerService->notice(
'End session request rejected: `id_token_hint` was not issued by this OP.',
'Request rejected: `id_token_hint` was not issued by this OP.',
['issuer' => $idTokenHint->getIssuer(), 'expected_issuer' => $this->moduleConfig->getIssuer()],
);
throw OidcServerException::invalidRequest(
ParamsEnum::IdTokenHint->value,
'Invalid ID Token Hint Issuer',
null,
null,
$redirectUri,
$state,
$responseMode,
);
}

try {
$idTokenHint->verifyWithKeySet($jwks);
} catch (\Throwable $exception) {
$loggerService->notice(
'End session request rejected: `id_token_hint` signature verification failed.',
'Request rejected: `id_token_hint` signature verification failed.',
['exception' => $exception->getMessage()],
);
throw OidcServerException::invalidRequest(
ParamsEnum::IdTokenHint->value,
$exception->getMessage(),
null,
null,
$redirectUri,
$state,
$responseMode,
);
}

// In the authorization flow the requesting client is known (ClientRule). An id_token_hint represents the
// End-User's session with the requesting client, so require that client to be an audience of the hint. This
// binds the hint to the requesting client and rejects a token that was issued to a different client. In the
// logout (end session) flow there is no ClientRule in the result bag, so this is skipped, preserving that
// flow's behavior.
$client = $currentResultBag->get(ClientRule::class)?->getValue();
if ($client !== null && !in_array($client->getIdentifier(), $idTokenHint->getAudience(), true)) {
$loggerService->notice(
'Request rejected: `id_token_hint` was not issued to the requesting client.',
['client_id' => $client->getIdentifier()],
);
throw OidcServerException::invalidRequest(
ParamsEnum::IdTokenHint->value,
'ID Token Hint audience does not include the requesting client',
null,
$redirectUri,
$state,
$responseMode,
);
}

return new Result($this->getKey(), $idTokenHint);
}
Expand Down
Loading
Loading