Merge pull request #4955 from BookStackApp/oidc_userinfo

OIDC userinfo endpoint support
This commit is contained in:
Dan Brown 2024-04-19 16:55:29 +01:00 committed by GitHub
commit d949b97cc1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 580 additions and 230 deletions

View File

@ -267,6 +267,7 @@ OIDC_ISSUER_DISCOVER=false
OIDC_PUBLIC_KEY=null OIDC_PUBLIC_KEY=null
OIDC_AUTH_ENDPOINT=null OIDC_AUTH_ENDPOINT=null
OIDC_TOKEN_ENDPOINT=null OIDC_TOKEN_ENDPOINT=null
OIDC_USERINFO_ENDPOINT=null
OIDC_ADDITIONAL_SCOPES=null OIDC_ADDITIONAL_SCOPES=null
OIDC_DUMP_USER_DETAILS=false OIDC_DUMP_USER_DETAILS=false
OIDC_USER_TO_GROUPS=false OIDC_USER_TO_GROUPS=false

View File

@ -2,58 +2,8 @@
namespace BookStack\Access\Oidc; 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. * Validate all possible parts of the id token.
* *
@ -61,91 +11,12 @@ class OidcIdToken
*/ */
public function validate(string $clientId): bool public function validate(string $clientId): bool
{ {
$this->validateTokenStructure(); parent::validateCommonTokenDetails($clientId);
$this->validateTokenSignature();
$this->validateTokenClaims($clientId); $this->validateTokenClaims($clientId);
return true; 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. * Validate the claims of the token.
* As per https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation. * 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) // 1. The Issuer Identifier for the OpenID Provider (which is typically obtained during Discovery)
// MUST exactly match the value of the iss (issuer) Claim. // MUST exactly match the value of the iss (issuer) Claim.
if (empty($this->payload['iss']) || $this->issuer !== $this->payload['iss']) { // Already done in parent.
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 // 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 // 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 // if the ID Token does not list the Client as a valid audience, or if it contains additional
// audiences not trusted by the Client. // audiences not trusted by the Client.
if (empty($this->payload['aud'])) { // Partially done in parent.
throw new OidcInvalidTokenException('Missing token audience value');
}
$aud = is_string($this->payload['aud']) ? [$this->payload['aud']] : $this->payload['aud']; $aud = is_string($this->payload['aud']) ? [$this->payload['aud']] : $this->payload['aud'];
if (count($aud) !== 1) { if (count($aud) !== 1) {
throw new OidcInvalidTokenException('Token audience value has ' . count($aud) . ' values, Expected 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. // 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. // NOTE: Addressed by enforcing a count of 1 above.

View File

@ -0,0 +1,174 @@
<?php
namespace BookStack\Access\Oidc;
class 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 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');
}
}
}

View File

@ -18,10 +18,10 @@ class OidcProviderSettings
public string $issuer; public string $issuer;
public string $clientId; public string $clientId;
public string $clientSecret; public string $clientSecret;
public ?string $redirectUri;
public ?string $authorizationEndpoint; public ?string $authorizationEndpoint;
public ?string $tokenEndpoint; public ?string $tokenEndpoint;
public ?string $endSessionEndpoint; public ?string $endSessionEndpoint;
public ?string $userinfoEndpoint;
/** /**
* @var string[]|array[] * @var string[]|array[]
@ -37,7 +37,7 @@ class OidcProviderSettings
/** /**
* Apply an array of settings to populate setting properties within this class. * 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) { foreach ($settingsArray as $key => $value) {
if (property_exists($this, $key)) { if (property_exists($this, $key)) {
@ -51,9 +51,9 @@ class OidcProviderSettings
* *
* @throws InvalidArgumentException * @throws InvalidArgumentException
*/ */
protected function validateInitial() protected function validateInitial(): void
{ {
$required = ['clientId', 'clientSecret', 'redirectUri', 'issuer']; $required = ['clientId', 'clientSecret', 'issuer'];
foreach ($required as $prop) { foreach ($required as $prop) {
if (empty($this->$prop)) { if (empty($this->$prop)) {
throw new InvalidArgumentException("Missing required configuration \"{$prop}\" value"); throw new InvalidArgumentException("Missing required configuration \"{$prop}\" value");
@ -73,12 +73,20 @@ class OidcProviderSettings
public function validate(): void public function validate(): void
{ {
$this->validateInitial(); $this->validateInitial();
$required = ['keys', 'tokenEndpoint', 'authorizationEndpoint']; $required = ['keys', 'tokenEndpoint', 'authorizationEndpoint'];
foreach ($required as $prop) { foreach ($required as $prop) {
if (empty($this->$prop)) { if (empty($this->$prop)) {
throw new InvalidArgumentException("Missing required configuration \"{$prop}\" value"); 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 * @throws OidcIssuerDiscoveryException
*/ */
public function discoverFromIssuer(ClientInterface $httpClient, Repository $cache, int $cacheMinutes) public function discoverFromIssuer(ClientInterface $httpClient, Repository $cache, int $cacheMinutes): void
{ {
try { try {
$cacheKey = 'oidc-discovery::' . $this->issuer; $cacheKey = 'oidc-discovery::' . $this->issuer;
@ -128,6 +136,10 @@ class OidcProviderSettings
$discoveredSettings['tokenEndpoint'] = $result['token_endpoint']; $discoveredSettings['tokenEndpoint'] = $result['token_endpoint'];
} }
if (!empty($result['userinfo_endpoint'])) {
$discoveredSettings['userinfoEndpoint'] = $result['userinfo_endpoint'];
}
if (!empty($result['jwks_uri'])) { if (!empty($result['jwks_uri'])) {
$keys = $this->loadKeysFromUri($result['jwks_uri'], $httpClient); $keys = $this->loadKeysFromUri($result['jwks_uri'], $httpClient);
$discoveredSettings['keys'] = $this->filterKeys($keys); $discoveredSettings['keys'] = $this->filterKeys($keys);
@ -175,9 +187,9 @@ class OidcProviderSettings
/** /**
* Get the settings needed by an OAuth provider, as a key=>value array. * 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 = []; $settings = [];
foreach ($settingKeys as $setting) { foreach ($settingKeys as $setting) {
$settings[$setting] = $this->$setting; $settings[$setting] = $this->$setting;

View File

@ -12,7 +12,6 @@ use BookStack\Facades\Theme;
use BookStack\Http\HttpRequestService; use BookStack\Http\HttpRequestService;
use BookStack\Theming\ThemeEvents; use BookStack\Theming\ThemeEvents;
use BookStack\Users\Models\User; use BookStack\Users\Models\User;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use League\OAuth2\Client\OptionProvider\HttpBasicAuthOptionProvider; use League\OAuth2\Client\OptionProvider\HttpBasicAuthOptionProvider;
use League\OAuth2\Client\Provider\Exception\IdentityProviderException; use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
@ -91,10 +90,10 @@ class OidcService
'issuer' => $config['issuer'], 'issuer' => $config['issuer'],
'clientId' => $config['client_id'], 'clientId' => $config['client_id'],
'clientSecret' => $config['client_secret'], 'clientSecret' => $config['client_secret'],
'redirectUri' => url('/oidc/callback'),
'authorizationEndpoint' => $config['authorization_endpoint'], 'authorizationEndpoint' => $config['authorization_endpoint'],
'tokenEndpoint' => $config['token_endpoint'], 'tokenEndpoint' => $config['token_endpoint'],
'endSessionEndpoint' => is_string($config['end_session_endpoint']) ? $config['end_session_endpoint'] : null, 'endSessionEndpoint' => is_string($config['end_session_endpoint']) ? $config['end_session_endpoint'] : null,
'userinfoEndpoint' => $config['userinfo_endpoint'],
]); ]);
// Use keys if configured // Use keys if configured
@ -129,7 +128,10 @@ class OidcService
*/ */
protected function getProvider(OidcProviderSettings $settings): OidcOAuthProvider 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), 'httpClient' => $this->http->buildClient(5),
'optionProvider' => new HttpBasicAuthOptionProvider(), 'optionProvider' => new HttpBasicAuthOptionProvider(),
]); ]);
@ -156,69 +158,6 @@ class OidcService
return array_filter($scopeArr); 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 * Processes a received access token for a user. Login the user when
* they exist, optionally registering them automatically. * they exist, optionally registering them automatically.
@ -255,34 +194,35 @@ class OidcService
try { try {
$idToken->validate($settings->clientId); $idToken->validate($settings->clientId);
} catch (OidcInvalidTokenException $exception) { } 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); $userDetails = $this->getUserDetailsFromToken($idToken, $accessToken, $settings);
$isLoggedIn = auth()->check(); if (empty($userDetails->email)) {
if (empty($userDetails['email'])) {
throw new OidcException(trans('errors.oidc_no_email_address')); throw new OidcException(trans('errors.oidc_no_email_address'));
} }
if (empty($userDetails->name)) {
$userDetails->name = $userDetails->externalId;
}
$isLoggedIn = auth()->check();
if ($isLoggedIn) { if ($isLoggedIn) {
throw new OidcException(trans('errors.oidc_already_logged_in')); throw new OidcException(trans('errors.oidc_already_logged_in'));
} }
try { try {
$user = $this->registrationService->findOrRegister( $user = $this->registrationService->findOrRegister(
$userDetails['name'], $userDetails->name,
$userDetails['email'], $userDetails->email,
$userDetails['external_id'] $userDetails->externalId
); );
} catch (UserRegistrationException $exception) { } catch (UserRegistrationException $exception) {
throw new OidcException($exception->getMessage()); throw new OidcException($exception->getMessage());
} }
if ($this->shouldSyncGroups()) { if ($this->shouldSyncGroups()) {
$groups = $userDetails['groups'];
$detachExisting = $this->config()['remove_from_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'); $this->loginService->login($user, 'oidc');
@ -290,6 +230,45 @@ class OidcService
return $user; 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. * Get the OIDC config from the application.
*/ */

View File

@ -0,0 +1,75 @@
<?php
namespace BookStack\Access\Oidc;
use Illuminate\Support\Arr;
class OidcUserDetails
{
public function __construct(
public ?string $externalId = null,
public ?string $email = null,
public ?string $name = null,
public ?array $groups = null,
) {
}
/**
* Check if the user details are fully populated for our usage.
*/
public function isFullyPopulated(bool $groupSyncActive): bool
{
$hasEmpty = empty($this->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);
}));
}
}

View File

@ -0,0 +1,67 @@
<?php
namespace BookStack\Access\Oidc;
use Psr\Http\Message\ResponseInterface;
class OidcUserinfoResponse implements ProvidesClaims
{
protected array $claims = [];
protected ?OidcJwtWithClaims $jwt = null;
public function __construct(ResponseInterface $response, string $issuer, array $keys)
{
$contentType = $response->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;
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace BookStack\Access\Oidc;
interface ProvidesClaims
{
/**
* Fetch a specific claim.
* Returns null if it is null or does not exist.
*/
public function getClaim(string $claim): mixed;
/**
* Get all contained claims.
*/
public function getAllClaims(): array;
}

View File

@ -35,6 +35,7 @@ return [
// OAuth2 endpoints. // OAuth2 endpoints.
'authorization_endpoint' => env('OIDC_AUTH_ENDPOINT', null), 'authorization_endpoint' => env('OIDC_AUTH_ENDPOINT', null),
'token_endpoint' => env('OIDC_TOKEN_ENDPOINT', null), 'token_endpoint' => env('OIDC_TOKEN_ENDPOINT', null),
'userinfo_endpoint' => env('OIDC_USERINFO_ENDPOINT', null),
// OIDC RP-Initiated Logout endpoint URL. // OIDC RP-Initiated Logout endpoint URL.
// A false value force-disables RP-Initiated Logout. // A false value force-disables RP-Initiated Logout.

View File

@ -37,6 +37,7 @@ class OidcTest extends TestCase
'oidc.issuer' => OidcJwtHelper::defaultIssuer(), 'oidc.issuer' => OidcJwtHelper::defaultIssuer(),
'oidc.authorization_endpoint' => 'https://oidc.local/auth', 'oidc.authorization_endpoint' => 'https://oidc.local/auth',
'oidc.token_endpoint' => 'https://oidc.local/token', 'oidc.token_endpoint' => 'https://oidc.local/token',
'oidc.userinfo_endpoint' => 'https://oidc.local/userinfo',
'oidc.discover' => false, 'oidc.discover' => false,
'oidc.dump_user_details' => false, 'oidc.dump_user_details' => false,
'oidc.additional_scopes' => '', 'oidc.additional_scopes' => '',
@ -208,6 +209,8 @@ class OidcTest extends TestCase
public function test_auth_fails_if_no_email_exists_in_user_data() public function test_auth_fails_if_no_email_exists_in_user_data()
{ {
config()->set('oidc.userinfo_endpoint', null);
$this->runLogin([ $this->runLogin([
'email' => '', 'email' => '',
'sub' => 'benny505', 'sub' => 'benny505',
@ -270,10 +273,38 @@ class OidcTest extends TestCase
]); ]);
$resp = $this->followRedirects($resp); $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()); $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() public function test_auth_login_with_autodiscovery()
{ {
$this->withAutodiscovery(); $this->withAutodiscovery();
@ -689,22 +720,152 @@ class OidcTest extends TestCase
$this->assertEquals($pkceCode, $bodyParams['code_verifier']); $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([ config()->set([
'oidc.issuer' => OidcJwtHelper::defaultIssuer(), 'oidc.issuer' => OidcJwtHelper::defaultIssuer(),
'oidc.discover' => true, 'oidc.discover' => true,
'oidc.authorization_endpoint' => null, 'oidc.authorization_endpoint' => null,
'oidc.token_endpoint' => null, 'oidc.token_endpoint' => null,
'oidc.userinfo_endpoint' => null,
'oidc.jwt_public_key' => null, 'oidc.jwt_public_key' => null,
]); ]);
} }
protected function runLogin($claimOverrides = []): TestResponse protected function runLogin($claimOverrides = [], $additionalHttpResponses = []): TestResponse
{ {
$this->post('/oidc/login'); $this->post('/oidc/login');
$state = session()->get('oidc_state'); $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); return $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=' . $state);
} }
@ -718,6 +879,7 @@ class OidcTest extends TestCase
], json_encode(array_merge([ ], json_encode(array_merge([
'token_endpoint' => OidcJwtHelper::defaultIssuer() . '/oidc/token', 'token_endpoint' => OidcJwtHelper::defaultIssuer() . '/oidc/token',
'authorization_endpoint' => OidcJwtHelper::defaultIssuer() . '/oidc/authorize', 'authorization_endpoint' => OidcJwtHelper::defaultIssuer() . '/oidc/authorize',
'userinfo_endpoint' => OidcJwtHelper::defaultIssuer() . '/oidc/userinfo',
'jwks_uri' => OidcJwtHelper::defaultIssuer() . '/oidc/keys', 'jwks_uri' => OidcJwtHelper::defaultIssuer() . '/oidc/keys',
'issuer' => OidcJwtHelper::defaultIssuer(), 'issuer' => OidcJwtHelper::defaultIssuer(),
'end_session_endpoint' => OidcJwtHelper::defaultIssuer() . '/oidc/logout', 'end_session_endpoint' => OidcJwtHelper::defaultIssuer() . '/oidc/logout',

View File

@ -113,7 +113,7 @@ class OidcIdTokenTest extends TestCase
// 2. aud claim present // 2. aud claim present
['Missing token audience value', ['aud' => null]], ['Missing token audience value', ['aud' => null]],
// 2. aud claim validates all values against those expected (Only expect single) // 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 // 2. aud claim matches client id
['Token audience value did not match the expected client_id', ['aud' => 'xxyyzz.aaa.bbccdd.456']], ['Token audience value did not match the expected client_id', ['aud' => 'xxyyzz.aaa.bbccdd.456']],
// 4. azp claim matches client id if present // 4. azp claim matches client id if present