Skip to content

Commit a71c8c6

Browse files
committed
OIDC: Extracted user detail handling to own OidcUserDetails class
Allows a proper defined object instead of an array an extracts related logic out of OidcService. Updated userinfo to only be called if we're missing details.
1 parent 9183e7f commit a71c8c6

File tree

2 files changed

+114
-92
lines changed

2 files changed

+114
-92
lines changed

app/Access/Oidc/OidcService.php

Lines changed: 31 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
use BookStack\Http\HttpRequestService;
1313
use BookStack\Theming\ThemeEvents;
1414
use BookStack\Users\Models\User;
15-
use Illuminate\Support\Arr;
1615
use Illuminate\Support\Facades\Cache;
1716
use League\OAuth2\Client\OptionProvider\HttpBasicAuthOptionProvider;
1817
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
@@ -159,69 +158,6 @@ protected function getAdditionalScopes(): array
159158
return array_filter($scopeArr);
160159
}
161160

162-
/**
163-
* Calculate the display name.
164-
*/
165-
protected function getUserDisplayName(OidcIdToken $token, string $defaultValue): string
166-
{
167-
$displayNameAttrString = $this->config()['display_name_claims'] ?? '';
168-
$displayNameAttrs = explode('|', $displayNameAttrString);
169-
170-
$displayName = [];
171-
foreach ($displayNameAttrs as $dnAttr) {
172-
$dnComponent = $token->getClaim($dnAttr) ?? '';
173-
if ($dnComponent !== '') {
174-
$displayName[] = $dnComponent;
175-
}
176-
}
177-
178-
if (count($displayName) == 0) {
179-
$displayName[] = $defaultValue;
180-
}
181-
182-
return implode(' ', $displayName);
183-
}
184-
185-
/**
186-
* Extract the assigned groups from the id token.
187-
*
188-
* @return string[]
189-
*/
190-
protected function getUserGroups(OidcIdToken $token): array
191-
{
192-
$groupsAttr = $this->config()['groups_claim'];
193-
if (empty($groupsAttr)) {
194-
return [];
195-
}
196-
197-
$groupsList = Arr::get($token->getAllClaims(), $groupsAttr);
198-
if (!is_array($groupsList)) {
199-
return [];
200-
}
201-
202-
return array_values(array_filter($groupsList, function ($val) {
203-
return is_string($val);
204-
}));
205-
}
206-
207-
/**
208-
* Extract the details of a user from an ID token.
209-
*
210-
* @return array{name: string, email: string, external_id: string, groups: string[]}
211-
*/
212-
protected function getUserDetails(OidcIdToken $token): array
213-
{
214-
$idClaim = $this->config()['external_id_claim'];
215-
$id = $token->getClaim($idClaim);
216-
217-
return [
218-
'external_id' => $id,
219-
'email' => $token->getClaim('email'),
220-
'name' => $this->getUserDisplayName($token, $id),
221-
'groups' => $this->getUserGroups($token),
222-
];
223-
}
224-
225161
/**
226162
* Processes a received access token for a user. Login the user when
227163
* they exist, optionally registering them automatically.
@@ -241,26 +177,6 @@ protected function processAccessTokenCallback(OidcAccessToken $accessToken, Oidc
241177

242178
session()->put("oidc_id_token", $idTokenText);
243179

244-
// TODO - This should not affect id token validation
245-
// TODO - Should only call if we're missing properties
246-
if (!empty($settings->userinfoEndpoint)) {
247-
$provider = $this->getProvider($settings);
248-
$request = $provider->getAuthenticatedRequest('GET', $settings->userinfoEndpoint, $accessToken->getToken());
249-
$response = $provider->getParsedResponse($request);
250-
// TODO - Ensure response content-type is "application/json" before using in this way (5.3.2)
251-
// TODO - 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. (5.3.2)
252-
// TODO - Response validation (5.3.4)
253-
// TODO - Verify that the OP that responded was the intended OP through a TLS server certificate check, per RFC 6125 [RFC6125].
254-
// TODO - If the Client has provided a userinfo_encrypted_response_alg parameter during Registration, decrypt the UserInfo Response using the keys specified during Registration.
255-
// TODO - If the response was signed, the Client SHOULD validate the signature according to JWS [JWS].
256-
$claims = $idToken->getAllClaims();
257-
foreach ($response as $key => $value) {
258-
$claims[$key] = $value;
259-
}
260-
// TODO - Should maybe remain separate from IdToken completely
261-
$idToken->replaceClaims($claims);
262-
}
263-
264180
$returnClaims = Theme::dispatch(ThemeEvents::OIDC_ID_TOKEN_PRE_VALIDATE, $idToken->getAllClaims(), [
265181
'access_token' => $accessToken->getToken(),
266182
'expires_in' => $accessToken->getExpires(),
@@ -281,31 +197,54 @@ protected function processAccessTokenCallback(OidcAccessToken $accessToken, Oidc
281197
throw new OidcException("ID token validate failed with error: {$exception->getMessage()}");
282198
}
283199

284-
$userDetails = $this->getUserDetails($idToken);
285-
$isLoggedIn = auth()->check();
200+
$userDetails = OidcUserDetails::fromToken(
201+
$idToken,
202+
$this->config()['external_id_claim'],
203+
$this->config()['display_name_claims'] ?? '',
204+
$this->config()['groups_claim'] ?? ''
205+
);
286206

287-
if (empty($userDetails['email'])) {
207+
// TODO - This should not affect id token validation
208+
if (!$userDetails->isFullyPopulated($this->shouldSyncGroups()) && !empty($settings->userinfoEndpoint)) {
209+
$provider = $this->getProvider($settings);
210+
$request = $provider->getAuthenticatedRequest('GET', $settings->userinfoEndpoint, $accessToken->getToken());
211+
$response = $provider->getParsedResponse($request);
212+
// TODO - Ensure response content-type is "application/json" before using in this way (5.3.2)
213+
// TODO - 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. (5.3.2)
214+
// TODO - Response validation (5.3.4)
215+
// TODO - Verify that the OP that responded was the intended OP through a TLS server certificate check, per RFC 6125 [RFC6125].
216+
// TODO - If the Client has provided a userinfo_encrypted_response_alg parameter during Registration, decrypt the UserInfo Response using the keys specified during Registration.
217+
// TODO - If the response was signed, the Client SHOULD validate the signature according to JWS [JWS].
218+
$claims = $idToken->getAllClaims();
219+
foreach ($response as $key => $value) {
220+
$claims[$key] = $value;
221+
}
222+
// TODO - Should maybe remain separate from IdToken completely
223+
$idToken->replaceClaims($claims);
224+
}
225+
226+
if (empty($userDetails->email)) {
288227
throw new OidcException(trans('errors.oidc_no_email_address'));
289228
}
290229

230+
$isLoggedIn = auth()->check();
291231
if ($isLoggedIn) {
292232
throw new OidcException(trans('errors.oidc_already_logged_in'));
293233
}
294234

295235
try {
296236
$user = $this->registrationService->findOrRegister(
297-
$userDetails['name'],
298-
$userDetails['email'],
299-
$userDetails['external_id']
237+
$userDetails->name,
238+
$userDetails->email,
239+
$userDetails->externalId
300240
);
301241
} catch (UserRegistrationException $exception) {
302242
throw new OidcException($exception->getMessage());
303243
}
304244

305245
if ($this->shouldSyncGroups()) {
306-
$groups = $userDetails['groups'];
307246
$detachExisting = $this->config()['remove_from_groups'];
308-
$this->groupService->syncUserWithFoundGroups($user, $groups, $detachExisting);
247+
$this->groupService->syncUserWithFoundGroups($user, $userDetails->groups ?? [], $detachExisting);
309248
}
310249

311250
$this->loginService->login($user, 'oidc');

app/Access/Oidc/OidcUserDetails.php

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
<?php
2+
3+
namespace BookStack\Access\Oidc;
4+
5+
use Illuminate\Support\Arr;
6+
7+
class OidcUserDetails
8+
{
9+
public function __construct(
10+
public ?string $externalId = null,
11+
public ?string $email = null,
12+
public ?string $name = null,
13+
public ?array $groups = null,
14+
) {
15+
}
16+
17+
/**
18+
* Check if the user details are fully populated for our usage.
19+
*/
20+
public function isFullyPopulated(bool $groupSyncActive): bool
21+
{
22+
$hasEmpty = empty($this->externalId)
23+
|| empty($this->email)
24+
|| empty($this->name)
25+
|| ($groupSyncActive && empty($this->groups));
26+
27+
return !$hasEmpty;
28+
}
29+
30+
/**
31+
* Populate user details from OidcIdToken data.
32+
*/
33+
public static function fromToken(
34+
OidcIdToken $token,
35+
string $idClaim,
36+
string $displayNameClaims,
37+
string $groupsClaim,
38+
): static {
39+
$id = $token->getClaim($idClaim);
40+
41+
return new self(
42+
externalId: $id,
43+
email: $token->getClaim('email'),
44+
name: static::getUserDisplayName($displayNameClaims, $token, $id),
45+
groups: static::getUserGroups($groupsClaim, $token),
46+
);
47+
}
48+
49+
protected static function getUserDisplayName(string $displayNameClaims, OidcIdToken $token, string $defaultValue): string
50+
{
51+
$displayNameClaimParts = explode('|', $displayNameClaims);
52+
53+
$displayName = [];
54+
foreach ($displayNameClaimParts as $claim) {
55+
$component = $token->getClaim(trim($claim)) ?? '';
56+
if ($component !== '') {
57+
$displayName[] = $component;
58+
}
59+
}
60+
61+
if (count($displayName) === 0) {
62+
$displayName[] = $defaultValue;
63+
}
64+
65+
return implode(' ', $displayName);
66+
}
67+
68+
protected static function getUserGroups(string $groupsClaim, OidcIdToken $token): array
69+
{
70+
if (empty($groupsClaim)) {
71+
return [];
72+
}
73+
74+
$groupsList = Arr::get($token->getAllClaims(), $groupsClaim);
75+
if (!is_array($groupsList)) {
76+
return [];
77+
}
78+
79+
return array_values(array_filter($groupsList, function ($val) {
80+
return is_string($val);
81+
}));
82+
}
83+
}

0 commit comments

Comments
 (0)