@@ -87,6 +87,10 @@ async function handleRegister(req: Request, env: Env): Promise<Response> {
8787 return jsonResponse ( { error : "invalid_challenge" } , 400 ) ;
8888 }
8989
90+ if ( ! ( await verifyPkceChallenge ( code_verifier , code_challenge ) ) ) {
91+ return jsonResponse ( { error : "challenge_mismatch" } , 400 ) ;
92+ }
93+
9094 const key = `${ KV_PREFIX } ${ state } ` ;
9195 const existing = await env . OAUTH_STATE . get ( key ) ;
9296 if ( existing !== null ) {
@@ -252,6 +256,32 @@ function methodNotAllowed(allowed: string): Response {
252256 } ) ;
253257}
254258
259+ async function verifyPkceChallenge ( verifier : string , challenge : string ) : Promise < boolean > {
260+ // RFC 7636 §4.6: code_challenge MUST equal BASE64URL-ENCODE(SHA256(ASCII(code_verifier))).
261+ // VERIFIER_RE restricts verifier to base64url alphabet, so its UTF-8 encoding
262+ // is byte-identical to ASCII — TextEncoder is safe here.
263+ const digest = await crypto . subtle . digest ( "SHA-256" , new TextEncoder ( ) . encode ( verifier ) ) ;
264+ const expected = base64UrlEncode ( new Uint8Array ( digest ) ) ;
265+ return constantTimeEqual ( expected , challenge ) ;
266+ }
267+
268+ function base64UrlEncode ( bytes : Uint8Array ) : string {
269+ let binary = "" ;
270+ for ( let i = 0 ; i < bytes . length ; i ++ ) {
271+ binary += String . fromCharCode ( bytes [ i ] ! ) ;
272+ }
273+ return btoa ( binary ) . replace ( / \+ / g, "-" ) . replace ( / \/ / g, "_" ) . replace ( / = + $ / , "" ) ;
274+ }
275+
276+ function constantTimeEqual ( a : string , b : string ) : boolean {
277+ if ( a . length !== b . length ) return false ;
278+ let diff = 0 ;
279+ for ( let i = 0 ; i < a . length ; i ++ ) {
280+ diff |= a . charCodeAt ( i ) ^ b . charCodeAt ( i ) ;
281+ }
282+ return diff === 0 ;
283+ }
284+
255285function sanitizeReason ( raw : string ) : string {
256286 return REASON_RE . test ( raw ) ? raw : "github_error" ;
257287}
0 commit comments