Repository navigation
fix(oauth2): validate PKCE verifier and challenge syntax - #92
Merged
Merged
Conversation
PKCE checked the transformation but never the RFC 7636 syntax: pkce.Verify hashed whatever it was given, and /authorize checked the presence and method of a code_challenge but not its shape. A client could pair a one-character code_verifier with its S256 challenge and have the code exchanged for tokens under every profile, and /authorize stored challenges that no S256 transformation can produce. The pkce package now owns the syntax. ValidVerifier encodes RFC 7636 §4.1: 43 to 128 bytes, each one unreserved, so whitespace and every non-ASCII byte are refused. ValidChallenge encodes §4.2: a plain challenge is a verifier, and an S256 challenge must survive a round trip through the unpadded base64url encoding of a SHA-256 digest. Decoding alone is not enough: encoding/base64 skips CR and LF even in strict mode, and lax decoding ignores non-zero trailing bits. Verify and VerifyS256 refuse a malformed verifier before the constant-time comparison. /authorize still checks presence first, then the method against the profile (an absent method means plain, §4.3), and only then the syntax, so a request breaking several rules keeps its current error; a malformed challenge is redirected as invalid_request before the ConsentFunc runs. /token answers invalid_grant "malformed code_verifier" after the profile checks: §4.6 maps a failed proof to invalid_grant, and the code, consumed before PKCE runs, stays burned. The PKCE error descriptions no longer carry the double quote RFC 6749 §4.1.2.1 and §5.2 forbid, nor echo an unsupported method. Tests that relied on non-compliant PKCE values now use well-formed ones, and the security considerations and Profile docs no longer misstate which profiles accept plain. Refs #79.
Coverage Report for CI Build 38015400055Coverage increased (+0.1%) to 94.444%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
Coverage Report for CI Build 38015644582Coverage increased (+0.1%) to 94.444%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
TestAuthorizationCodeMissingVerifier only asserted invalid_grant, so moving the missing-verifier check after the RFC 7636 syntax check went unnoticed: an absent code_verifier then reads "malformed code_verifier" instead of "missing code_verifier", with the same error code. Mutation testing of the PKCE syntax fix left that mutant alive. The test now pins the description, so the presence check keeps answering before the syntax check. Refs #79.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #79.
Problem
pkce.Verifychecked the transformation and nothing else: it hashed whatevercode_verifierit was given, although RFC 7636 §4.1 defines the verifier as43*128unreserved. Reproduced end to end through the real handlers under thedefault
Profile20BCP:/authorizewithcode_challenge=LXEWQrcmsEQBYnyp-6wy9chTD7GQPMTbAiWHF5IaSIE(S256 ofx),then
/tokenwithcode_verifier=x→ 200 with tokens. It does not bypassa properly generated challenge — a SHA-256 preimage is still needed — but it
let non-compliant, low-entropy clients through, and
pkce.Verify/VerifyS256gave library users the same lax answer. UnderProfile20, ashort plain pair (verifier = challenge, 19 characters) was exchanged too.
/authorize(validateAuthorizePKCE) checked that acode_challengewaspresent and its method allowed by the profile, never the challenge's shape.
RFC 7636 §4.2 defines
code_challenge = 43*128unreserved, and for S256BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))— exactly 43 unpaddedbase64url characters (Appendix A).
abc, a padded or standard-alphabet value,CR/LF, or values up to the request-size limits (≈1 MB in a GET, 10 MB in a
POST form) were handed to the
ConsentFuncand stored verbatim. An S256 codebound to such a challenge can never be redeemed: the client only found out at
/token, withPKCE verification failed.Two traps make "just decode it" the wrong S256 check:
encoding/base64skips\rand\n, even inStrict()mode. A43-byte challenge with an LF in place of one character decodes without error
(to 31 bytes); a trailing LF (44 bytes) decodes to 32 bytes even with
Strict().4 bits of the digest and 2 zero bits, so only 16 final characters
(
AEIMQUYcgkosw048) are canonical:E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cNdecodes to the same digest asthe RFC 7636 Appendix B challenge ending in
-cM.Finally, three PKCE
error_descriptions broke the RFC 6749 §4.1.2.1 / §5.2charset (
%x20-21 / %x23-5B / %x5D-7E):code_challenge_method "plain" is refused…andPKCE method "plain" is refused…carry a", andunsupported code_challenge_method %qechoed the attacker-controlled method,quotes and backslashes included.
Fix
pkceowns the syntax. Two new functions, returningboollike the restof the package:
ValidVerifier: 43 to 128 bytes, every byte unreserved(
A-Z a-z 0-9 - . _ ~). Bytes, not runes, are inspected, so whitespace,control characters and every non-ASCII byte are refused.
ValidChallenge: a plain challenge is a verifier (§4.2), soValidChallenge(plain, c) == ValidVerifier(c). An S256 challenge must beexactly what BASE64URL-ENCODE yields for a SHA-256 digest: its length must
be
RawURLEncoding.EncodedLen(sha256.Size)(43, which also bounds thework), then it must decode and re-encode to itself:
The round trip refuses padding, the standard alphabet,
.and~,non-ASCII, CR/LF and non-canonical trailing bits with a single rule. Any
other method — including
""(callers resolve the §4.3 default first) ands256(methods are case-sensitive) — is never valid.Verifyrefuses a malformed verifier before the constant-time comparison,and
VerifyS256delegates to it, so a malformed stored challenge nevermatches either.
Challengeis unchanged; its godoc now says it does notvalidate.
/authorize—validateAuthorizePKCEkeeps its policy checks first andadds the syntax last, so a request breaking several rules keeps its current
error:
code_challenge is requiredunder BCP / 2.1);plain;plainrefused under BCP / 2.1, unknownmethods refused);
pkce.ValidChallenge→malformed S256 code_challenge/malformed plain code_challenge.Errors still travel as a redirected
invalid_requestwithstate(RFC 6749§4.1.2.1; RFC 7636 §4.4.1 uses
invalid_requesttoo), before theConsentFuncruns; nothing is stored.AuthorizeRequest.CodeChallengeMethodstill carries the method as sent.
/token—verifyPKCEkeeps its order and inserts one check: PKCErequired → missing verifier → default method → plain ban → new:
pkce.ValidVerifier→invalid_grantmalformed code_verifier→pkce.Verify. The code was consumed atomically before PKCE runs, so amalformed verifier burns it exactly like a wrong one (fail closed). The
change is confined to
verifyPKCE.Descriptions — ASCII, inside the RFC 6749 charset, never echoing input:
/authorize, plain refusedcode_challenge_method "plain" is refused by the active profilecode_challenge_method plain is refused by the active profile/authorize, unknown methodunsupported code_challenge_method "<method as sent>"unsupported code_challenge_method/authorize, malformed challengemalformed S256 code_challenge/malformed plain code_challenge/token, plain refusedPKCE method "plain" is refused by the active profilePKCE method plain is refused by the active profile/token, malformed verifiermalformed code_verifierConstant time: well-formed verifiers are compared exactly as before. The
syntax pre-check exits early, but its timing depends only on the client's own
input and the public RFC alphabet, never on the stored challenge; and codes
are single-use, so repeated probes against one challenge are impossible
anyway. No storage change.
Docs: godoc (
pkcepackage,ValidVerifier,ValidChallenge,Verify,VerifyS256,Challenge, theAuthorizeRequestPKCE fields,validateAuthorizePKCE,verifyPKCE);oauth2/profile.go(theProfile21Draftbullet claimed 2.1 alone refusesplain— BCP alreadydoes);
CHANGELOG.md(Security);docs/security-considerations.md(thePKCE bullet said
plainwas "accepted (with a warning)" under the looserprofiles: it is refused under BCP, and there is no warning).
Decision:
invalid_grantfor a malformedcode_verifierRFC 7636 does not name an error for a malformed verifier. This PR answers
invalid_grant:challenge, the token endpoint MUST answer
invalid_grant. A malformedverifier fails that comparison against every challenge it did not produce,
so answering
invalid_requestto every malformed verifier would break thatMUST in the common case. The only malformed verifiers that "match" are those
whose own transformation was registered — the bug fixed here.
invalid_granttells the client to restart the flow, whereinvalid_requestwould invite a retry that can only fail.handler/pkce/handler.go) and Keycloak(
PkceUtils) answerinvalid_grantto verifier length or charset errors.Counter-precedent: node-oidc-provider answers
invalid_request(
lib/helpers/pkce_format.js) and checks the syntax before consuming thecode. Checking before consumption here would touch the consumption path
oauth2: retain authorization-code replay metadata to revoke previously issued tokens #80 reworks and weaken fail-closed single use.
/tokenin this module wasalready
invalid_grant,missing code_verifierincluded.The counter-argument — RFC 6749 §5.2 maps an "otherwise malformed" request to
invalid_request— is acknowledged; §4.6 is the more specific rule for afailed PKCE proof.
Behavior change
Nothing is released yet, and only input RFC 7636 already forbids is affected:
code_verifiershorter than 43 or longer than 128 characters, or with acharacter outside
A-Z a-z 0-9 - . _ ~(standard-base64+/=,whitespace, a
%-escape left encoded, non-ASCII), used to be exchanged whenits transformation matched;
/tokennow answersinvalid_grant;/authorizerefuses such plain challenges, and any S256 challenge that isnot a canonical 43-character unpadded base64url SHA-256 digest;
pkce.Verify/VerifyS256returnfalsefor a malformed verifier(signatures unchanged);
pkce.Challengeis unchanged;error, noton
error_description);invalid_grantuntil they expire (CodeTTL, 10 minutes by default); nostorage migration.
Tests
Three new files —
oauth2/pkce/syntax_test.go,oauth2/grant/authorization_code_pkce_test.go, andoauth2/authorize_pkce_test.go(packageoauth2_test: the realAuthorizeHandlerandTokenHandlerwith the memory store):TestValidVerifier(19 rows) — the RFC 7636 Appendix B verifier (43, theminimum), 128, the whole alphabet; empty,
x, 42, 129, 43 bytes / 42 runes,43 runes, a 3-byte rune, leading space, trailing LF / CRLF, tab, embedded
space,
+,/,=,%7E.TestValidVerifierRejectsEveryNonUnreservedByte— all 256 byte values in a43-byte verifier: exactly the 66 unreserved characters pass.
TestValidChallenge(29 rows) — S256: the RFC challenge andS256("x")(well-formed:
/authorizecannot catch the issue's verifier); 42, 44 and64 characters, padded,
+,/, LF in place of a character, trailingCRLF / LF,
~,., a non-canonical final character, non-ASCII, empty.Plain: the verifier rows. Methods:
"",S512,s256.TestValidChallengeS256RequiresCanonicalTrailingBits— the 64 possiblefinal characters: exactly
AEIMQUYcgkosw048pass.TestVerifyRejectsMalformedVerifierEvenWhenTransformMatches(7 rows) —Verify(S256),VerifyS256andVerify(plain)against the malformedverifier's own challenge.
TestVerifyAcceptsEveryLegalVerifierLength(guard) — lengths 43 to 128round-trip through both methods.
TestAuthorizationCodeRejectsMalformedVerifier(10 rows, BCP) — each codeis bound to the malformed verifier's own S256 challenge, so only the syntax
check can refuse it:
invalid_grant,malformed code_verifier.TestAuthorizationCodePlainRejectsMalformedVerifier— a matching19-character plain pair under
Profile20.TestAuthorizationCodeMalformedStoredChallengeNeverMatches(pin) — codesstored before the fix (padded or short S256, short plain) fail with
PKCE verification failed.TestAuthorizationCodeAcceptsWellFormedVerifiersandTestAuthorizationCodeProfile20WithoutPKCE(guards).TestAuthorizeRejectsMalformedS256Challenge(13 rows) andTestAuthorizeRejectsMalformedPlainChallenge(12 rows: explicit and absentmethod) — 302 to the registered URI,
invalid_request, the exactdescription,
state, nocode; the S256 rows also check theConsentFuncis never called.
TestAuthorizePKCEChecksKeepTheirPrecedence(4 rows) — a missingchallenge,
plainor an absent method with a malformed challenge, and ahostile method
S512"\: exact descriptions, nothing echoed.TestAuthorizeAcceptsWellFormedChallenges(guard) — theConsentFuncreceives the challenge and the method as sent.
TestAuthorizeCodeFlowRejectsOneCharacterVerifier— the issuereproduction:
S256("x")at/authorize,xat/token.TestAuthorizeCodeFlowMalformedVerifierBurnsTheCode— the RFC challenge,then
/tokenwithpkceVerifier+" "→malformed code_verifier; the samecode with the right verifier →
invalid_grant(it would be 200 had the codesurvived).
TestAuthorizeCodeFlowEnforcesVerifierSyntax— the form-encoding cases: atrailing space (sent as
+) and a literal+(sent as%2B).Every
error_descriptionthese tests read is also checked against theRFC 6749 charset (
^[\x20\x21\x23-\x5B\x5D-\x7E]*$).Fixtures that relied on non-compliant PKCE values now use well-formed ones:
pkce_test.go;TestAuthorizationCodePKCEMismatch, which now also pinsPKCE verification failed;plainPKCECode(t, store, verifier), where theProfile20test uses a 52-character well-formed pair and the BCP test keepsthe short pair to pin that the profile ban answers before the syntax check,
with its exact description; and the plain row of
TestAuthorizeProfile20AllowsNoPKCEAndPlain. A second commit pinsmissing code_verifierinTestAuthorizationCodeMissingVerifier, so thepresence check keeps answering before the syntax check.
Red phase:
pkcefirst failed to compile (undefined: pkce.ValidVerifier,pkce.ValidChallenge). With the two functions stubbed to the pre-fixbehavior (accept everything): all 7 rows of
TestVerifyRejectsMalformedVerifierEvenWhenTransformMatchesfailed —VerifyandVerifyS256returned true forx; every negative row ofTestValidVerifier(16) andTestValidChallenge(19) failed; the bytesweep accepted all 256 bytes, the trailing-bits sweep all 64 characters.
grant, against the unmodified code: all 10 rows ofTestAuthorizationCodeRejectsMalformedVerifierissued tokens, and sodid the short plain pair; the BCP plain test failed only on its description
(
PKCE method "plain" …).oauth2, against the unmodified code: the issue reproduction got 200with tokens, and so did both form-encoding rows; the burn test failed on
PKCE verification failed; all 25 malformed-challenge rows got a 302carrying a code instead of an error; 3 of the 4 precedence rows failed on
the
"and on the echoedS512"\.Acceptance criteria:
TestValidVerifier(RFC vector = 43, 128, alphabet),TestVerifyAcceptsEveryLegalVerifierLength,TestValidChallenge(RFC challenge),TestAuthorizationCodeAcceptsWellFormedVerifiers,TestAuthorizeAcceptsWellFormedChallenges, the existingTestAuthorizeCodeFlowEndToEndTestValidVerifier,TestValidVerifierRejectsEveryNonUnreservedByte,TestVerifyRejectsMalformedVerifierEvenWhenTransformMatches,TestAuthorizationCodeRejectsMalformedVerifier,TestAuthorizationCodePlainRejectsMalformedVerifier,TestAuthorizeCodeFlowEnforcesVerifierSyntax/authorizeTestValidChallenge,TestValidChallengeS256RequiresCanonicalTrailingBits,TestAuthorizeRejectsMalformedS256Challenge,TestAuthorizeRejectsMalformedPlainChallengeTestAuthorizePKCEChecksKeepTheirPrecedence,TestValidChallenge(method rows),TestAuthorizationCodePlainPKCERefusedUnderBCP(exact description),TestAuthorizationCodeProfile20WithoutPKCE; the existingTestAuthorizeRedirectsProtocolErrors,TestAuthorizeRejectsUnknownPKCEMethod,TestAuthorizeProfile20AllowsNoPKCEAndPlain,TestAuthorizationCodeMissingVerifier,TestAuthorizationCodeProfileRequiresPKCE,TestAuthorizationCodeRequiresPKCEWhenConfigured,TestAuthorizationCodePlainPKCEAcceptedUnderProfile20and the metadata testsxrows ofTestValidVerifier,TestVerifyRejectsMalformedVerifierEvenWhenTransformMatchesandTestAuthorizationCodeRejectsMalformedVerifier; end to end,TestAuthorizeCodeFlowRejectsOneCharacterVerifier(the issue reproduction) andTestAuthorizeCodeFlowMalformedVerifierBurnsTheCodeAn independent adversarial review fuzzed the validators with about 14 million
inputs — zero divergence from the RFC 7636 ABNF — and mutation-tested the
change before approving it. Mutation testing on a scratch copy of this branch
(18 mutants, all killed):
TestValidVerifier,TestValidChallenge,TestVerifyRejectsMalformedVerifierEvenWhenTransformMatches, the grant malformed-verifier test,/authorizeplain~dropped from the unreserved setTestValidVerifier, the byte sweep,TestValidChallenge, the every-length guard, the grant and/authorizewell-formed guards, theProfile20plain exchange+added to the unreserved setTestValidVerifier, the byte sweep,TestValidChallenge,TestVerifyRejectsMalformedVerifierEvenWhenTransformMatches, the grant malformed-verifier test,/authorizeplain, the form-encoding flowTestValidChallenge, the trailing-bits sweep,/authorizeS256TestValidChallenge,/authorizeS256TestValidChallenge,/authorizeplainValidChallengeTestValidChallengeVerifywithout its syntax guardTestVerifyRejectsMalformedVerifierEvenWhenTransformMatches/tokensyntax check removed/tokensyntax check before the plain ban; plain-ban description quoted againTestAuthorizationCodePlainPKCERefusedUnderBCP/authorizesyntax check removed/authorizeS256 and plain/authorizesyntax check before the method / profile checks; method echoed again; plain-refused description quoted againTestAuthorizePKCEChecksKeepTheirPrecedence/authorize§4.3 default removed/authorizeplain, the well-formed guard/tokenmissing-verifier check moved after the syntax check (an absent verifier would readmalformed code_verifier, sameinvalid_grant)TestAuthorizationCodeMissingVerifier, which now pinsmissing code_verifier(second commit)Checks
make build— OK.make test— every package passes with-race;oauth2coverage93.0% → 93.1%,
oauth2/grant97.5% → 98.0%,oauth2/pkcestays at 100%.New and changed code is 100% covered (every function of
pkce.go,validateAuthorizePKCE,verifyPKCE).make lint— 0 issues;go vet ./...onoauth2(tests included) — OK.Follow-ups (out of scope)
error_descriptions still echo client input, so they can carrycharacters outside the RFC 6749 §5.2 charset:
token_endpoint.go(
grant_type … not supported),grant/client_credentials.go(
scope … not allowed for client),authorize_endpoint.go(
response_type … is not supported, andscope %q is not allowed for this clientfromauthorizeScope).Profile20withoutRequirePKCE, acode_verifiersent for a code that has no challenge is ignored; the servershould refuse it (fosite and Keycloak answer
invalid_grant). Profiles thatmandate PKCE are not affected.
MUST NOT be included more than once);
FormValuetakes the first value.Notes for the maintainer
CLAUDE.mdis not edited here. Proposed addition to the "OAuth2 server"section, after the sub-packages list:
MIGRATION.mdis not updated: the change adds two functions and onlyrefuses input RFC 7636 already forbids. An entry under "Breaking changes
before the first v2 release" can be added if you want one.
This also resolves the
PKCE method "plain"charset breach listed as afollow-up in fix(oauth2): authorize token introspection callers #90, and the stale PKCE bullet noted in fix(oauth2): enforce the refresh-token rotation the profile requires #89.