Skip to content

Commit b18cee3

Browse files
committed
OIDC Userinfo: Added JWT signed response support
Not yet tested, nor checked all response validations.
1 parent fa543bb commit b18cee3

File tree

4 files changed

+196
-144
lines changed

4 files changed

+196
-144
lines changed

app/Access/Oidc/OidcIdToken.php

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

33
namespace BookStack\Access\Oidc;
44

5-
class OidcIdToken implements ProvidesClaims
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::validateCommonClaims();
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-
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-
14720
/**
14821
* Validate the claims of the token.
14922
* As per https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation.
@@ -154,27 +27,18 @@ protected function validateTokenClaims(string $clientId): void
15427
{
15528
// 1. The Issuer Identifier for the OpenID Provider (which is typically obtained during Discovery)
15629
// MUST exactly match the value of the iss (issuer) Claim.
157-
if (empty($this->payload['iss']) || $this->issuer !== $this->payload['iss']) {
158-
throw new OidcInvalidTokenException('Missing or non-matching token issuer value');
159-
}
30+
// Already done in parent.
16031

16132
// 2. The Client MUST validate that the aud (audience) Claim contains its client_id value registered
16233
// at the Issuer identified by the iss (issuer) Claim as an audience. The ID Token MUST be rejected
16334
// if the ID Token does not list the Client as a valid audience, or if it contains additional
16435
// audiences not trusted by the Client.
165-
if (empty($this->payload['aud'])) {
166-
throw new OidcInvalidTokenException('Missing token audience value');
167-
}
168-
36+
// Partially done in parent.
16937
$aud = is_string($this->payload['aud']) ? [$this->payload['aud']] : $this->payload['aud'];
17038
if (count($aud) !== 1) {
17139
throw new OidcInvalidTokenException('Token audience value has ' . count($aud) . ' values, Expected 1');
17240
}
17341

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

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+
protected function validateCommonTokenDetails(): bool
63+
{
64+
$this->validateTokenStructure();
65+
$this->validateTokenSignature();
66+
$this->validateCommonClaims();
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+
public function validateCommonClaims(): 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($this->payload['aud'], $aud, true)) {
171+
throw new OidcInvalidTokenException('Token audience value did not match the expected client_id');
172+
}
173+
}
174+
}

app/Access/Oidc/OidcService.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,11 @@ protected function getUserDetailsFromToken(OidcIdToken $idToken, OidcAccessToken
246246
if (!$userDetails->isFullyPopulated($this->shouldSyncGroups()) && !empty($settings->userinfoEndpoint)) {
247247
$provider = $this->getProvider($settings);
248248
$request = $provider->getAuthenticatedRequest('GET', $settings->userinfoEndpoint, $accessToken->getToken());
249-
$response = new OidcUserinfoResponse($provider->getResponse($request));
249+
$response = new OidcUserinfoResponse(
250+
$provider->getResponse($request),
251+
$settings->issuer,
252+
$settings->keys,
253+
);
250254

251255
try {
252256
$response->validate($idToken->getClaim('sub'));

0 commit comments

Comments
 (0)