Skip to content

Commit bb725c4

Browse files
committed
added tests for validateDPOP, fixed some validations
left 2 checks to be implemented (and tested)
1 parent 6517a1b commit bb725c4

3 files changed

Lines changed: 192 additions & 30 deletions

File tree

src/Utils/DPop.php

Lines changed: 34 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use Lcobucci\JWT\Signer\Key\InMemory;
1010
use Lcobucci\JWT\Signer\Rsa\Sha256;
1111
use Lcobucci\JWT\Validation\Constraint\LooseValidAt;
12+
use Lcobucci\JWT\Validation\Constraint\SignedWith;
1213

1314
use Jose\Component\Core\JWK;
1415
use Jose\Component\Core\Util\ECKey;
@@ -68,7 +69,15 @@ private function validateJwtDpop($jwt, $dpopKey) {
6869
return false;
6970
}
7071

71-
private function validateDpop($dpop, $request) {
72+
/**
73+
* Validates that the DPOP token matches all requirements from
74+
* https://datatracker.ietf.org/doc/html/draft-ietf-oauth-dpop#section-4.2
75+
* @param string $dpop The DPOP token
76+
* @param Psr\Http\Message\ServerRequestInterface $request Server Request
77+
* @return bool True if the DPOP token is valid, false otherwise
78+
* @throws Lcobucci\JWT\Validation\RequiredConstraintsViolated
79+
*/
80+
public function validateDpop($dpop, $request) {
7281
/*
7382
4.2. Checking DPoP Proofs
7483
To check if a string that was received as part of an HTTP Request is
@@ -91,34 +100,43 @@ private function validateDpop($dpop, $request) {
91100
9. that, within a reasonable consideration of accuracy and resource
92101
utilization, a JWT with the same "jti" value has not been
93102
received previously (see Section 9.1).
103+
10. that, if used with an access token, it also contains the 'ath'
104+
claim, with a hash of the access token
94105
*/
95-
//error_log("1");
96106
// 1. the string value is a well-formed JWT,
97107
$jwtConfig = $configuration = Configuration::forUnsecuredSigner();
98108
$dpop = $jwtConfig->parser()->parse($dpop);
99109

100-
//error_log("2");
101110
// 2. all required claims are contained in the JWT,
102111
$htm = $dpop->claims()->get("htm"); // http method
112+
if (!$htm) {
113+
throw new \Exception("missing htm");
114+
}
103115
$htu = $dpop->claims()->get("htu"); // http uri
116+
if (!$htu) {
117+
throw new \Exception("missing htu");
118+
}
104119
$typ = $dpop->headers()->get("typ");
120+
if (!$typ) {
121+
throw new \Exception("missing typ");
122+
}
105123
$alg = $dpop->headers()->get("alg");
124+
if (!$alg) {
125+
throw new \Exception("missing alg");
126+
}
106127

107-
//error_log("3");
108128
// 3. the "typ" field in the header has the value "dpop+jwt",
109129
if ($typ != "dpop+jwt") {
110130
throw new \Exception("typ is not dpop+jwt");
111131
}
112132

113-
//error_log("4");
114133
// 4. the algorithm in the header of the JWT indicates an asymmetric
115134
// digital signature algorithm, is not "none", is supported by the
116135
// application, and is deemed secure,
117136
if ($alg == "none") {
118137
throw new \Exception("alg is none");
119138
}
120139

121-
//error_log("5");
122140
// 5. that the JWT is signed using the public key contained in the
123141
// "jwk" header of the JWT,
124142
$jwk = $dpop->headers()->get("jwk");
@@ -130,59 +148,48 @@ private function validateDpop($dpop, $request) {
130148
break;
131149
case "ES256":
132150
$pem = \Jose\Component\Core\Util\ECKey::convertToPEM($webTokenJwk);
133-
$signer = \Lcobucci\JWT\Signer\Ecdsa\Sha256::create();
151+
$signer = \Lcobucci\JWT\Signer\Ecdsa\Sha256::create();
134152
break;
135153
default:
136154
throw new \Exception("unsupported algorithm");
137155
break;
138156
}
139157
$key = InMemory::plainText($pem);
140-
$jwtConfig = Configuration::forSymmetricSigner($signer, InMemory::plainText($pem));
141-
142-
// FIXME: Add constraints;
143-
// $constraint = new LooseValidAt($clock, $leeway); // It will use the current time to validate (iat, nbf and exp)
144-
// $jwtConfig->setValidationConstraints($constraint);
145-
// if (!$jwtConfig->validator()->validate($dpop, ...$jwtConfig->validationConstraints())) {
146-
// throw new \Exception("invalid signature");
147-
// }
158+
$validationConstraints = [];
159+
$validationConstraints[] = new SignedWith($signer, $key);
148160

149-
//error_log("6");
150161
// 6. the "htm" claim matches the HTTP method value of the HTTP request
151162
// in which the JWT was received (case-insensitive),
152163
if (strtolower($htm) != strtolower($request->getMethod())) {
153164
throw new \Exception("htm http method is invalid");
154165
}
155166

156-
//error_log("7");
157167
// 7. the "htu" claims matches the HTTP URI value for the HTTP request
158168
// in which the JWT was received, ignoring any query and fragment
159169
// parts,
160170
$requestedPath = (string)$request->getUri();
161171
$requestedPath = preg_replace("/[?#].*$/", "", $requestedPath);
162-
// FIXME: Remove this; it was disabled for testing with a server running on 443 internally but accessible on :444
163-
$htu = str_replace(":444", "", $htu);
164-
$requestedPath = str_replace(":444", "", $requestedPath);
165172

166173
//error_log("REQUESTED HTU $htu");
167174
//error_log("REQUESTED PATH $requestedPath");
168175
if ($htu != $requestedPath) {
169176
throw new \Exception("htu does not match requested path");
170177
}
171178

172-
//error_log("8");
173179
// 8. the token was issued within an acceptable timeframe (see Section 9.1), and
174180

175181
$leeway = new \DateInterval("PT60S"); // allow 60 seconds clock skew
176182
$clock = SystemClock::fromUTC();
177-
$constraint = new LooseValidAt($clock, $leeway); // It will use the current time to validate (iat, nbf and exp)
178-
$jwtConfig->setValidationConstraints($constraint);
179-
if (!$jwtConfig->validator()->validate($dpop, ...$jwtConfig->validationConstraints())) {
180-
throw new \Exception("token timing is invalid");
183+
$validationsConstraints[] = new LooseValidAt($clock, $leeway); // It will use the current time to validate (iat, nbf and exp)
184+
if (!$jwtConfig->validator()->validate($dpop, ...$validationConstraints)) {
185+
$jwtConfig->validator()->assert($dpop, ...$validationConstraints); // throws an explanatory exception
181186
}
182187

183188
// 9. that, within a reasonable consideration of accuracy and resource utilization, a JWT with the same "jti" value has not been received previously (see Section 9.1).
184-
// FIXME: Check if we know the jti;
185-
//error_log("9");
189+
// TODO: Check if we know the jti;
190+
191+
// 10. that, if used with an access token, it also contains the 'ath' claim, with a hash of the access token
192+
// TODO: implement
186193

187194
return true;
188195
}

src/Utils/Jwks.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,13 @@ final public function jsonSerialize()
3939
*
4040
* @return array
4141
*/
42-
private function createKey(string $certificate, $subject) : array
42+
private function createKey(string $certificate, $subject, $exponent) : array
4343
{
4444
return [
4545
JwkParameter::ALGORITHM => 'RS256',
4646
JwkParameter::KEY_ID => md5($certificate),
4747
JwkParameter::KEY_TYPE => 'RSA',
48-
RsaParameter::PUBLIC_EXPONENT => 'AQAB', // Hard-coded as `Base64Url::encode($keyInfo['rsa']['e'])` tends to be empty...
48+
RsaParameter::PUBLIC_EXPONENT => Base64Url::encode($exponent),
4949
RsaParameter::PUBLIC_MODULUS => Base64Url::encode($subject),
5050
JwkParameter::KEY_OPERATIONS => array("verify"),
5151
];
@@ -70,7 +70,7 @@ private function create() : array
7070
$key = openssl_pkey_get_public($certificate);
7171
$keyInfo = openssl_pkey_get_details($key);
7272

73-
$jwks['keys'][] = $this->createKey($certificate, $keyInfo['rsa']['n']);
73+
$jwks['keys'][] = $this->createKey($certificate, $keyInfo['rsa']['n'], $keyInfo['rsa']['e']);
7474
});
7575

7676
return $jwks;

tests/unit/Utils/DPOPTest.php

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
<?php
2+
3+
namespace Pdsinterop\Solid\Auth\Utils;
4+
5+
use PHPUnit\Framework\TestCase;
6+
use Lcobucci\JWT\Validation\RequiredConstraintsViolated;
7+
8+
/**
9+
* @covers DPop
10+
*/
11+
class DPOPTest extends TestCase
12+
{
13+
14+
private $dpop;
15+
private $url;
16+
private $serverRequest;
17+
18+
protected function sign($dpop, $privateKey=null)
19+
{
20+
$keyPath = dirname(__DIR__) . '/../fixtures/keys';
21+
if (!$privateKey) {
22+
$privateKey = file_get_contents($keyPath . '/private.key');
23+
}
24+
25+
$signature = '';
26+
$success = \openssl_sign(
27+
Base64Url::encode(json_encode($dpop['header'])).'.'.
28+
Base64Url::encode(json_encode($dpop['payload'])),
29+
$signature,
30+
$privateKey,
31+
OPENSSL_ALGO_SHA256
32+
);
33+
34+
if (!$success) {
35+
throw new \Exception('unable to sign dpop');
36+
}
37+
$token = Base64Url::encode(json_encode($dpop['header'])).'.'.
38+
Base64Url::encode(json_encode($dpop['payload'])).'.'.
39+
Base64Url::encode($signature);
40+
41+
return array_merge($dpop, [
42+
'signature' => $signature,
43+
'token' => $token
44+
]);
45+
}
46+
47+
protected function setUp(): void
48+
{
49+
$keyPath = dirname(__DIR__) . '/../fixtures/keys';
50+
$privateKey = file_get_contents($keyPath . '/private.key');
51+
$publicKey = file_get_contents($keyPath . '/public.key');
52+
53+
$keyInfo = \openssl_pkey_get_details(\openssl_pkey_get_public($publicKey));
54+
$jwk = [
55+
'kty' => 'RSA',
56+
'n' => Base64Url::encode($keyInfo['rsa']['n']),
57+
'e' => Base64Url::encode($keyInfo['rsa']['e'])
58+
];
59+
60+
$header = [
61+
'typ' => 'dpop+jwt',
62+
'alg' => 'RS256',
63+
'jwk' => $jwk
64+
];
65+
66+
$payload = [
67+
'iss' => 'example.com',
68+
'aud' => 'example.com',
69+
'htm' => 'GET',
70+
'htu' => 'https://www.example.com',
71+
'iat' => time(),
72+
'nbf' => time(),
73+
'exp' => time()+3600
74+
];
75+
76+
$this->dpop = $this->sign([
77+
'header' => $header,
78+
'payload' => $payload
79+
]);
80+
81+
$this->url = 'https://www.example.com';
82+
$this->serverRequest = new \Laminas\Diactoros\ServerRequest(array(),array(), $this->url);
83+
84+
}
85+
86+
private function getWrongKey() {
87+
$key = <<<EOF
88+
-----BEGIN PUBLIC KEY-----
89+
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAiGzr51tpH6F2HwMUSKCX
90+
zxyYfZyNJpKZWzNb3AMrNZTGlKDHHuVCkxNmHV0yFIxr3flRNmvxebLxsuYPAmCF
91+
ccV1r+1Pry244MHOIq3aq5mIRq+smVSPk350WpyO4jn8mOLOiH+CYe9LXmJSPBvO
92+
zZHwjEp+VmIGp5oDUZc5nnrf/UkQcj6jvKj0TanD8vGpDg9w3WbkQHWbFAMGPQdc
93+
YF5CZ68QPKPS86/aOdcnyoliSyIMn9BhrSXS8+Q3fCZHsYgejUjD7e0sx/+gBCrW
94+
MOuzbyD29mgbqETiSCZS1YLxgPnA34NRRKY06G0fMusXSGsXC+y7EU8JjTvTs4/L
95+
PwIDAQAB
96+
-----END PUBLIC KEY-----
97+
EOF;
98+
// Get public key
99+
$pubkey=\openssl_pkey_get_details(\openssl_pkey_get_public($key));
100+
return $pubkey;
101+
}
102+
103+
public function testWrongTyp(): void
104+
{
105+
$this->dpop['header']['typ'] = 'jwt';
106+
$token = $this->sign($this->dpop);
107+
108+
$dpop = new DPop();
109+
$this->expectException(\Exception::class);
110+
$this->expectExceptionMessage('typ is not dpop+jwt');
111+
112+
$result = $dpop->validateDpop($token['token'], $this->serverRequest);
113+
}
114+
115+
public function testAlgNone(): void
116+
{
117+
$this->dpop['header']['alg'] = 'none';
118+
$token = $this->sign($this->dpop);
119+
120+
$dpop = new DPop();
121+
$this->expectException(\Exception::class);
122+
$this->expectExceptionMessage('alg is none');
123+
$result = $dpop->validateDpop($token['token'], $this->serverRequest);
124+
}
125+
126+
public function testWrongKey(): void
127+
{
128+
$theWrongKey = $this->getWrongKey();
129+
$this->dpop['header']['jwk'] = [
130+
'kty' => 'RSA',
131+
'n' => Base64Url::encode($theWrongKey['rsa']['n']),
132+
'e' => Base64Url::encode($theWrongKey['rsa']['e'])
133+
];
134+
$token = $this->sign($this->dpop);
135+
136+
$dpop = new DPop();
137+
try {
138+
$result = $dpop->validateDpop($token['token'], $this->serverRequest);
139+
} catch(RequiredConstraintsViolated $e) {
140+
$result = false;
141+
$this->assertSame($e->violations()[0]->getMessage(),'Token signature mismatch');
142+
}
143+
$this->assertFalse($result);
144+
}
145+
146+
public function testCorrectToken(): void
147+
{
148+
$token = $this->sign($this->dpop);
149+
150+
$dpop = new DPop();
151+
$result = $dpop->validateDpop($token['token'], $this->serverRequest);
152+
$this->assertTrue($result);
153+
}
154+
155+
}

0 commit comments

Comments
 (0)