Skip to content

Commit 6902e3a

Browse files
committed
split dpop validation in jwt-dpop validation and idtoken-dpop validation, as they require different things
added the 'ath' check in the validateJwtDpop method moved the 'jkt' check to the validateIdTokenDpop method fixed the tests to use a correct jwt access token (instead of just putting the dpop token there as well)
1 parent 8350a28 commit 6902e3a

File tree

2 files changed

+191
-101
lines changed

2 files changed

+191
-101
lines changed

src/Utils/DPop.php

Lines changed: 121 additions & 88 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,109 +70,146 @@ 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) {
90-
$kid = '';
91-
9281
$this->validateDpop($dpop, $request);
9382

94-
// 1. the string value is a well-formed JWT,
9583
$jwtConfig = Configuration::forUnsecuredSigner();
9684
$dpop = $jwtConfig->parser()->parse($dpop);
9785
$jwk = $dpop->headers()->get("jwk");
9886

99-
if (isset($jwk['kid'])) {
100-
$kid = $jwk['kid'];
87+
if (isset($jwk['kid']) === false) {
88+
throw new InvalidTokenException('Key ID is missing from JWK header');
89+
}
90+
91+
return $jwk['kid'];
92+
}
93+
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');
101116
}
117+
if (!in_array($jwk['kty'], ['RSA','EC'])) {
118+
throw new InvalidTokenException('JWK "kty" key type value must be one of "RSA" or "EC", got "'.$jwk['kty'].'" instead.');
119+
}
120+
if ($jwk['kty']=='RSA') {
121+
if (!isset($jwk['e']) || !isset($jwk['n'])) {
122+
throw new InvalidTokenException('JWK values do not match "RSA" key type');
123+
}
124+
$json = vsprintf('{"e":"%s","kty":"%s","n":"%s"}', [
125+
$jwk['e'],
126+
$jwk['kty'],
127+
$jwk['n'],
128+
]);
102129

103-
return $kid;
130+
} else { //EC
131+
if (!isset($jwk['crv']) || !isset($jwk['x']) || !isset($jwk['y'])) {
132+
throw new InvalidTokenException('JWK values doe not match "EC" key type');
133+
}
134+
//crv, kty, x, y
135+
$json = vsprintf('{"crv":"%s","kty":"%s","x":"%s","y":"%s"}', [
136+
$jwk['crv'],
137+
$jwk['kty'],
138+
$jwk['x'],
139+
$jwk['y']
140+
]);
141+
}
142+
$hash = hash('sha256', $json);
143+
$encoded = Base64Url::encode($hash);
144+
return $encoded;
104145
}
105146

106-
private function validateJwtDpop($jwt, $dpopKey) {
147+
/**
148+
* https://datatracker.ietf.org/doc/html/draft-ietf-oauth-dpop#section-4.2
149+
* When the DPoP proof is used in conjunction with the presentation of
150+
* an access token in protected resource access, see Section 7, the DPoP
151+
* proof MUST also contain the following claim:
152+
* ath: hash of the access token. The value MUST be the result of a
153+
* base64url encoding (as defined in Section 2 of [RFC7515]) the
154+
* SHA-256 [SHS] hash of the ASCII encoding of the associated access
155+
* token's value.
156+
* See also: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-dpop#section-7
157+
*
158+
* Validates the above part of the oauth dpop specification
159+
* @param string $jwt JWT access token, raw
160+
* @param string $dpop DPoP token, raw
161+
* @param ServerRequestInterface $request Server Request
162+
* @return bool true, if the dpop token "ath" claim matches the access token
163+
*/
164+
public function validateJwtDpop($jwt, $dpop, $request) {
165+
$this->validateDpop($dpop, $request);
166+
$jwtConfig = Configuration::forUnsecuredSigner();
167+
$dpopJWT = $jwtConfig->parser()->parse($dpop);
168+
169+
$ath = $dpopJWT->claims()->get('ath');
170+
if ($ath === null) {
171+
throw new InvalidTokenException('DPoP "ath" claim is missing');
172+
}
173+
174+
$hash = hash('sha256', $jwt);
175+
$encoded = Base64Url::encode($hash);
176+
return ($ath === $encoded);
177+
}
178+
179+
/**
180+
* https://solidproject.org/TR/oidc#tokens-id
181+
* validates that the provided OIDC ID Token matches the DPoP header
182+
* @param string $token The OIDS ID Token (raw)
183+
* @param string $dpop The DPoP Token (raw)
184+
* @param ServerRequestInterface $request Server Request
185+
* @return bool True if the id token jkt matches the dpop token jwk
186+
* @throws InvalidTokenException when the tokens do not match
187+
*/
188+
public function validateIdTokenDpop($token, $dpop, $request) {
189+
$this->validateDpop($dpop, $request);
107190
$jwtConfig = Configuration::forUnsecuredSigner();
108-
$jwt = $jwtConfig->parser()->parse($jwt);
191+
$jwt = $jwtConfig->parser()->parse($token);
109192
$cnf = $jwt->claims()->get("cnf");
110193

111194
if ($cnf === null) {
112195
throw new InvalidTokenException('JWT Confirmation claim (cnf) is missing');
113196
}
114197

115-
if (isset($cnf['jkt']) === false) {
198+
if (!isset($cnf['jkt'])) {
116199
throw new InvalidTokenException('JWT Confirmation claim (cnf) is missing Thumbprint (jkt)');
117200
}
118201

119-
// !!! HIER GEBLEVEN !!!
120-
121-
// @CHECKME: If we are checking against the JKT this "if" can be removed
122-
if ($dpopKey !== '') {
123-
$keyTypes = [
124-
/*ES256*/ 'EC' => ['crv', 'kty', 'x', 'y'],
125-
/*RS256*/ 'RSA' => ['e', 'kty', 'n'],
126-
];
127-
128-
$jwk = $jwt->headers()->get('jwk');
129-
130-
$keyType = $jwk['kty'];
131-
132-
if (array_key_exists($keyType, $keyTypes) === false) {
133-
$message = vsprintf('Unsupported key type "%s". Must be one of: %s', [
134-
$keyType,
135-
implode(', ', array_keys($keyTypes)),
136-
]);
137-
throw new InvalidTokenException($message);
138-
}
202+
$jkt = $cnf['jkt'];
139203

140-
$required = $keyTypes[$keyType];
141-
$missing = array_diff($required, array_keys($jwk));
204+
$dpopJwt = $jwtConfig->parser()->parse($dpop);
205+
$jwk = $dpopJwt->headers()->get('jwk');
142206

143-
if ($missing !== []) {
144-
throw new InvalidTokenException('Required JWK values have not been set: ' . implode(', ', $missing));
145-
}
146-
147-
/**
148-
* RFC7638 defines a method for computing the hash value (or "digest") of a JSON Web Key (JWK).
149-
*
150-
* The resulting hash value can be used for identifying the key represented by the JWK
151-
* that is the subject of the thumbprint.
152-
*
153-
* For instance by using the base64url-encoded JWK Thumbprint value as a key ID (or "kid") value.
154-
*
155-
* @see https://www.rfc-editor.org/rfc/rfc7638
156-
*
157-
* The thumbprint of a JWK is created by:
158-
*
159-
* 1. Constructing a JSON string (without whitespaces) with the required keys in alphabetical order.
160-
* 2. Hashing the JSON string using SHA-256 (or another hash function)
161-
*
162-
*/
163-
// @FIXME: Add logic to build correct JSON string
164-
$json = vsprintf('{"e":"%s","kty":"%s","n":"%s"}', [
165-
$jwk['e'],
166-
$jwk['kty'],
167-
$jwk['n'],
168-
]);
169-
170-
$hash = hash('sha256', $json);
171-
$encoded = Base64Url::encode($hash);
172-
173-
// @FIXME: What are we comparing against? JKT? KID / $dpopKey?
174-
if ($encoded !== $dpopKey /* or $cnf['jkt'] ?*/) {
175-
// @CHECKME: What error message belongs here?
176-
throw new InvalidTokenException('JWT Confirmation claim (cnf) provided Thumbprint (jkt) does not match Key ID from JWK header');
177-
}
207+
$jwkThumbprint = $this->makeJwkThumbprint($jwk);
208+
if ($jwkThumbprint !== $jkt) {
209+
throw new InvalidTokenException('ID Token JWK Thumbprint (jkt) does not match the JWK from DPoP header');
178210
}
179211

180-
//@FIXME: add check for "ath" claim in DPoP token, per https://datatracker.ietf.org/doc/html/draft-ietf-oauth-dpop#section-7
181-
return false;
212+
return true;
182213
}
183214

184215
/**
@@ -191,6 +222,7 @@ private function validateJwtDpop($jwt, $dpopKey) {
191222
* @return bool True if the DPOP token is valid, false otherwise
192223
*
193224
* @throws RequiredConstraintsViolated
225+
* @throws InvalidTokenException
194226
*/
195227
public function validateDpop($dpop, $request) {
196228
/*
@@ -220,7 +252,11 @@ public function validateDpop($dpop, $request) {
220252
*/
221253
// 1. the string value is a well-formed JWT,
222254
$jwtConfig = Configuration::forUnsecuredSigner();
223-
$dpop = $jwtConfig->parser()->parse($dpop);
255+
try {
256+
$dpop = $jwtConfig->parser()->parse($dpop);
257+
} catch(\Exception $e) {
258+
throw new InvalidTokenException('Invalid DPoP token', 400, $e);
259+
}
224260

225261
// 2. all required claims are contained in the JWT,
226262
$htm = $dpop->claims()->get("htm"); // http method
@@ -310,9 +346,6 @@ public function validateDpop($dpop, $request) {
310346
throw new InvalidTokenException("jti is invalid");
311347
}
312348

313-
// 10. that, if used with an access token, it also contains the 'ath' claim, with a hash of the access token
314-
// TODO: implement
315-
316349
return true;
317350
}
318351

0 commit comments

Comments
 (0)