diff --git a/.env.example.complete b/.env.example.complete index e8520a24c..124296818 100644 --- a/.env.example.complete +++ b/.env.example.complete @@ -267,6 +267,7 @@ OIDC_ISSUER_DISCOVER=false OIDC_PUBLIC_KEY=null OIDC_AUTH_ENDPOINT=null OIDC_TOKEN_ENDPOINT=null +OIDC_USERINFO_ENDPOINT=null OIDC_ADDITIONAL_SCOPES=null OIDC_DUMP_USER_DETAILS=false OIDC_USER_TO_GROUPS=false diff --git a/app/Access/Oidc/OidcIdToken.php b/app/Access/Oidc/OidcIdToken.php index 5a395022a..68a8aa611 100644 --- a/app/Access/Oidc/OidcIdToken.php +++ b/app/Access/Oidc/OidcIdToken.php @@ -2,58 +2,8 @@ namespace BookStack\Access\Oidc; -class OidcIdToken +class OidcIdToken extends OidcJwtWithClaims implements ProvidesClaims { - protected array $header; - protected array $payload; - protected string $signature; - protected string $issuer; - protected array $tokenParts = []; - - /** - * @var array[]|string[] - */ - protected array $keys; - - public function __construct(string $token, string $issuer, array $keys) - { - $this->keys = $keys; - $this->issuer = $issuer; - $this->parse($token); - } - - /** - * Parse the token content into its components. - */ - protected function parse(string $token): void - { - $this->tokenParts = explode('.', $token); - $this->header = $this->parseEncodedTokenPart($this->tokenParts[0]); - $this->payload = $this->parseEncodedTokenPart($this->tokenParts[1] ?? ''); - $this->signature = $this->base64UrlDecode($this->tokenParts[2] ?? '') ?: ''; - } - - /** - * Parse a Base64-JSON encoded token part. - * Returns the data as a key-value array or empty array upon error. - */ - protected function parseEncodedTokenPart(string $part): array - { - $json = $this->base64UrlDecode($part) ?: '{}'; - $decoded = json_decode($json, true); - - return is_array($decoded) ? $decoded : []; - } - - /** - * Base64URL decode. Needs some character conversions to be compatible - * with PHP's default base64 handling. - */ - protected function base64UrlDecode(string $encoded): string - { - return base64_decode(strtr($encoded, '-_', '+/')); - } - /** * Validate all possible parts of the id token. * @@ -61,91 +11,12 @@ class OidcIdToken */ public function validate(string $clientId): bool { - $this->validateTokenStructure(); - $this->validateTokenSignature(); + parent::validateCommonTokenDetails($clientId); $this->validateTokenClaims($clientId); return true; } - /** - * Fetch a specific claim from this token. - * Returns null if it is null or does not exist. - * - * @return mixed|null - */ - public function getClaim(string $claim) - { - return $this->payload[$claim] ?? null; - } - - /** - * Get all returned claims within the token. - */ - public function getAllClaims(): array - { - return $this->payload; - } - - /** - * Replace the existing claim data of this token with that provided. - */ - public function replaceClaims(array $claims): void - { - $this->payload = $claims; - } - - /** - * Validate the structure of the given token and ensure we have the required pieces. - * As per https://datatracker.ietf.org/doc/html/rfc7519#section-7.2. - * - * @throws OidcInvalidTokenException - */ - protected function validateTokenStructure(): void - { - foreach (['header', 'payload'] as $prop) { - if (empty($this->$prop) || !is_array($this->$prop)) { - throw new OidcInvalidTokenException("Could not parse out a valid {$prop} within the provided token"); - } - } - - if (empty($this->signature) || !is_string($this->signature)) { - throw new OidcInvalidTokenException('Could not parse out a valid signature within the provided token'); - } - } - - /** - * Validate the signature of the given token and ensure it validates against the provided key. - * - * @throws OidcInvalidTokenException - */ - protected function validateTokenSignature(): void - { - if ($this->header['alg'] !== 'RS256') { - throw new OidcInvalidTokenException("Only RS256 signature validation is supported. Token reports using {$this->header['alg']}"); - } - - $parsedKeys = array_map(function ($key) { - try { - return new OidcJwtSigningKey($key); - } catch (OidcInvalidKeyException $e) { - throw new OidcInvalidTokenException('Failed to read signing key with error: ' . $e->getMessage()); - } - }, $this->keys); - - $parsedKeys = array_filter($parsedKeys); - - $contentToSign = $this->tokenParts[0] . '.' . $this->tokenParts[1]; - /** @var OidcJwtSigningKey $parsedKey */ - foreach ($parsedKeys as $parsedKey) { - if ($parsedKey->verify($contentToSign, $this->signature)) { - return; - } - } - - throw new OidcInvalidTokenException('Token signature could not be validated using the provided keys'); - } - /** * Validate the claims of the token. * As per https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation. @@ -156,27 +27,18 @@ class OidcIdToken { // 1. The Issuer Identifier for the OpenID Provider (which is typically obtained during Discovery) // MUST exactly match the value of the iss (issuer) Claim. - if (empty($this->payload['iss']) || $this->issuer !== $this->payload['iss']) { - throw new OidcInvalidTokenException('Missing or non-matching token issuer value'); - } + // Already done in parent. // 2. The Client MUST validate that the aud (audience) Claim contains its client_id value registered // at the Issuer identified by the iss (issuer) Claim as an audience. The ID Token MUST be rejected // if the ID Token does not list the Client as a valid audience, or if it contains additional // audiences not trusted by the Client. - if (empty($this->payload['aud'])) { - throw new OidcInvalidTokenException('Missing token audience value'); - } - + // Partially done in parent. $aud = is_string($this->payload['aud']) ? [$this->payload['aud']] : $this->payload['aud']; if (count($aud) !== 1) { throw new OidcInvalidTokenException('Token audience value has ' . count($aud) . ' values, Expected 1'); } - if ($aud[0] !== $clientId) { - throw new OidcInvalidTokenException('Token audience value did not match the expected client_id'); - } - // 3. If the ID Token contains multiple audiences, the Client SHOULD verify that an azp Claim is present. // NOTE: Addressed by enforcing a count of 1 above. diff --git a/app/Access/Oidc/OidcJwtWithClaims.php b/app/Access/Oidc/OidcJwtWithClaims.php new file mode 100644 index 000000000..06c04d81e --- /dev/null +++ b/app/Access/Oidc/OidcJwtWithClaims.php @@ -0,0 +1,174 @@ +keys = $keys; + $this->issuer = $issuer; + $this->parse($token); + } + + /** + * Parse the token content into its components. + */ + protected function parse(string $token): void + { + $this->tokenParts = explode('.', $token); + $this->header = $this->parseEncodedTokenPart($this->tokenParts[0]); + $this->payload = $this->parseEncodedTokenPart($this->tokenParts[1] ?? ''); + $this->signature = $this->base64UrlDecode($this->tokenParts[2] ?? '') ?: ''; + } + + /** + * Parse a Base64-JSON encoded token part. + * Returns the data as a key-value array or empty array upon error. + */ + protected function parseEncodedTokenPart(string $part): array + { + $json = $this->base64UrlDecode($part) ?: '{}'; + $decoded = json_decode($json, true); + + return is_array($decoded) ? $decoded : []; + } + + /** + * Base64URL decode. Needs some character conversions to be compatible + * with PHP's default base64 handling. + */ + protected function base64UrlDecode(string $encoded): string + { + return base64_decode(strtr($encoded, '-_', '+/')); + } + + /** + * Validate common parts of OIDC JWT tokens. + * + * @throws OidcInvalidTokenException + */ + public function validateCommonTokenDetails(string $clientId): bool + { + $this->validateTokenStructure(); + $this->validateTokenSignature(); + $this->validateCommonClaims($clientId); + + return true; + } + + /** + * Fetch a specific claim from this token. + * Returns null if it is null or does not exist. + */ + public function getClaim(string $claim): mixed + { + return $this->payload[$claim] ?? null; + } + + /** + * Get all returned claims within the token. + */ + public function getAllClaims(): array + { + return $this->payload; + } + + /** + * Replace the existing claim data of this token with that provided. + */ + public function replaceClaims(array $claims): void + { + $this->payload = $claims; + } + + /** + * Validate the structure of the given token and ensure we have the required pieces. + * As per https://datatracker.ietf.org/doc/html/rfc7519#section-7.2. + * + * @throws OidcInvalidTokenException + */ + protected function validateTokenStructure(): void + { + foreach (['header', 'payload'] as $prop) { + if (empty($this->$prop) || !is_array($this->$prop)) { + throw new OidcInvalidTokenException("Could not parse out a valid {$prop} within the provided token"); + } + } + + if (empty($this->signature) || !is_string($this->signature)) { + throw new OidcInvalidTokenException('Could not parse out a valid signature within the provided token'); + } + } + + /** + * Validate the signature of the given token and ensure it validates against the provided key. + * + * @throws OidcInvalidTokenException + */ + protected function validateTokenSignature(): void + { + if ($this->header['alg'] !== 'RS256') { + throw new OidcInvalidTokenException("Only RS256 signature validation is supported. Token reports using {$this->header['alg']}"); + } + + $parsedKeys = array_map(function ($key) { + try { + return new OidcJwtSigningKey($key); + } catch (OidcInvalidKeyException $e) { + throw new OidcInvalidTokenException('Failed to read signing key with error: ' . $e->getMessage()); + } + }, $this->keys); + + $parsedKeys = array_filter($parsedKeys); + + $contentToSign = $this->tokenParts[0] . '.' . $this->tokenParts[1]; + /** @var OidcJwtSigningKey $parsedKey */ + foreach ($parsedKeys as $parsedKey) { + if ($parsedKey->verify($contentToSign, $this->signature)) { + return; + } + } + + throw new OidcInvalidTokenException('Token signature could not be validated using the provided keys'); + } + + /** + * Validate common claims for OIDC JWT tokens. + * As per https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation + * and https://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse + * + * @throws OidcInvalidTokenException + */ + protected function validateCommonClaims(string $clientId): void + { + // 1. The Issuer Identifier for the OpenID Provider (which is typically obtained during Discovery) + // MUST exactly match the value of the iss (issuer) Claim. + if (empty($this->payload['iss']) || $this->issuer !== $this->payload['iss']) { + throw new OidcInvalidTokenException('Missing or non-matching token issuer value'); + } + + // 2. The Client MUST validate that the aud (audience) Claim contains its client_id value registered + // at the Issuer identified by the iss (issuer) Claim as an audience. The ID Token MUST be rejected + // if the ID Token does not list the Client as a valid audience. + if (empty($this->payload['aud'])) { + throw new OidcInvalidTokenException('Missing token audience value'); + } + + $aud = is_string($this->payload['aud']) ? [$this->payload['aud']] : $this->payload['aud']; + if (!in_array($clientId, $aud, true)) { + throw new OidcInvalidTokenException('Token audience value did not match the expected client_id'); + } + } +} diff --git a/app/Access/Oidc/OidcProviderSettings.php b/app/Access/Oidc/OidcProviderSettings.php index bea6a523e..71c3b5734 100644 --- a/app/Access/Oidc/OidcProviderSettings.php +++ b/app/Access/Oidc/OidcProviderSettings.php @@ -18,10 +18,10 @@ class OidcProviderSettings public string $issuer; public string $clientId; public string $clientSecret; - public ?string $redirectUri; public ?string $authorizationEndpoint; public ?string $tokenEndpoint; public ?string $endSessionEndpoint; + public ?string $userinfoEndpoint; /** * @var string[]|array[] @@ -37,7 +37,7 @@ class OidcProviderSettings /** * Apply an array of settings to populate setting properties within this class. */ - protected function applySettingsFromArray(array $settingsArray) + protected function applySettingsFromArray(array $settingsArray): void { foreach ($settingsArray as $key => $value) { if (property_exists($this, $key)) { @@ -51,9 +51,9 @@ class OidcProviderSettings * * @throws InvalidArgumentException */ - protected function validateInitial() + protected function validateInitial(): void { - $required = ['clientId', 'clientSecret', 'redirectUri', 'issuer']; + $required = ['clientId', 'clientSecret', 'issuer']; foreach ($required as $prop) { if (empty($this->$prop)) { throw new InvalidArgumentException("Missing required configuration \"{$prop}\" value"); @@ -73,12 +73,20 @@ class OidcProviderSettings public function validate(): void { $this->validateInitial(); + $required = ['keys', 'tokenEndpoint', 'authorizationEndpoint']; foreach ($required as $prop) { if (empty($this->$prop)) { throw new InvalidArgumentException("Missing required configuration \"{$prop}\" value"); } } + + $endpointProperties = ['tokenEndpoint', 'authorizationEndpoint', 'userinfoEndpoint']; + foreach ($endpointProperties as $prop) { + if (is_string($this->$prop) && !str_starts_with($this->$prop, 'https://')) { + throw new InvalidArgumentException("Endpoint value for \"{$prop}\" must start with https://"); + } + } } /** @@ -86,7 +94,7 @@ class OidcProviderSettings * * @throws OidcIssuerDiscoveryException */ - public function discoverFromIssuer(ClientInterface $httpClient, Repository $cache, int $cacheMinutes) + public function discoverFromIssuer(ClientInterface $httpClient, Repository $cache, int $cacheMinutes): void { try { $cacheKey = 'oidc-discovery::' . $this->issuer; @@ -128,6 +136,10 @@ class OidcProviderSettings $discoveredSettings['tokenEndpoint'] = $result['token_endpoint']; } + if (!empty($result['userinfo_endpoint'])) { + $discoveredSettings['userinfoEndpoint'] = $result['userinfo_endpoint']; + } + if (!empty($result['jwks_uri'])) { $keys = $this->loadKeysFromUri($result['jwks_uri'], $httpClient); $discoveredSettings['keys'] = $this->filterKeys($keys); @@ -175,9 +187,9 @@ class OidcProviderSettings /** * Get the settings needed by an OAuth provider, as a key=>value array. */ - public function arrayForProvider(): array + public function arrayForOAuthProvider(): array { - $settingKeys = ['clientId', 'clientSecret', 'redirectUri', 'authorizationEndpoint', 'tokenEndpoint']; + $settingKeys = ['clientId', 'clientSecret', 'authorizationEndpoint', 'tokenEndpoint', 'userinfoEndpoint']; $settings = []; foreach ($settingKeys as $setting) { $settings[$setting] = $this->$setting; diff --git a/app/Access/Oidc/OidcService.php b/app/Access/Oidc/OidcService.php index 036c9fc47..7c1760649 100644 --- a/app/Access/Oidc/OidcService.php +++ b/app/Access/Oidc/OidcService.php @@ -12,7 +12,6 @@ use BookStack\Facades\Theme; use BookStack\Http\HttpRequestService; use BookStack\Theming\ThemeEvents; use BookStack\Users\Models\User; -use Illuminate\Support\Arr; use Illuminate\Support\Facades\Cache; use League\OAuth2\Client\OptionProvider\HttpBasicAuthOptionProvider; use League\OAuth2\Client\Provider\Exception\IdentityProviderException; @@ -91,10 +90,10 @@ class OidcService 'issuer' => $config['issuer'], 'clientId' => $config['client_id'], 'clientSecret' => $config['client_secret'], - 'redirectUri' => url('/oidc/callback'), 'authorizationEndpoint' => $config['authorization_endpoint'], 'tokenEndpoint' => $config['token_endpoint'], 'endSessionEndpoint' => is_string($config['end_session_endpoint']) ? $config['end_session_endpoint'] : null, + 'userinfoEndpoint' => $config['userinfo_endpoint'], ]); // Use keys if configured @@ -129,7 +128,10 @@ class OidcService */ protected function getProvider(OidcProviderSettings $settings): OidcOAuthProvider { - $provider = new OidcOAuthProvider($settings->arrayForProvider(), [ + $provider = new OidcOAuthProvider([ + ...$settings->arrayForOAuthProvider(), + 'redirectUri' => url('/oidc/callback'), + ], [ 'httpClient' => $this->http->buildClient(5), 'optionProvider' => new HttpBasicAuthOptionProvider(), ]); @@ -156,69 +158,6 @@ class OidcService return array_filter($scopeArr); } - /** - * Calculate the display name. - */ - protected function getUserDisplayName(OidcIdToken $token, string $defaultValue): string - { - $displayNameAttrString = $this->config()['display_name_claims'] ?? ''; - $displayNameAttrs = explode('|', $displayNameAttrString); - - $displayName = []; - foreach ($displayNameAttrs as $dnAttr) { - $dnComponent = $token->getClaim($dnAttr) ?? ''; - if ($dnComponent !== '') { - $displayName[] = $dnComponent; - } - } - - if (count($displayName) == 0) { - $displayName[] = $defaultValue; - } - - return implode(' ', $displayName); - } - - /** - * Extract the assigned groups from the id token. - * - * @return string[] - */ - protected function getUserGroups(OidcIdToken $token): array - { - $groupsAttr = $this->config()['groups_claim']; - if (empty($groupsAttr)) { - return []; - } - - $groupsList = Arr::get($token->getAllClaims(), $groupsAttr); - if (!is_array($groupsList)) { - return []; - } - - return array_values(array_filter($groupsList, function ($val) { - return is_string($val); - })); - } - - /** - * Extract the details of a user from an ID token. - * - * @return array{name: string, email: string, external_id: string, groups: string[]} - */ - protected function getUserDetails(OidcIdToken $token): array - { - $idClaim = $this->config()['external_id_claim']; - $id = $token->getClaim($idClaim); - - return [ - 'external_id' => $id, - 'email' => $token->getClaim('email'), - 'name' => $this->getUserDisplayName($token, $id), - 'groups' => $this->getUserGroups($token), - ]; - } - /** * Processes a received access token for a user. Login the user when * they exist, optionally registering them automatically. @@ -255,34 +194,35 @@ class OidcService try { $idToken->validate($settings->clientId); } catch (OidcInvalidTokenException $exception) { - throw new OidcException("ID token validate failed with error: {$exception->getMessage()}"); + throw new OidcException("ID token validation failed with error: {$exception->getMessage()}"); } - $userDetails = $this->getUserDetails($idToken); - $isLoggedIn = auth()->check(); - - if (empty($userDetails['email'])) { + $userDetails = $this->getUserDetailsFromToken($idToken, $accessToken, $settings); + if (empty($userDetails->email)) { throw new OidcException(trans('errors.oidc_no_email_address')); } + if (empty($userDetails->name)) { + $userDetails->name = $userDetails->externalId; + } + $isLoggedIn = auth()->check(); if ($isLoggedIn) { throw new OidcException(trans('errors.oidc_already_logged_in')); } try { $user = $this->registrationService->findOrRegister( - $userDetails['name'], - $userDetails['email'], - $userDetails['external_id'] + $userDetails->name, + $userDetails->email, + $userDetails->externalId ); } catch (UserRegistrationException $exception) { throw new OidcException($exception->getMessage()); } if ($this->shouldSyncGroups()) { - $groups = $userDetails['groups']; $detachExisting = $this->config()['remove_from_groups']; - $this->groupService->syncUserWithFoundGroups($user, $groups, $detachExisting); + $this->groupService->syncUserWithFoundGroups($user, $userDetails->groups ?? [], $detachExisting); } $this->loginService->login($user, 'oidc'); @@ -290,6 +230,45 @@ class OidcService return $user; } + /** + * @throws OidcException + */ + protected function getUserDetailsFromToken(OidcIdToken $idToken, OidcAccessToken $accessToken, OidcProviderSettings $settings): OidcUserDetails + { + $userDetails = new OidcUserDetails(); + $userDetails->populate( + $idToken, + $this->config()['external_id_claim'], + $this->config()['display_name_claims'] ?? '', + $this->config()['groups_claim'] ?? '' + ); + + if (!$userDetails->isFullyPopulated($this->shouldSyncGroups()) && !empty($settings->userinfoEndpoint)) { + $provider = $this->getProvider($settings); + $request = $provider->getAuthenticatedRequest('GET', $settings->userinfoEndpoint, $accessToken->getToken()); + $response = new OidcUserinfoResponse( + $provider->getResponse($request), + $settings->issuer, + $settings->keys, + ); + + try { + $response->validate($idToken->getClaim('sub'), $settings->clientId); + } catch (OidcInvalidTokenException $exception) { + throw new OidcException("Userinfo endpoint response validation failed with error: {$exception->getMessage()}"); + } + + $userDetails->populate( + $response, + $this->config()['external_id_claim'], + $this->config()['display_name_claims'] ?? '', + $this->config()['groups_claim'] ?? '' + ); + } + + return $userDetails; + } + /** * Get the OIDC config from the application. */ diff --git a/app/Access/Oidc/OidcUserDetails.php b/app/Access/Oidc/OidcUserDetails.php new file mode 100644 index 000000000..bccc49ee4 --- /dev/null +++ b/app/Access/Oidc/OidcUserDetails.php @@ -0,0 +1,75 @@ +externalId) + || empty($this->email) + || empty($this->name) + || ($groupSyncActive && empty($this->groups)); + + return !$hasEmpty; + } + + /** + * Populate user details from the given claim data. + */ + public function populate( + ProvidesClaims $claims, + string $idClaim, + string $displayNameClaims, + string $groupsClaim, + ): void { + $this->externalId = $claims->getClaim($idClaim) ?? $this->externalId; + $this->email = $claims->getClaim('email') ?? $this->email; + $this->name = static::getUserDisplayName($displayNameClaims, $claims) ?? $this->name; + $this->groups = static::getUserGroups($groupsClaim, $claims) ?? $this->groups; + } + + protected static function getUserDisplayName(string $displayNameClaims, ProvidesClaims $token): string + { + $displayNameClaimParts = explode('|', $displayNameClaims); + + $displayName = []; + foreach ($displayNameClaimParts as $claim) { + $component = $token->getClaim(trim($claim)) ?? ''; + if ($component !== '') { + $displayName[] = $component; + } + } + + return implode(' ', $displayName); + } + + protected static function getUserGroups(string $groupsClaim, ProvidesClaims $token): array + { + if (empty($groupsClaim)) { + return []; + } + + $groupsList = Arr::get($token->getAllClaims(), $groupsClaim); + if (!is_array($groupsList)) { + return []; + } + + return array_values(array_filter($groupsList, function ($val) { + return is_string($val); + })); + } +} diff --git a/app/Access/Oidc/OidcUserinfoResponse.php b/app/Access/Oidc/OidcUserinfoResponse.php new file mode 100644 index 000000000..9aded654e --- /dev/null +++ b/app/Access/Oidc/OidcUserinfoResponse.php @@ -0,0 +1,67 @@ +getHeader('Content-Type')[0]; + if ($contentType === 'application/json') { + $this->claims = json_decode($response->getBody()->getContents(), true); + } + + if ($contentType === 'application/jwt') { + $this->jwt = new OidcJwtWithClaims($response->getBody()->getContents(), $issuer, $keys); + $this->claims = $this->jwt->getAllClaims(); + } + } + + /** + * @throws OidcInvalidTokenException + */ + public function validate(string $idTokenSub, string $clientId): bool + { + if (!is_null($this->jwt)) { + $this->jwt->validateCommonTokenDetails($clientId); + } + + $sub = $this->getClaim('sub'); + + // Spec: v1.0 5.3.2: The sub (subject) Claim MUST always be returned in the UserInfo Response. + if (!is_string($sub) || empty($sub)) { + throw new OidcInvalidTokenException("No valid subject value found in userinfo data"); + } + + // Spec: v1.0 5.3.2: The sub Claim in the UserInfo Response MUST be verified to exactly match the sub Claim in the ID Token; + // if they do not match, the UserInfo Response values MUST NOT be used. + if ($idTokenSub !== $sub) { + throw new OidcInvalidTokenException("Subject value provided in the userinfo endpoint does not match the provided ID token value"); + } + + // Spec v1.0 5.3.4 Defines the following: + // Verify that the OP that responded was the intended OP through a TLS server certificate check, per RFC 6125 [RFC6125]. + // This is effectively done as part of the HTTP request we're making through CURLOPT_SSL_VERIFYHOST on the request. + // If the Client has provided a userinfo_encrypted_response_alg parameter during Registration, decrypt the UserInfo Response using the keys specified during Registration. + // We don't currently support JWT encryption for OIDC + // If the response was signed, the Client SHOULD validate the signature according to JWS [JWS]. + // This is done as part of the validateCommonClaims above. + + return true; + } + + public function getClaim(string $claim): mixed + { + return $this->claims[$claim] ?? null; + } + + public function getAllClaims(): array + { + return $this->claims; + } +} diff --git a/app/Access/Oidc/ProvidesClaims.php b/app/Access/Oidc/ProvidesClaims.php new file mode 100644 index 000000000..a3cf51655 --- /dev/null +++ b/app/Access/Oidc/ProvidesClaims.php @@ -0,0 +1,17 @@ + env('OIDC_AUTH_ENDPOINT', null), 'token_endpoint' => env('OIDC_TOKEN_ENDPOINT', null), + 'userinfo_endpoint' => env('OIDC_USERINFO_ENDPOINT', null), // OIDC RP-Initiated Logout endpoint URL. // A false value force-disables RP-Initiated Logout. diff --git a/tests/Auth/OidcTest.php b/tests/Auth/OidcTest.php index 228c75e9e..9bde71c80 100644 --- a/tests/Auth/OidcTest.php +++ b/tests/Auth/OidcTest.php @@ -37,6 +37,7 @@ class OidcTest extends TestCase 'oidc.issuer' => OidcJwtHelper::defaultIssuer(), 'oidc.authorization_endpoint' => 'https://oidc.local/auth', 'oidc.token_endpoint' => 'https://oidc.local/token', + 'oidc.userinfo_endpoint' => 'https://oidc.local/userinfo', 'oidc.discover' => false, 'oidc.dump_user_details' => false, 'oidc.additional_scopes' => '', @@ -208,6 +209,8 @@ class OidcTest extends TestCase public function test_auth_fails_if_no_email_exists_in_user_data() { + config()->set('oidc.userinfo_endpoint', null); + $this->runLogin([ 'email' => '', 'sub' => 'benny505', @@ -270,10 +273,38 @@ class OidcTest extends TestCase ]); $resp = $this->followRedirects($resp); - $resp->assertSeeText('ID token validate failed with error: Missing token subject value'); + $resp->assertSeeText('ID token validation failed with error: Missing token subject value'); $this->assertFalse(auth()->check()); } + public function test_auth_fails_if_endpoints_start_with_https() + { + $endpointConfigKeys = [ + 'oidc.token_endpoint' => 'tokenEndpoint', + 'oidc.authorization_endpoint' => 'authorizationEndpoint', + 'oidc.userinfo_endpoint' => 'userinfoEndpoint', + ]; + + foreach ($endpointConfigKeys as $endpointConfigKey => $endpointName) { + $logger = $this->withTestLogger(); + $original = config()->get($endpointConfigKey); + $new = str_replace('https://', 'http://', $original); + config()->set($endpointConfigKey, $new); + + $this->withoutExceptionHandling(); + $err = null; + try { + $resp = $this->runLogin(); + $resp->assertRedirect('/login'); + } catch (\Exception $exception) { + $err = $exception; + } + $this->assertEquals("Endpoint value for \"{$endpointName}\" must start with https://", $err->getMessage()); + + config()->set($endpointConfigKey, $original); + } + } + public function test_auth_login_with_autodiscovery() { $this->withAutodiscovery(); @@ -689,22 +720,152 @@ class OidcTest extends TestCase $this->assertEquals($pkceCode, $bodyParams['code_verifier']); } - protected function withAutodiscovery() + public function test_userinfo_endpoint_used_if_missing_claims_in_id_token() + { + config()->set('oidc.display_name_claims', 'first_name|last_name'); + $this->post('/oidc/login'); + $state = session()->get('oidc_state'); + + $client = $this->mockHttpClient([ + $this->getMockAuthorizationResponse(['name' => null]), + new Response(200, [ + 'Content-Type' => 'application/json', + ], json_encode([ + 'sub' => OidcJwtHelper::defaultPayload()['sub'], + 'first_name' => 'Barry', + 'last_name' => 'Userinfo', + ])) + ]); + + $resp = $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=' . $state); + $resp->assertRedirect('/'); + $this->assertEquals(2, $client->requestCount()); + + $userinfoRequest = $client->requestAt(1); + $this->assertEquals('GET', $userinfoRequest->getMethod()); + $this->assertEquals('https://oidc.local/userinfo', (string) $userinfoRequest->getUri()); + + $this->assertEquals('Barry Userinfo', user()->name); + } + + public function test_userinfo_endpoint_fetch_with_different_sub_throws_error() + { + $userinfoResponseData = ['sub' => 'dcba4321']; + $userinfoResponse = new Response(200, ['Content-Type' => 'application/json'], json_encode($userinfoResponseData)); + $resp = $this->runLogin(['name' => null], [$userinfoResponse]); + $resp->assertRedirect('/login'); + $this->assertSessionError('Userinfo endpoint response validation failed with error: Subject value provided in the userinfo endpoint does not match the provided ID token value'); + } + + public function test_userinfo_endpoint_fetch_returning_no_sub_throws_error() + { + $userinfoResponseData = ['name' => 'testing']; + $userinfoResponse = new Response(200, ['Content-Type' => 'application/json'], json_encode($userinfoResponseData)); + $resp = $this->runLogin(['name' => null], [$userinfoResponse]); + $resp->assertRedirect('/login'); + $this->assertSessionError('Userinfo endpoint response validation failed with error: No valid subject value found in userinfo data'); + } + + public function test_userinfo_endpoint_fetch_can_parsed_nested_groups() + { + config()->set([ + 'oidc.user_to_groups' => true, + 'oidc.groups_claim' => 'my.nested.groups.attr', + 'oidc.remove_from_groups' => false, + ]); + + $roleA = Role::factory()->create(['display_name' => 'Ducks']); + $userinfoResponseData = [ + 'sub' => OidcJwtHelper::defaultPayload()['sub'], + 'my' => ['nested' => ['groups' => ['attr' => ['Ducks', 'Donkeys']]]] + ]; + $userinfoResponse = new Response(200, ['Content-Type' => 'application/json'], json_encode($userinfoResponseData)); + $resp = $this->runLogin(['groups' => null], [$userinfoResponse]); + $resp->assertRedirect('/'); + + $user = User::where('email', OidcJwtHelper::defaultPayload()['email'])->first(); + $this->assertTrue($user->hasRole($roleA->id)); + } + + public function test_userinfo_endpoint_jwks_response_handled() + { + $userinfoResponseData = OidcJwtHelper::idToken(['name' => 'Barry Jwks']); + $userinfoResponse = new Response(200, ['Content-Type' => 'application/jwt'], $userinfoResponseData); + + $resp = $this->runLogin(['name' => null], [$userinfoResponse]); + $resp->assertRedirect('/'); + + $user = User::where('email', OidcJwtHelper::defaultPayload()['email'])->first(); + $this->assertEquals('Barry Jwks', $user->name); + } + + public function test_userinfo_endpoint_jwks_response_returning_no_sub_throws() + { + $userinfoResponseData = OidcJwtHelper::idToken(['sub' => null]); + $userinfoResponse = new Response(200, ['Content-Type' => 'application/jwt'], $userinfoResponseData); + + $resp = $this->runLogin(['name' => null], [$userinfoResponse]); + $resp->assertRedirect('/login'); + $this->assertSessionError('Userinfo endpoint response validation failed with error: No valid subject value found in userinfo data'); + } + + public function test_userinfo_endpoint_jwks_response_returning_non_matching_sub_throws() + { + $userinfoResponseData = OidcJwtHelper::idToken(['sub' => 'zzz123']); + $userinfoResponse = new Response(200, ['Content-Type' => 'application/jwt'], $userinfoResponseData); + + $resp = $this->runLogin(['name' => null], [$userinfoResponse]); + $resp->assertRedirect('/login'); + $this->assertSessionError('Userinfo endpoint response validation failed with error: Subject value provided in the userinfo endpoint does not match the provided ID token value'); + } + + public function test_userinfo_endpoint_jwks_response_with_invalid_signature_throws() + { + $userinfoResponseData = OidcJwtHelper::idToken(); + $exploded = explode('.', $userinfoResponseData); + $exploded[2] = base64_encode(base64_decode($exploded[2]) . 'ABC'); + $userinfoResponse = new Response(200, ['Content-Type' => 'application/jwt'], implode('.', $exploded)); + + $resp = $this->runLogin(['name' => null], [$userinfoResponse]); + $resp->assertRedirect('/login'); + $this->assertSessionError('Userinfo endpoint response validation failed with error: Token signature could not be validated using the provided keys'); + } + + public function test_userinfo_endpoint_jwks_response_with_invalid_signature_alg_throws() + { + $userinfoResponseData = OidcJwtHelper::idToken([], ['alg' => 'ZZ512']); + $userinfoResponse = new Response(200, ['Content-Type' => 'application/jwt'], $userinfoResponseData); + + $resp = $this->runLogin(['name' => null], [$userinfoResponse]); + $resp->assertRedirect('/login'); + $this->assertSessionError('Userinfo endpoint response validation failed with error: Only RS256 signature validation is supported. Token reports using ZZ512'); + } + + public function test_userinfo_endpoint_response_with_invalid_content_type_throws() + { + $userinfoResponse = new Response(200, ['Content-Type' => 'application/beans'], json_encode(OidcJwtHelper::defaultPayload())); + $resp = $this->runLogin(['name' => null], [$userinfoResponse]); + $resp->assertRedirect('/login'); + $this->assertSessionError('Userinfo endpoint response validation failed with error: No valid subject value found in userinfo data'); + } + + protected function withAutodiscovery(): void { config()->set([ 'oidc.issuer' => OidcJwtHelper::defaultIssuer(), 'oidc.discover' => true, 'oidc.authorization_endpoint' => null, 'oidc.token_endpoint' => null, + 'oidc.userinfo_endpoint' => null, 'oidc.jwt_public_key' => null, ]); } - protected function runLogin($claimOverrides = []): TestResponse + protected function runLogin($claimOverrides = [], $additionalHttpResponses = []): TestResponse { $this->post('/oidc/login'); $state = session()->get('oidc_state'); - $this->mockHttpClient([$this->getMockAuthorizationResponse($claimOverrides)]); + $this->mockHttpClient([$this->getMockAuthorizationResponse($claimOverrides), ...$additionalHttpResponses]); return $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=' . $state); } @@ -718,6 +879,7 @@ class OidcTest extends TestCase ], json_encode(array_merge([ 'token_endpoint' => OidcJwtHelper::defaultIssuer() . '/oidc/token', 'authorization_endpoint' => OidcJwtHelper::defaultIssuer() . '/oidc/authorize', + 'userinfo_endpoint' => OidcJwtHelper::defaultIssuer() . '/oidc/userinfo', 'jwks_uri' => OidcJwtHelper::defaultIssuer() . '/oidc/keys', 'issuer' => OidcJwtHelper::defaultIssuer(), 'end_session_endpoint' => OidcJwtHelper::defaultIssuer() . '/oidc/logout', diff --git a/tests/Unit/OidcIdTokenTest.php b/tests/Unit/OidcIdTokenTest.php index 6302f84c7..739323266 100644 --- a/tests/Unit/OidcIdTokenTest.php +++ b/tests/Unit/OidcIdTokenTest.php @@ -113,7 +113,7 @@ class OidcIdTokenTest extends TestCase // 2. aud claim present ['Missing token audience value', ['aud' => null]], // 2. aud claim validates all values against those expected (Only expect single) - ['Token audience value has 2 values, Expected 1', ['aud' => ['abc', 'def']]], + ['Token audience value has 2 values, Expected 1', ['aud' => ['xxyyzz.aaa.bbccdd.123', 'def']]], // 2. aud claim matches client id ['Token audience value did not match the expected client_id', ['aud' => 'xxyyzz.aaa.bbccdd.456']], // 4. azp claim matches client id if present