Created
February 22, 2023 16:39
-
-
Save felixschloesser/a5e11a7ce2fac3111f8746accaf6a6dd to your computer and use it in GitHub Desktop.
Bookstack - get name and groups from userinfo endpoint
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
namespace BookStack\Auth\Access\Oidc; | |
use GuzzleHttp\Psr7\Request; | |
use Illuminate\Contracts\Cache\Repository; | |
use InvalidArgumentException; | |
use Psr\Http\Client\ClientExceptionInterface; | |
use Psr\Http\Client\ClientInterface; | |
/** | |
* OpenIdConnectProviderSettings | |
* Acts as a DTO for settings used within the oidc request and token handling. | |
* Performs auto-discovery upon request. | |
*/ | |
class OidcProviderSettings | |
{ | |
public string $issuer; | |
public string $clientId; | |
public string $clientSecret; | |
public ?string $redirectUri; | |
public ?string $authorizationEndpoint; | |
public ?string $tokenEndpoint; | |
public ?string $userInfoEndpoint; | |
/** | |
* @var string[]|array[] | |
*/ | |
public ?array $keys = []; | |
public function __construct(array $settings) | |
{ | |
$this->applySettingsFromArray($settings); | |
$this->validateInitial(); | |
} | |
/** | |
* Apply an array of settings to populate setting properties within this class. | |
*/ | |
protected function applySettingsFromArray(array $settingsArray) | |
{ | |
foreach ($settingsArray as $key => $value) { | |
if (property_exists($this, $key)) { | |
$this->$key = $value; | |
} | |
} | |
} | |
/** | |
* Validate any core, required properties have been set. | |
* | |
* @throws InvalidArgumentException | |
*/ | |
protected function validateInitial() | |
{ | |
$required = ['clientId', 'clientSecret', 'redirectUri', 'issuer']; | |
foreach ($required as $prop) { | |
if (empty($this->$prop)) { | |
throw new InvalidArgumentException("Missing required configuration \"{$prop}\" value"); | |
} | |
} | |
if (strpos($this->issuer, 'https://') !== 0) { | |
throw new InvalidArgumentException('Issuer value must start with https://'); | |
} | |
} | |
/** | |
* Perform a full validation on these settings. | |
* | |
* @throws InvalidArgumentException | |
*/ | |
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"); | |
} | |
} | |
} | |
/** | |
* Discover and autoload settings from the configured issuer. | |
* | |
* @throws OidcIssuerDiscoveryException | |
*/ | |
public function discoverFromIssuer(ClientInterface $httpClient, Repository $cache, int $cacheMinutes) | |
{ | |
try { | |
$cacheKey = 'oidc-discovery::' . $this->issuer; | |
$discoveredSettings = $cache->remember($cacheKey, $cacheMinutes * 60, function () use ($httpClient) { | |
return $this->loadSettingsFromIssuerDiscovery($httpClient); | |
}); | |
$this->applySettingsFromArray($discoveredSettings); | |
} catch (ClientExceptionInterface $exception) { | |
throw new OidcIssuerDiscoveryException("HTTP request failed during discovery with error: {$exception->getMessage()}"); | |
} | |
} | |
/** | |
* @throws OidcIssuerDiscoveryException | |
* @throws ClientExceptionInterface | |
*/ | |
protected function loadSettingsFromIssuerDiscovery(ClientInterface $httpClient): array | |
{ | |
$issuerUrl = rtrim($this->issuer, '/') . '/.well-known/openid-configuration'; | |
$request = new Request('GET', $issuerUrl); | |
$response = $httpClient->sendRequest($request); | |
$result = json_decode($response->getBody()->getContents(), true); | |
if (empty($result) || !is_array($result)) { | |
throw new OidcIssuerDiscoveryException("Error discovering provider settings from issuer at URL {$issuerUrl}"); | |
} | |
if ($result['issuer'] !== $this->issuer) { | |
throw new OidcIssuerDiscoveryException('Unexpected issuer value found on discovery response'); | |
} | |
$discoveredSettings = []; | |
if (!empty($result['authorization_endpoint'])) { | |
$discoveredSettings['authorizationEndpoint'] = $result['authorization_endpoint']; | |
} | |
if (!empty($result['token_endpoint'])) { | |
$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); | |
} | |
return $discoveredSettings; | |
} | |
/** | |
* Filter the given JWK keys down to just those we support. | |
*/ | |
protected function filterKeys(array $keys): array | |
{ | |
return array_filter($keys, function (array $key) { | |
$alg = $key['alg'] ?? 'RS256'; | |
$use = $key['use'] ?? 'sig'; | |
return $key['kty'] === 'RSA' && $use === 'sig' && $alg === 'RS256'; | |
}); | |
} | |
/** | |
* Return an array of jwks as PHP key=>value arrays. | |
* | |
* @throws ClientExceptionInterface | |
* @throws OidcIssuerDiscoveryException | |
*/ | |
protected function loadKeysFromUri(string $uri, ClientInterface $httpClient): array | |
{ | |
$request = new Request('GET', $uri); | |
$response = $httpClient->sendRequest($request); | |
$result = json_decode($response->getBody()->getContents(), true); | |
if (empty($result) || !is_array($result) || !isset($result['keys'])) { | |
throw new OidcIssuerDiscoveryException('Error reading keys from issuer jwks_uri'); | |
} | |
return $result['keys']; | |
} | |
/** | |
* Get the settings needed by an OAuth provider, as a key=>value array. | |
*/ | |
public function arrayForProvider(): array | |
{ | |
$settingKeys = ['clientId', 'clientSecret', 'redirectUri', 'authorizationEndpoint', 'tokenEndpoint']; | |
$settings = []; | |
foreach ($settingKeys as $setting) { | |
$settings[$setting] = $this->$setting; | |
} | |
return $settings; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
namespace BookStack\Auth\Access\Oidc; | |
use BookStack\Auth\Access\GroupSyncService; | |
use BookStack\Auth\Access\LoginService; | |
use BookStack\Auth\Access\RegistrationService; | |
use BookStack\Auth\User; | |
use BookStack\Exceptions\JsonDebugException; | |
use BookStack\Exceptions\StoppedAuthenticationException; | |
use BookStack\Exceptions\UserRegistrationException; | |
use Illuminate\Support\Arr; | |
use Illuminate\Support\Facades\Cache; | |
use League\OAuth2\Client\OptionProvider\HttpBasicAuthOptionProvider; | |
use League\OAuth2\Client\Provider\Exception\IdentityProviderException; | |
use Psr\Http\Client\ClientInterface as HttpClient; | |
use Illuminate\Support\Facades\Log; | |
/** | |
* Class OpenIdConnectService | |
* Handles any app-specific OIDC tasks. | |
*/ | |
class OidcService | |
{ | |
protected RegistrationService $registrationService; | |
protected LoginService $loginService; | |
protected HttpClient $httpClient; | |
protected GroupSyncService $groupService; | |
/** | |
* OpenIdService constructor. | |
*/ | |
public function __construct( | |
RegistrationService $registrationService, | |
LoginService $loginService, | |
HttpClient $httpClient, | |
GroupSyncService $groupService | |
) { | |
$this->registrationService = $registrationService; | |
$this->loginService = $loginService; | |
$this->httpClient = $httpClient; | |
$this->groupService = $groupService; | |
} | |
/** | |
* Initiate an authorization flow. | |
* | |
* @throws OidcException | |
* | |
* @return array{url: string, state: string} | |
*/ | |
public function login(): array | |
{ | |
$settings = $this->getProviderSettings(); | |
$provider = $this->getProvider($settings); | |
return [ | |
'url' => $provider->getAuthorizationUrl(), | |
'state' => $provider->getState(), | |
]; | |
} | |
/** | |
* Process the Authorization response from the authorization server and | |
* return the matching, or new if registration active, user matched to the | |
* authorization server. Throws if the user cannot be auth if not authenticated. | |
* | |
* @throws JsonDebugException | |
* @throws OidcException | |
* @throws StoppedAuthenticationException | |
* @throws IdentityProviderException | |
*/ | |
public function processAuthorizeResponse(?string $authorizationCode): User | |
{ | |
$settings = $this->getProviderSettings(); | |
$provider = $this->getProvider($settings); | |
// Try to exchange authorization code for access token | |
$accessToken = $provider->getAccessToken('authorization_code', [ | |
'code' => $authorizationCode, | |
]); | |
return $this->processAccessTokenCallback($accessToken, $settings); | |
} | |
/** | |
* @throws OidcException | |
*/ | |
protected function getProviderSettings(): OidcProviderSettings | |
{ | |
$config = $this->config(); | |
$settings = new OidcProviderSettings([ | |
'issuer' => $config['issuer'], | |
'clientId' => $config['client_id'], | |
'clientSecret' => $config['client_secret'], | |
'redirectUri' => url('/oidc/callback'), | |
'authorizationEndpoint' => $config['authorization_endpoint'], | |
'tokenEndpoint' => $config['token_endpoint'], | |
]); | |
// Use keys if configured | |
if (!empty($config['jwt_public_key'])) { | |
$settings->keys = [$config['jwt_public_key']]; | |
} | |
// Run discovery | |
if ($config['discover'] ?? false) { | |
try { | |
$settings->discoverFromIssuer($this->httpClient, Cache::store(null), 15); | |
} catch (OidcIssuerDiscoveryException $exception) { | |
throw new OidcException('OIDC Discovery Error: ' . $exception->getMessage()); | |
} | |
} | |
$settings->validate(); | |
return $settings; | |
} | |
/** | |
* Load the underlying OpenID Connect Provider. | |
*/ | |
protected function getProvider(OidcProviderSettings $settings): OidcOAuthProvider | |
{ | |
$provider = new OidcOAuthProvider($settings->arrayForProvider(), [ | |
'httpClient' => $this->httpClient, | |
'optionProvider' => new HttpBasicAuthOptionProvider(), | |
]); | |
foreach ($this->getAdditionalScopes() as $scope) { | |
$provider->addScope($scope); | |
} | |
return $provider; | |
} | |
/** | |
* Get any user-defined addition/custom scopes to apply to the authentication request. | |
* | |
* @return string[] | |
*/ | |
protected function getAdditionalScopes(): array | |
{ | |
$scopeConfig = $this->config()['additional_scopes'] ?: ''; | |
$scopeArr = explode(',', $scopeConfig); | |
$scopeArr = array_map(fn (string $scope) => trim($scope), $scopeArr); | |
return array_filter($scopeArr); | |
} | |
/** | |
* Query the user info endpoint for the user's details. | |
* | |
* @return array{name: string, email: string, external_id: string, groups: string[]} | |
*/ | |
protected function getUserInfo(OidcAccessToken $accessToken): ?array | |
{ | |
$settings = $this->getProviderSettings(); | |
$user_info_endpoint = $settings->userInfoEndpoint; | |
$headers = [ | |
'Authorization: Bearer ' . $accessToken, | |
'Content-Type: application/json', | |
]; | |
$curl = curl_init(); | |
curl_setopt_array($curl, [ | |
CURLOPT_URL => $user_info_endpoint, | |
CURLOPT_RETURNTRANSFER => true, | |
CURLOPT_HTTPHEADER => $headers, | |
]); | |
$responseBody = curl_exec($curl); | |
curl_close($curl); | |
$userinfo = json_decode($responseBody, true); | |
return $userinfo ?? null; | |
} | |
/** | |
* Get the user's name using the access token. | |
* | |
* @return string | |
*/ | |
protected function getUserName(OidcAccessToken $token): string | |
{ | |
$nameAttr = $this->config()['display_name_claims'] ?? 'name'; | |
// Get the user info from the endpoint | |
$userInfo = $this->getUserInfo($token); | |
if (is_array($nameAttr)) { | |
$name = ''; | |
foreach ($nameAttr as $attr) { | |
if (isset($userInfo[$attr])) { | |
$name = $userInfo[$attr]; | |
break; | |
} | |
} | |
} else { | |
$name = $userInfo[$nameAttr] ?? 'Anonymous'; | |
} | |
return $name; | |
} | |
/** | |
* Get the user's groups using the access token. | |
* | |
* @return string[] | |
*/ | |
protected function getUserGroups(OidcAccessToken $token): array | |
{ | |
$groupsAttr = $this->config()['groups_claim']; | |
if (empty($groupsAttr)) { | |
return []; | |
} | |
// $groupsList = Arr::get($token->getAllClaims(), $groupsAttr); | |
// Instead we use the user info endpoint | |
$userInfo = $this->getUserInfo($token); | |
$groupsList = $userInfo[$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, OidcAccessToken $accessToken): array | |
{ | |
$idClaim = 'sub'; | |
$id = $token->getClaim($idClaim); | |
return [ | |
'external_id' => $id, | |
'email' => $token->getClaim('email'), | |
'name' => $this->getUserName($accessToken), | |
'groups' => $this->getUserGroups($accessToken), | |
]; | |
} | |
/** | |
* Processes a received access token for a user. Login the user when | |
* they exist, optionally registering them automatically. | |
* | |
* @throws OidcException | |
* @throws JsonDebugException | |
* @throws StoppedAuthenticationException | |
*/ | |
protected function processAccessTokenCallback(OidcAccessToken $accessToken, OidcProviderSettings $settings): User | |
{ | |
$idTokenText = $accessToken->getIdToken(); | |
$idToken = new OidcIdToken( | |
$idTokenText, | |
$settings->issuer, | |
$settings->keys, | |
); | |
try { | |
$idToken->validate($settings->clientId); | |
} catch (OidcInvalidTokenException $exception) { | |
throw new OidcException("ID token validate failed with error: {$exception->getMessage()}"); | |
} | |
$userDetails = $this->getUserDetails($idToken, $accessToken); | |
if ($this->config()['dump_user_details']) { | |
throw new JsonDebugException($userDetails); | |
} | |
if ($this->config()['dump_user_details']) { | |
throw new JsonDebugException($idToken->getAllClaims()); | |
} | |
try { | |
$idToken->validate($settings->clientId); | |
} catch (OidcInvalidTokenException $exception) { | |
throw new OidcException("ID token validate failed with error: {$exception->getMessage()}"); | |
} | |
$userDetails = $this->getUserDetails($idToken); | |
$isLoggedIn = auth()->check(); | |
if (empty($userDetails['email'])) { | |
throw new OidcException(trans('errors.oidc_no_email_address')); | |
} | |
if ($isLoggedIn) { | |
throw new OidcException(trans('errors.oidc_already_logged_in')); | |
} | |
try { | |
$user = $this->registrationService->findOrRegister( | |
$userDetails['name'], | |
$userDetails['email'], | |
$userDetails['external_id'] | |
); | |
} 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->loginService->login($user, 'oidc'); | |
return $user; | |
} | |
/** | |
* Get the OIDC config from the application. | |
*/ | |
protected function config(): array | |
{ | |
return config('oidc'); | |
} | |
/** | |
* Check if groups should be synced. | |
*/ | |
protected function shouldSyncGroups(): bool | |
{ | |
return $this->config()['user_to_groups'] !== false; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment