Skip to content

Commit d949b97

Browse files
authored
Merge pull request #4955 from BookStackApp/oidc_userinfo
OIDC userinfo endpoint support
2 parents 80ac66e + 8b14a70 commit d949b97

11 files changed

+580
-230
lines changed

.env.example.complete

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,7 @@ OIDC_ISSUER_DISCOVER=false
267267
OIDC_PUBLIC_KEY=null
268268
OIDC_AUTH_ENDPOINT=null
269269
OIDC_TOKEN_ENDPOINT=null
270+
OIDC_USERINFO_ENDPOINT=null
270271
OIDC_ADDITIONAL_SCOPES=null
271272
OIDC_DUMP_USER_DETAILS=false
272273
OIDC_USER_TO_GROUPS=false

app/Access/Oidc/OidcIdToken.php

Lines changed: 4 additions & 142 deletions
Original file line numberDiff line numberDiff line change
@@ -2,150 +2,21 @@
22

33
namespace BookStack\Access\Oidc;
44

5-
class OidcIdToken
5+
class OidcIdToken extends OidcJwtWithClaims implements ProvidesClaims
66
{
7-
protected array $header;
8-
protected array $payload;
9-
protected string $signature;
10-
protected string $issuer;
11-
protected array $tokenParts = [];
12-
13-
/**
14-
* @var array[]|string[]
15-
*/
16-
protected array $keys;
17-
18-
public function __construct(string $token, string $issuer, array $keys)
19-
{
20-
$this->keys = $keys;
21-
$this->issuer = $issuer;
22-
$this->parse($token);
23-
}
24-
25-
/**
26-
* Parse the token content into its components.
27-
*/
28-
protected function parse(string $token): void
29-
{
30-
$this->tokenParts = explode('.', $token);
31-
$this->header = $this->parseEncodedTokenPart($this->tokenParts[0]);
32-
$this->payload = $this->parseEncodedTokenPart($this->tokenParts[1] ?? '');
33-
$this->signature = $this->base64UrlDecode($this->tokenParts[2] ?? '') ?: '';
34-
}
35-
36-
/**
37-
* Parse a Base64-JSON encoded token part.
38-
* Returns the data as a key-value array or empty array upon error.
39-
*/
40-
protected function parseEncodedTokenPart(string $part): array
41-
{
42-
$json = $this->base64UrlDecode($part) ?: '{}';
43-
$decoded = json_decode($json, true);
44-
45-
return is_array($decoded) ? $decoded : [];
46-
}
47-
48-
/**
49-
* Base64URL decode. Needs some character conversions to be compatible
50-
* with PHP's default base64 handling.
51-
*/
52-
protected function base64UrlDecode(string $encoded): string
53-
{
54-
return base64_decode(strtr($encoded, '-_', '+/'));
55-
}
56-
577
/**
588
* Validate all possible parts of the id token.
599
*
6010
* @throws OidcInvalidTokenException
6111
*/
6212
public function validate(string $clientId): bool
6313
{
64-
$this->validateTokenStructure();
65-
$this->validateTokenSignature();
14+
parent::validateCommonTokenDetails($clientId);
6615
$this->validateTokenClaims($clientId);
6716

6817
return true;
6918
}
7019

71-
/**
72-
* Fetch a specific claim from this token.
73-
* Returns null if it is null or does not exist.
74-
*
75-
* @return mixed|null
76-
*/
77-
public function getClaim(string $claim)
78-
{
79-
return $this->payload[$claim] ?? null;
80-
}
81-
82-
/**
83-
* Get all returned claims within the token.
84-
*/
85-
public function getAllClaims(): array
86-
{
87-
return $this->payload;
88-
}
89-
90-
/**
91-
* Replace the existing claim data of this token with that provided.
92-
*/
93-
public function replaceClaims(array $claims): void
94-
{
95-
$this->payload = $claims;
96-
}
97-
98-
/**
99-
* Validate the structure of the given token and ensure we have the required pieces.
100-
* As per https://datatracker.ietf.org/doc/html/rfc7519#section-7.2.
101-
*
102-
* @throws OidcInvalidTokenException
103-
*/
104-
protected function validateTokenStructure(): void
105-
{
106-
foreach (['header', 'payload'] as $prop) {
107-
if (empty($this->$prop) || !is_array($this->$prop)) {
108-
throw new OidcInvalidTokenException("Could not parse out a valid {$prop} within the provided token");
109-
}
110-
}
111-
112-
if (empty($this->signature) || !is_string($this->signature)) {
113-
throw new OidcInvalidTokenException('Could not parse out a valid signature within the provided token');
114-
}
115-
}
116-
117-
/**
118-
* Validate the signature of the given token and ensure it validates against the provided key.
119-
*
120-
* @throws OidcInvalidTokenException
121-
*/
122-
protected function validateTokenSignature(): void
123-
{
124-
if ($this->header['alg'] !== 'RS256') {
125-
throw new OidcInvalidTokenException("Only RS256 signature validation is supported. Token reports using {$this->header['alg']}");
126-
}
127-
128-
$parsedKeys = array_map(function ($key) {
129-
try {
130-
return new OidcJwtSigningKey($key);
131-
} catch (OidcInvalidKeyException $e) {
132-
throw new OidcInvalidTokenException('Failed to read signing key with error: ' . $e->getMessage());
133-
}
134-
}, $this->keys);
135-
136-
$parsedKeys = array_filter($parsedKeys);
137-
138-
$contentToSign = $this->tokenParts[0] . '.' . $this->tokenParts[1];
139-
/** @var OidcJwtSigningKey $parsedKey */
140-
foreach ($parsedKeys as $parsedKey) {
141-
if ($parsedKey->verify($contentToSign, $this->signature)) {
142-
return;
143-
}
144-
}
145-
146-
throw new OidcInvalidTokenException('Token signature could not be validated using the provided keys');
147-
}
148-
14920
/**
15021
* Validate the claims of the token.
15122
* As per https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation.
@@ -156,27 +27,18 @@ protected function validateTokenClaims(string $clientId): void
15627
{
15728
// 1. The Issuer Identifier for the OpenID Provider (which is typically obtained during Discovery)
15829
// MUST exactly match the value of the iss (issuer) Claim.
159-
if (empty($this->payload['iss']) || $this->issuer !== $this->payload['iss']) {
160-
throw new OidcInvalidTokenException('Missing or non-matching token issuer value');
161-
}
30+
// Already done in parent.
16231

16332
// 2. The Client MUST validate that the aud (audience) Claim contains its client_id value registered
16433
// at the Issuer identified by the iss (issuer) Claim as an audience. The ID Token MUST be rejected
16534
// if the ID Token does not list the Client as a valid audience, or if it contains additional
16635
// audiences not trusted by the Client.
167-
if (empty($this->payload['aud'])) {
168-
throw new OidcInvalidTokenException('Missing token audience value');
169-
}
170-
36+
// Partially done in parent.
17137
$aud = is_string($this->payload['aud']) ? [$this->payload['aud']] : $this->payload['aud'];
17238
if (count($aud) !== 1) {
17339
throw new OidcInvalidTokenException('Token audience value has ' . count($aud) . ' values, Expected 1');
17440
}
17541

176-
if ($aud[0] !== $clientId) {
177-
throw new OidcInvalidTokenException('Token audience value did not match the expected client_id');
178-
}
179-
18042
// 3. If the ID Token contains multiple audiences, the Client SHOULD verify that an azp Claim is present.
18143
// NOTE: Addressed by enforcing a count of 1 above.
18244

app/Access/Oidc/OidcJwtWithClaims.php

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
<?php
2+
3+
namespace BookStack\Access\Oidc;
4+
5+
class OidcJwtWithClaims implements ProvidesClaims
6+
{
7+
protected array $header;
8+
protected array $payload;
9+
protected string $signature;
10+
protected string $issuer;
11+
protected array $tokenParts = [];
12+
13+
/**
14+
* @var array[]|string[]
15+
*/
16+
protected array $keys;
17+
18+
public function __construct(string $token, string $issuer, array $keys)
19+
{
20+
$this->keys = $keys;
21+
$this->issuer = $issuer;
22+
$this->parse($token);
23+
}
24+
25+
/**
26+
* Parse the token content into its components.
27+
*/
28+
protected function parse(string $token): void
29+
{
30+
$this->tokenParts = explode('.', $token);
31+
$this->header = $this->parseEncodedTokenPart($this->tokenParts[0]);
32+
$this->payload = $this->parseEncodedTokenPart($this->tokenParts[1] ?? '');
33+
$this->signature = $this->base64UrlDecode($this->tokenParts[2] ?? '') ?: '';
34+
}
35+
36+
/**
37+
* Parse a Base64-JSON encoded token part.
38+
* Returns the data as a key-value array or empty array upon error.
39+
*/
40+
protected function parseEncodedTokenPart(string $part): array
41+
{
42+
$json = $this->base64UrlDecode($part) ?: '{}';
43+
$decoded = json_decode($json, true);
44+
45+
return is_array($decoded) ? $decoded : [];
46+
}
47+
48+
/**
49+
* Base64URL decode. Needs some character conversions to be compatible
50+
* with PHP's default base64 handling.
51+
*/
52+
protected function base64UrlDecode(string $encoded): string
53+
{
54+
return base64_decode(strtr($encoded, '-_', '+/'));
55+
}
56+
57+
/**
58+
* Validate common parts of OIDC JWT tokens.
59+
*
60+
* @throws OidcInvalidTokenException
61+
*/
62+
public function validateCommonTokenDetails(string $clientId): bool
63+
{
64+
$this->validateTokenStructure();
65+
$this->validateTokenSignature();
66+
$this->validateCommonClaims($clientId);
67+
68+
return true;
69+
}
70+
71+
/**
72+
* Fetch a specific claim from this token.
73+
* Returns null if it is null or does not exist.
74+
*/
75+
public function getClaim(string $claim): mixed
76+
{
77+
return $this->payload[$claim] ?? null;
78+
}
79+
80+
/**
81+
* Get all returned claims within the token.
82+
*/
83+
public function getAllClaims(): array
84+
{
85+
return $this->payload;
86+
}
87+
88+
/**
89+
* Replace the existing claim data of this token with that provided.
90+
*/
91+
public function replaceClaims(array $claims): void
92+
{
93+
$this->payload = $claims;
94+
}
95+
96+
/**
97+
* Validate the structure of the given token and ensure we have the required pieces.
98+
* As per https://datatracker.ietf.org/doc/html/rfc7519#section-7.2.
99+
*
100+
* @throws OidcInvalidTokenException
101+
*/
102+
protected function validateTokenStructure(): void
103+
{
104+
foreach (['header', 'payload'] as $prop) {
105+
if (empty($this->$prop) || !is_array($this->$prop)) {
106+
throw new OidcInvalidTokenException("Could not parse out a valid {$prop} within the provided token");
107+
}
108+
}
109+
110+
if (empty($this->signature) || !is_string($this->signature)) {
111+
throw new OidcInvalidTokenException('Could not parse out a valid signature within the provided token');
112+
}
113+
}
114+
115+
/**
116+
* Validate the signature of the given token and ensure it validates against the provided key.
117+
*
118+
* @throws OidcInvalidTokenException
119+
*/
120+
protected function validateTokenSignature(): void
121+
{
122+
if ($this->header['alg'] !== 'RS256') {
123+
throw new OidcInvalidTokenException("Only RS256 signature validation is supported. Token reports using {$this->header['alg']}");
124+
}
125+
126+
$parsedKeys = array_map(function ($key) {
127+
try {
128+
return new OidcJwtSigningKey($key);
129+
} catch (OidcInvalidKeyException $e) {
130+
throw new OidcInvalidTokenException('Failed to read signing key with error: ' . $e->getMessage());
131+
}
132+
}, $this->keys);
133+
134+
$parsedKeys = array_filter($parsedKeys);
135+
136+
$contentToSign = $this->tokenParts[0] . '.' . $this->tokenParts[1];
137+
/** @var OidcJwtSigningKey $parsedKey */
138+
foreach ($parsedKeys as $parsedKey) {
139+
if ($parsedKey->verify($contentToSign, $this->signature)) {
140+
return;
141+
}
142+
}
143+
144+
throw new OidcInvalidTokenException('Token signature could not be validated using the provided keys');
145+
}
146+
147+
/**
148+
* Validate common claims for OIDC JWT tokens.
149+
* As per https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation
150+
* and https://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse
151+
*
152+
* @throws OidcInvalidTokenException
153+
*/
154+
protected function validateCommonClaims(string $clientId): void
155+
{
156+
// 1. The Issuer Identifier for the OpenID Provider (which is typically obtained during Discovery)
157+
// MUST exactly match the value of the iss (issuer) Claim.
158+
if (empty($this->payload['iss']) || $this->issuer !== $this->payload['iss']) {
159+
throw new OidcInvalidTokenException('Missing or non-matching token issuer value');
160+
}
161+
162+
// 2. The Client MUST validate that the aud (audience) Claim contains its client_id value registered
163+
// at the Issuer identified by the iss (issuer) Claim as an audience. The ID Token MUST be rejected
164+
// if the ID Token does not list the Client as a valid audience.
165+
if (empty($this->payload['aud'])) {
166+
throw new OidcInvalidTokenException('Missing token audience value');
167+
}
168+
169+
$aud = is_string($this->payload['aud']) ? [$this->payload['aud']] : $this->payload['aud'];
170+
if (!in_array($clientId, $aud, true)) {
171+
throw new OidcInvalidTokenException('Token audience value did not match the expected client_id');
172+
}
173+
}
174+
}

0 commit comments

Comments
 (0)