Skip to content

Commit ea830b6

Browse files
authored
Merge pull request #32 from pdsinterop/fix/jkt-check
Fix for JKT check
2 parents e07c22d + bc9e227 commit ea830b6

File tree

2 files changed

+201
-44
lines changed

2 files changed

+201
-44
lines changed

src/Utils/DPop.php

Lines changed: 122 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -59,13 +59,7 @@ public function getWebId($request) {
5959

6060
//@FIXME: check that there is just one DPoP token in the request
6161
try {
62-
$dpopKey = $this->getDpopKey($dpop, $request);
63-
} catch (InvalidTokenStructure $e) {
64-
throw new InvalidTokenException("Invalid JWT token: {$e->getMessage()}", 0, $e);
65-
}
66-
67-
try {
68-
$this->validateJwtDpop($jwt, $dpopKey);
62+
$this->validateJwtDpop($jwt, $dpop, $request);
6963
} catch (RequiredConstraintsViolated $e) {
7064
throw new InvalidTokenException($e->getMessage(), 0, $e);
7165
}
@@ -76,20 +70,16 @@ public function getWebId($request) {
7670
}
7771

7872
/**
79-
* Returns the "kid" from the "jwk" header in the DPoP token.
80-
* The DPoP token must be valid.
81-
*
82-
* @param string $dpop The DPoP token
83-
* @param ServerRequestInterface $request Server Request
84-
*
85-
* @return string the "kid" from the "jwk" header in the DPoP token.
86-
*
87-
* @throws RequiredConstraintsViolated
73+
* kept for backwards compatability
74+
* note: the "kid" value is not guaranteed to be a hash of the jwk
75+
* so to compare a jkt, calculate the jwk thumbprint instead
76+
* @param string $dpop The DPoP token, raw
77+
* @param ServerRequestInterface $request Server Request
78+
* @return string The "kid" from the "jwk" header
8879
*/
8980
public function getDpopKey($dpop, $request) {
9081
$this->validateDpop($dpop, $request);
9182

92-
// 1. the string value is a well-formed JWT,
9383
$jwtConfig = Configuration::forUnsecuredSigner();
9484
$dpop = $jwtConfig->parser()->parse($dpop);
9585
$jwk = $dpop->headers()->get("jwk");
@@ -101,25 +91,126 @@ public function getDpopKey($dpop, $request) {
10191
return $jwk['kid'];
10292
}
10393

104-
private function validateJwtDpop($jwt, $dpopKey) {
94+
/**
95+
* RFC7638 defines a method for computing the hash value (or "digest") of a JSON Web Key (JWK).
96+
*
97+
* The resulting hash value can be used for identifying the key represented by the JWK
98+
* that is the subject of the thumbprint.
99+
*
100+
* For instance by using the base64url-encoded JWK Thumbprint value as a key ID (or "kid") value.
101+
*
102+
* @see https://www.rfc-editor.org/rfc/rfc7638
103+
*
104+
* The thumbprint of a JWK is created by:
105+
*
106+
* 1. Constructing a JSON string (without whitespaces) with the required keys in alphabetical order.
107+
* 2. Hashing the JSON string using SHA-256 (or another hash function)
108+
*
109+
* @param string $jwk The JWK key to thumbprint
110+
* @return string the thumbprint
111+
* @throws InvalidTokenException
112+
*/
113+
public function makeJwkThumbprint($jwk) {
114+
if (!$jwk || !isset($jwk['kty'])) {
115+
throw new InvalidTokenException('JWK has no "kty" key type');
116+
}
117+
// https://www.rfc-editor.org/rfc/rfc7517.html#section-4.1
118+
// and https://www.rfc-editor.org/rfc/rfc7518.html#section-6.1
119+
if (!in_array($jwk['kty'], ['RSA','EC'])) {
120+
throw new InvalidTokenException('JWK "kty" key type value must be one of "RSA" or "EC", got "'.$jwk['kty'].'" instead.');
121+
}
122+
if ($jwk['kty']=='RSA') { // used with RS256 alg
123+
if (!isset($jwk['e'], $jwk['n'])) {
124+
throw new InvalidTokenException('JWK values do not match "RSA" key type');
125+
}
126+
$json = vsprintf('{"e":"%s","kty":"%s","n":"%s"}', [
127+
$jwk['e'],
128+
$jwk['kty'],
129+
$jwk['n'],
130+
]);
131+
} else { // EC used with ES256 alg
132+
if (!isset($jwk['crv'], $jwk['x'], $jwk['y'])) {
133+
throw new InvalidTokenException('JWK values doe not match "EC" key type');
134+
}
135+
//crv, kty, x, y
136+
$json = vsprintf('{"crv":"%s","kty":"%s","x":"%s","y":"%s"}', [
137+
$jwk['crv'],
138+
$jwk['kty'],
139+
$jwk['x'],
140+
$jwk['y']
141+
]);
142+
}
143+
$hash = hash('sha256', $json);
144+
$encoded = Base64Url::encode($hash);
145+
return $encoded;
146+
}
147+
148+
/**
149+
* https://datatracker.ietf.org/doc/html/draft-ietf-oauth-dpop#section-4.2
150+
* When the DPoP proof is used in conjunction with the presentation of
151+
* an access token in protected resource access, see Section 7, the DPoP
152+
* proof MUST also contain the following claim:
153+
* ath: hash of the access token. The value MUST be the result of a
154+
* base64url encoding (as defined in Section 2 of [RFC7515]) the
155+
* SHA-256 [SHS] hash of the ASCII encoding of the associated access
156+
* token's value.
157+
* See also: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-dpop#section-7
158+
*
159+
* Validates the above part of the oauth dpop specification
160+
* @param string $jwt JWT access token, raw
161+
* @param string $dpop DPoP token, raw
162+
* @param ServerRequestInterface $request Server Request
163+
* @return bool true, if the dpop token "ath" claim matches the access token
164+
*/
165+
public function validateJwtDpop($jwt, $dpop, $request) {
166+
$this->validateDpop($dpop, $request);
105167
$jwtConfig = Configuration::forUnsecuredSigner();
106-
$jwt = $jwtConfig->parser()->parse($jwt);
168+
$dpopJWT = $jwtConfig->parser()->parse($dpop);
169+
170+
$ath = $dpopJWT->claims()->get('ath');
171+
if ($ath === null) {
172+
throw new InvalidTokenException('DPoP "ath" claim is missing');
173+
}
174+
175+
$hash = hash('sha256', $jwt);
176+
$encoded = Base64Url::encode($hash);
177+
return ($ath === $encoded);
178+
}
179+
180+
/**
181+
* https://solidproject.org/TR/oidc#tokens-id
182+
* validates that the provided OIDC ID Token matches the DPoP header
183+
* @param string $token The OIDS ID Token (raw)
184+
* @param string $dpop The DPoP Token (raw)
185+
* @param ServerRequestInterface $request Server Request
186+
* @return bool True if the id token jkt matches the dpop token jwk
187+
* @throws InvalidTokenException when the tokens do not match
188+
*/
189+
public function validateIdTokenDpop($token, $dpop, $request) {
190+
$this->validateDpop($dpop, $request);
191+
$jwtConfig = Configuration::forUnsecuredSigner();
192+
$jwt = $jwtConfig->parser()->parse($token);
107193
$cnf = $jwt->claims()->get("cnf");
108194

109195
if ($cnf === null) {
110196
throw new InvalidTokenException('JWT Confirmation claim (cnf) is missing');
111197
}
112198

113-
if (isset($cnf['jkt']) === false) {
199+
if (!isset($cnf['jkt'])) {
114200
throw new InvalidTokenException('JWT Confirmation claim (cnf) is missing Thumbprint (jkt)');
115201
}
116202

117-
if ($cnf['jkt'] !== $dpopKey) {
118-
throw new InvalidTokenException('JWT Confirmation claim (cnf) provided Thumbprint (jkt) does not match Key ID from JWK header');
203+
$jkt = $cnf['jkt'];
204+
205+
$dpopJwt = $jwtConfig->parser()->parse($dpop);
206+
$jwk = $dpopJwt->headers()->get('jwk');
207+
208+
$jwkThumbprint = $this->makeJwkThumbprint($jwk);
209+
if ($jwkThumbprint !== $jkt) {
210+
throw new InvalidTokenException('ID Token JWK Thumbprint (jkt) does not match the JWK from DPoP header');
119211
}
120212

121-
//@FIXME: add check for "ath" claim in DPoP token, per https://datatracker.ietf.org/doc/html/draft-ietf-oauth-dpop#section-7
122-
return false;
213+
return true;
123214
}
124215

125216
/**
@@ -129,9 +220,10 @@ private function validateJwtDpop($jwt, $dpopKey) {
129220
* @param string $dpop The DPOP token
130221
* @param ServerRequestInterface $request Server Request
131222
*
132-
* @return bool True if the DPOP token is valid, false otherwise
223+
* @return bool True if the DPOP token is valid
133224
*
134225
* @throws RequiredConstraintsViolated
226+
* @throws InvalidTokenException
135227
*/
136228
public function validateDpop($dpop, $request) {
137229
/*
@@ -161,7 +253,11 @@ public function validateDpop($dpop, $request) {
161253
*/
162254
// 1. the string value is a well-formed JWT,
163255
$jwtConfig = Configuration::forUnsecuredSigner();
164-
$dpop = $jwtConfig->parser()->parse($dpop);
256+
try {
257+
$dpop = $jwtConfig->parser()->parse($dpop);
258+
} catch(\Exception $e) {
259+
throw new InvalidTokenException('Invalid DPoP token', 400, $e);
260+
}
165261

166262
// 2. all required claims are contained in the JWT,
167263
$htm = $dpop->claims()->get("htm"); // http method
@@ -251,9 +347,6 @@ public function validateDpop($dpop, $request) {
251347
throw new InvalidTokenException("jti is invalid");
252348
}
253349

254-
// 10. that, if used with an access token, it also contains the 'ath' claim, with a hash of the access token
255-
// TODO: implement
256-
257350
return true;
258351
}
259352

0 commit comments

Comments
 (0)