Skip to content
Open
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
10 changes: 10 additions & 0 deletions docs/6-oidc-upgrade.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,16 @@ language tags). Note: use the canonical SimpleSAMLphp locale codes in
(`pt-br`, `zh-tw`), which SimpleSAMLphp itself warns against, are not fully
supported by `ui_locales` on the authorization endpoint, because the language
cookie is validated against the raw configured codes.
- Support for the `login_hint` parameter on the authorization endpoint
(previously ignored). The parameter carries a hint about the login identifier
the End-User might use to log in. When present, it is propagated to the
SimpleSAMLphp login page as the pre-filled username (using the standard
`core:username` state key consumed by the core UserPass login form). Per
specification this is best-effort: authentication sources that do not use the
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`.
- 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
2 changes: 2 additions & 0 deletions src/Factories/RequestRulesManagerFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
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;
use SimpleSAML\Module\oidc\Server\RequestRules\Rules\PostLogoutRedirectUriRule;
use SimpleSAML\Module\oidc\Server\RequestRules\Rules\PromptRule;
Expand Down Expand Up @@ -171,6 +172,7 @@ private function getDefaultRules(): array
),
new PostLogoutRedirectUriRule($this->requestParamsResolver, $this->helpers, $this->clientRepository),
new UiLocalesRule($this->requestParamsResolver, $this->helpers),
new LoginHintRule($this->requestParamsResolver, $this->helpers),
new AcrValuesRule($this->requestParamsResolver, $this->helpers),
new ScopeOfflineAccessRule($this->requestParamsResolver, $this->helpers),
new ClientAuthenticationRule(
Expand Down
10 changes: 10 additions & 0 deletions src/Server/Grants/AuthCodeGrant.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
use SimpleSAML\Module\oidc\Server\RequestRules\Rules\CodeChallengeRule;
use SimpleSAML\Module\oidc\Server\RequestRules\Rules\CodeVerifierRule;
use SimpleSAML\Module\oidc\Server\RequestRules\Rules\IssuerStateRule;
use SimpleSAML\Module\oidc\Server\RequestRules\Rules\LoginHintRule;
use SimpleSAML\Module\oidc\Server\RequestRules\Rules\MaxAgeRule;
use SimpleSAML\Module\oidc\Server\RequestRules\Rules\PromptRule;
use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequestedClaimsRule;
Expand Down Expand Up @@ -857,6 +858,9 @@ public function validateAuthorizationRequestWithRequestRules(
ClientIdRule::class,
ResponseTypeRule::class,
RequestObjectRule::class,
// 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,
PromptRule::class,
MaxAgeRule::class,
ScopeRule::class,
Expand Down Expand Up @@ -990,6 +994,12 @@ public function validateAuthorizationRequestWithRequestRules(
$this->loggerService->debug('AuthCodeGrant: UI locales: ', ['uiLocales' => $uiLocales]);
$authorizationRequest->setUiLocales($uiLocales);

$loginHint = $resultBag->getOrFail(LoginHintRule::class)->getValue();
// Only log presence, not the value: login_hint is commonly a PII.
$loginHintPresent = $loginHint !== null;
$this->loggerService->debug('AuthCodeGrant: Login hint present: ', ['loginHintPresent' => $loginHintPresent]);
$authorizationRequest->setLoginHint($loginHint);


$authorizationRequest->setIsVciRequest($isVciAuthorizationCodeRequest);
$flowType = $isVciAuthorizationCodeRequest ?
Expand Down
7 changes: 7 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\LoginHintRule;
use SimpleSAML\Module\oidc\Server\RequestRules\Rules\MaxAgeRule;
use SimpleSAML\Module\oidc\Server\RequestRules\Rules\PromptRule;
use SimpleSAML\Module\oidc\Server\RequestRules\Rules\RequestedClaimsRule;
Expand Down Expand Up @@ -126,6 +127,9 @@ public function validateAuthorizationRequestWithRequestRules(
$rulesToExecute = [
ScopeRule::class,
RequestObjectRule::class,
// 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,
PromptRule::class,
MaxAgeRule::class,
RequiredOpenIdScopeRule::class,
Expand Down Expand Up @@ -194,6 +198,9 @@ public function validateAuthorizationRequestWithRequestRules(
$uiLocales = $resultBag->getOrFail(UiLocalesRule::class)->getValue();
$authorizationRequest->setUiLocales($uiLocales);

$loginHint = $resultBag->getOrFail(LoginHintRule::class)->getValue();
$authorizationRequest->setLoginHint($loginHint);

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

Expand Down
41 changes: 41 additions & 0 deletions src/Server/RequestRules/Rules/LoginHintRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

declare(strict_types=1);

namespace SimpleSAML\Module\oidc\Server\RequestRules\Rules;

use Psr\Http\Message\ServerRequestInterface;
use SimpleSAML\Module\oidc\Server\RequestRules\Interfaces\ResultBagInterface;
use SimpleSAML\Module\oidc\Server\RequestRules\Result;
use SimpleSAML\Module\oidc\Server\ResponseModes\QueryResponseMode;
use SimpleSAML\Module\oidc\Server\ResponseModes\ResponseModeInterface;
use SimpleSAML\Module\oidc\Services\LoggerService;
use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum;
use SimpleSAML\OpenID\Codebooks\ParamsEnum;

/**
* @extends AbstractRule<string|null>
*/
class LoginHintRule extends AbstractRule
{
/**
* @inheritDoc
*
* @param ResponseModeInterface $responseMode
* @param HttpMethodsEnum[] $allowedServerRequestMethods
*/
public function checkRule(
ServerRequestInterface $request,
ResultBagInterface $currentResultBag,
LoggerService $loggerService,
array $data = [],
ResponseModeInterface $responseMode = new QueryResponseMode(),
array $allowedServerRequestMethods = [HttpMethodsEnum::GET],
): ?Result {
return new Result($this->getKey(), $this->requestParamsResolver->getAsStringBasedOnAllowedMethods(
ParamsEnum::LoginHint->value,
$request,
$allowedServerRequestMethods,
));
}
}
5 changes: 5 additions & 0 deletions src/Server/RequestRules/Rules/MaxAgeRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,11 @@ public function checkRule(
$this->sspBridge->utils()->http()->getSelfURLNoQuery(),
$requestParams,
);
// Propagate login_hint (resolved earlier by LoginHintRule) as the pre-filled username.
$loginHint = $currentResultBag->getOrFail(LoginHintRule::class)->getValue();
if ($loginHint !== null) {
$loginParams[AuthenticationService::LOGIN_PARAM_USERNAME] = $loginHint;
}

$this->authenticationService->authenticateForClient($client, $loginParams);
}
Expand Down
5 changes: 5 additions & 0 deletions src/Server/RequestRules/Rules/PromptRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,11 @@ public function checkRule(
$this->sspBridge->utils()->http()->getSelfURLNoQuery(),
$requestParams,
);
// Propagate login_hint (resolved earlier by LoginHintRule) as the pre-filled username.
$loginHint = $currentResultBag->getOrFail(LoginHintRule::class)->getValue();
if ($loginHint !== null) {
$loginParams[AuthenticationService::LOGIN_PARAM_USERNAME] = $loginHint;
}

$this->authenticationService->authenticateForClient($client, $loginParams);
}
Expand Down
15 changes: 15 additions & 0 deletions src/Server/RequestTypes/AuthorizationRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ class AuthorizationRequest extends OAuth2AuthorizationRequest
*/
protected ?string $uiLocales = null;

/**
* Hint about the login identifier the End-User might use to log in, as requested using the login_hint parameter.
*/
protected ?string $loginHint = null;

/**
* ACR used during authn.
*/
Expand Down Expand Up @@ -241,6 +246,16 @@ public function setUiLocales(?string $uiLocales): void
$this->uiLocales = $uiLocales;
}

public function getLoginHint(): ?string
{
return $this->loginHint;
}

public function setLoginHint(?string $loginHint): void
{
$this->loginHint = $loginHint;
}

public function getAcr(): ?string
{
return $this->acr;
Expand Down
30 changes: 29 additions & 1 deletion src/Services/AuthenticationService.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@

class AuthenticationService
{
/**
* Login parameter (state array key) used to pre-fill the username on the SimpleSAMLphp core UserPass login form.
*/
public const string LOGIN_PARAM_USERNAME = 'core:username';

/**
* ID of auth source used during authn.
*/
Expand Down Expand Up @@ -100,7 +105,7 @@ public function processRequest(
$this->authSourceId = $authSimple->getAuthSource()->getAuthId();

if (! $authSimple->isAuthenticated()) {
$this->authenticate($authSimple);
$this->authenticate($authSimple, $this->resolveLoginParams($authorizationRequest));
} elseif ($this->sessionService->getIsAuthnPerformedInPreviousRequest()) {
$this->sessionService->setIsAuthnPerformedInPreviousRequest(false);

Expand Down Expand Up @@ -272,6 +277,29 @@ public function getSessionId(): ?string
return $this->sessionService->getCurrentSession()->getSessionId();
}

/**
* Resolve additional login parameters to pass to the authentication source, based on the authorization request.
*
* Currently this propagates the login_hint authorization request parameter to the SimpleSAMLphp login page as
* the pre-filled username (the standard 'core:username' state key consumed by the core UserPass login form).
* Per specification this is a hint about the identifier the End-User might use to log in, so it is best-effort:
* auth sources that do not use the core login form simply ignore it, and an incorrect value is corrected by the
* user (or the login simply fails with invalid credentials).
*
* @return array<string,mixed>
*/
protected function resolveLoginParams(OAuth2AuthorizationRequestInterface $authorizationRequest): array
{
if (
!$authorizationRequest instanceof AuthorizationRequest ||
($loginHint = $authorizationRequest->getLoginHint()) === null
) {
return [];
}

return [self::LOGIN_PARAM_USERNAME => $loginHint];
}

/**
* @throws Error\BadRequest
* @throws Error\NotFound
Expand Down
95 changes: 95 additions & 0 deletions tests/unit/src/Server/RequestRules/Rules/LoginHintRuleTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<?php

declare(strict_types=1);

namespace SimpleSAML\Test\Module\oidc\unit\Server\RequestRules\Rules;

use PHPUnit\Framework\MockObject\Stub;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ServerRequestInterface;
use SimpleSAML\Module\oidc\Helpers;
use SimpleSAML\Module\oidc\Server\RequestRules\Interfaces\ResultBagInterface;
use SimpleSAML\Module\oidc\Server\RequestRules\Result;
use SimpleSAML\Module\oidc\Server\RequestRules\Rules\LoginHintRule;
use SimpleSAML\Module\oidc\Server\ResponseModes\ResponseModeInterface;
use SimpleSAML\Module\oidc\Services\LoggerService;
use SimpleSAML\Module\oidc\Utils\RequestParamsResolver;

/**
* @covers \SimpleSAML\Module\oidc\Server\RequestRules\Rules\LoginHintRule
*/
class LoginHintRuleTest extends TestCase
{
protected Stub $requestStub;
protected Stub $resultBagStub;
protected Stub $loggerServiceStub;
protected Stub $requestParamsResolverStub;
protected Helpers $helpers;
protected Stub $responseModeStub;

/**
* @throws \Exception
*/
protected function setUp(): void
{
$this->requestStub = $this->createStub(ServerRequestInterface::class);
$this->requestStub->method('getMethod')->willReturn('GET');

$this->resultBagStub = $this->createStub(ResultBagInterface::class);
$this->loggerServiceStub = $this->createStub(LoggerService::class);
$this->requestParamsResolverStub = $this->createStub(RequestParamsResolver::class);
$this->helpers = new Helpers();
$this->responseModeStub = $this->createStub(ResponseModeInterface::class);
}

protected function sut(
?RequestParamsResolver $requestParamsResolver = null,
?Helpers $helpers = null,
): LoginHintRule {
$requestParamsResolver ??= $this->requestParamsResolverStub;
$helpers ??= $this->helpers;

return new LoginHintRule(
$requestParamsResolver,
$helpers,
);
}

/**
* @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException
*/
public function testCheckRuleReturnsResultWhenParamSet()
{
$this->requestParamsResolverStub->method('getAsStringBasedOnAllowedMethods')->willReturn('user@example.org');

$result = $this->sut()->checkRule(
$this->requestStub,
$this->resultBagStub,
$this->loggerServiceStub,
[],
$this->responseModeStub,
) ??
new Result(LoginHintRule::class);

$this->assertEquals('user@example.org', $result->getValue());
}

/**
* @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException
*/
public function testCheckRuleReturnsNullWhenParamNotSet()
{
$this->requestStub->method('getQueryParams')->willReturn([]);

$result = $this->sut()->checkRule(
$this->requestStub,
$this->resultBagStub,
$this->loggerServiceStub,
[],
$this->responseModeStub,
) ??
new Result(LoginHintRule::class);

$this->assertNull($result->getValue());
}
}
35 changes: 35 additions & 0 deletions tests/unit/src/Server/RequestRules/Rules/MaxAgeRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,22 @@
use Psr\Http\Message\ServerRequestInterface;
use SimpleSAML\Auth\Simple;
use SimpleSAML\Module\oidc\Bridges\SspBridge;
use SimpleSAML\Module\oidc\Bridges\SspBridge\Utils as SspBridgeUtils;
use SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface;
use SimpleSAML\Module\oidc\Factories\AuthSimpleFactory;
use SimpleSAML\Module\oidc\Helpers;
use SimpleSAML\Module\oidc\Server\RequestRules\Result;
use SimpleSAML\Module\oidc\Server\RequestRules\ResultBag;
use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRedirectUriRule;
use SimpleSAML\Module\oidc\Server\RequestRules\Rules\ClientRule;
use SimpleSAML\Module\oidc\Server\RequestRules\Rules\LoginHintRule;
use SimpleSAML\Module\oidc\Server\RequestRules\Rules\MaxAgeRule;
use SimpleSAML\Module\oidc\Server\RequestRules\Rules\StateRule;
use SimpleSAML\Module\oidc\Server\ResponseModes\ResponseModeInterface;
use SimpleSAML\Module\oidc\Services\AuthenticationService;
use SimpleSAML\Module\oidc\Services\LoggerService;
use SimpleSAML\Module\oidc\Utils\RequestParamsResolver;
use SimpleSAML\Utils\HTTP as SspHttp;

#[CoversClass(MaxAgeRule::class)]
class MaxAgeRuleTest extends TestCase
Expand Down Expand Up @@ -111,4 +116,34 @@ public function testDefaultMaxAgeNotExpiredReturnsAuthInstant(): void

$this->assertNotNull($this->checkRule());
}

public function testExpiredMaxAgeReAuthenticatesAndPropagatesLoginHint(): void
{
$this->resultBag->add(new Result(ClientRedirectUriRule::class, 'https://rp.example.org/cb'));
$this->resultBag->add(new Result(StateRule::class, 'state123'));
$this->resultBag->add(new Result(LoginHintRule::class, 'user@example.org'));
$this->requestParamsResolverMock->method('getAllBasedOnAllowedMethods')
->willReturn(['max_age' => 0, 'login_hint' => 'user@example.org']);
$this->clientMock->method('getRequireAuthTime')->willReturn(false);
$this->authSimpleMock->method('isAuthenticated')->willReturn(true);
// Authenticated well before the (zero) max_age window, so re-authentication is enforced.
$this->authSimpleMock->method('getAuthData')->willReturn(time() - 3600);

$httpMock = $this->createMock(SspHttp::class);
$httpMock->method('getSelfURLNoQuery')->willReturn('https://op.example.org/authorize');
$httpMock->method('addURLParameters')->willReturn('https://op.example.org/authorize?max_age=0');
$utilsMock = $this->createMock(SspBridgeUtils::class);
$utilsMock->method('http')->willReturn($httpMock);
$this->sspBridgeMock->method('utils')->willReturn($utilsMock);

$this->authenticationServiceMock->expects($this->once())
->method('authenticateForClient')
->with(
$this->clientMock,
$this->callback(fn(array $loginParams): bool =>
($loginParams['core:username'] ?? null) === 'user@example.org'),
);

$this->checkRule();
}
}
Loading
Loading