@@ -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