Repository navigation
fix(oauth2): refuse client authentication with an empty secret - #95
Merged
Merged
Conversation
client_secret_basic decoded "Basic base64(<client_id>:)" into an empty secret and handed it to the client's SecretMatcher, and DefaultClient.SecretMatches matched it against an empty Secret. Anyone who knew the client_id of a client registered without a secret — a public client, or a confidential or untyped one left without one — authenticated as it at /token and /revoke, and at /introspect when it was confidential, which the confidential-only check of #81 let through. client_secret_post never accepted an empty client_secret, so the two secret methods disagreed. An empty secret is no credential. RFC 6749 §2.3 lets the authorization server accept the client authentication meeting its security requirements and forbids relying on a public client's authentication to identify it, and OAuth 2.1 §2.1 calls clients without credentials "public clients". RFC 6749 §2.3.1 lets a client omit an empty client_secret, but requires Basic only for clients "that were issued a client password". client_secret_basic now refuses a missing client_id or an empty password with 401 invalid_client before loading the client, with the guard client_secret_post's Authenticate already has: no store lookup, one answer for every client. DefaultClient.SecretMatches never matches an empty Secret, and the SecretMatcher contract says implementations MUST NOT match an empty secret: callers of DefaultClient are covered, custom matchers are told to follow suit. Public clients that sent an empty Basic password must send their client_id in the form body (none); confidential or untyped clients registered without a secret need one to use client_secret_basic or client_secret_post. CHANGELOG and MIGRATION.md describe the change. Refs #94.
Coverage Report for CI Build 38048066397Coverage increased (+0.1%) to 94.753%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
1 similar comment
Coverage Report for CI Build 38048066397Coverage increased (+0.1%) to 94.753%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
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 #94.
Problem
Secret-based client authentication succeeded without a secret, through two
independent holes:
client_secret_basicnever checked the password.decodeBasicturnsAuthorization: Basic base64("<client_id>:")into an empty secret, andAuthenticatewent straight from the decode toLoadClientand theclient's
SecretMatcher(Basic base64(":")even looked up the client"").DefaultClient.SecretMatchesmatched two empty strings.subtle.ConstantTimeComparereturns 1 for two empty slices, so an unsetSecretmatched the empty password.client_secret_postalready refused an emptyclient_secretbefore thestore lookup, so the two secret-based methods disagreed. Anyone knowing the
client_idof a client registered without a secret — a public client, or aconfidential or untyped one left without one — authenticated as it:
/tokenclient_credentialsmints a token withsub= the client, whatever its type/revoke/introspectAn empty secret is no credential. RFC 6749 §2.3 lets the authorization
server accept "any form of client authentication meeting its security
requirements" and forbids relying on a public client's authentication to
identify it; §10.1 forbids issuing client passwords to native or
user-agent-based clients; OAuth 2.1 §2.1 calls clients without credentials
"public clients". RFC 6749 §2.3.1 lets a client omit an empty
client_secretfrom the body, but requires Basic only for clients "thatwere issued a client password", and a client registered without a secret
was issued none.
Reproductions — the red phase of the committed tests, run on
d8ccbb5withan empty Basic password:
/tokenclient_credentialsas a confidential, public or untyped client without a secret/revokeof the client's own token, same clients/introspectas a confidential client without a secret{"active":true,…}with the token's claimsunknown client, thenclient authentication failed: the answer depended on the clientFix
LoadClient(oauth2/clientauth/basic.go). Right afterthe decode,
id == "" || secret == ""answers401 invalid_clientmissing client_id or client_secret— the guard and descriptionclient_secret_post'sAuthenticatealready had. No store lookuphappens, so the answer is byte-identical whether the client exists, has a
secret or has none (no existence oracle), and a third-party
SecretMatcherthat would wrongly accept""is never consulted. Thecheck holds under every profile,
Profile20included: RFC 6749 permitsthe refusal, and nothing in the repository relied on an empty secret.
decodeBasicstays a pure decoder andMatcha cheap header check.DefaultClient.SecretMatchesreturnsfalsewhenSecretis empty;a non-empty
Secretis still compared withsubtle.ConstantTimeCompare.This covers code that calls the matcher directly. The early return leaks
nothing:
ConstantTimeComparealready returns early on a lengthmismatch.
oauth2.SecretMatchercontract (godoc): an empty secret is never acredential — implementations MUST NOT match it, not even against a hash
of the empty string, nor anything when no secret is registered. The
NewBasic,SecretMatchesandDefaultClient.Secretgodoc say the same.No signature change and no new exported identifier.
none,client_secret_post, #81's confidential-only check,/authorizeand themetadata are unchanged. The refusal is an
*oauth2.Errorwhose descriptionstays within the RFC 6749 §5.2 charset; the 401 carries
WWW-Authenticate: Basic realm="oauth2"and reachesOnErrorasinvalid_client.Docs:
CHANGELOG.md(Security),MIGRATION.md(new #94 section with abefore/after table),
LIMITATIONS.md(RFC 6749 §4.4 is not enforced yet),docs/security-considerations.md(client-authentication and introspectionbullets, a new operator-checklist item).
Breaking change / migration
Nothing is tagged yet, but code tracking
masteris affected — seeMIGRATION.md.none:client_idin the form body, noAuthorizationheader and noclient_secret(RFC 6749 §3.2.1). Register them withTypeValue: oauth2.ClientPublic, addclientauth.NewNone()toServerConfig.ClientAuth(examples/oauth2does not wire it) and, if theclient sets
AuthMethodValues, include"none".longer authenticate through
client_secret_basicorclient_secret_post(and
nonerefuses non-public clients): give each one a high-entropysecret, or authenticate it with a custom
ClientAuthenticatorusinganother credential (e.g.
private_key_jwt, mTLS).SecretMatchers must returnfalsewhen no secret isregistered or the presented one is empty: a bcrypt hash of
""matches"".Basic base64("<client_id>:")andBasic base64(":<secret>")answer
401 invalid_clientmissing client_id or client_secretat/token,/revokeand/introspect, whatever the client. The two oauth2: add explicit caller authorization for token introspection #81/introspectrows that used an empty Basic password now get thisdescription instead of
introspection requires confidential client authentication.DefaultClient{Secret: ""}.SecretMatches("")is nowfalse.Public clients still authenticate with
none, by design. Through it, apublic client can still use any grant its
GrantTypeValuesallow,client_credentialsincluded: RFC 6749 §4.4 is not enforced yet (now listedin
LIMITATIONS.md, follow-up (a) below). RestrictGrantTypeValuesforpublic clients — an empty list allows every grant.
Tests
TestDefaultClient(values_test.go, extended) — aDefaultClientwithout a
Secretmatches neither""nor"s3cr3t".TestSecretMethodsRefuseMissingCredentials(clientauth_test.go) —Basic and Post × six cases: a client with a secret, a confidential and a
public client without one, an unknown client (each with an empty secret),
an empty
client_idwith and without a secret. Each case asserts noclient,
invalid_client, the exact description and zeroLoadClientcalls (a new
countingStorefake, one per subtest).TestEmptyBasicPasswordRefusedAtEveryEndpoint(newempty_client_secret_test.go) —/token(client_credentials),/revokeand/introspecton aProfile20BCPserver with a pinnedclock, Basic + Post +
none, a client with a secret and confidential,public and untyped clients without one, each holding an active access
token. The unknown client's answer is pinned (401, the Basic challenge,
the exact JSON body, the §5.2 charset), and every registered client's
answer must be the same status, headers and bytes; every client's token
is still in the store afterwards — nothing revoked or disclosed.
TestIntrospectCallerAuthentication— its two empty-password rows nowexpect
missing client_id or client_secret: Basic refuses them beforeoauth2: add explicit caller authorization for token introspection #81's confidential-only check, which stays covered by the
nonepublic,public-with-secret (Basic and Post) and untyped-with-secret rows.
Red phase — the tests were written first and run on
d8ccbb5:TestDefaultClient(SecretMatches("")was true); the six Basic rows (theconfidential and public clients without a secret authenticated, the
others got
client authentication failedorunknown clientafter a storelookup), while the six Post rows passed as consistency guards; the three
endpoint subtests (reproductions (a)–(d)); the two introspection rows.
Mutation — my runs on a scratch copy killed: the Basic guard removed,
reduced to
secret == "", reduced toid == "", theDefaultClientguardremoved, and both removed (the original bug). The independent adversarial
review ran 16 mutants on
basic.go,client.go,post.goandintrospect_endpoint.go(guard dropped or reduced to one term,&&insteadof
||, moved afterLoadClientor after the nil-client check, baresentinel,
invalid_requestcode,DefaultClientguard dropped, inverted orweakened, the Post guard dropped, the confidential-only check dropped or
turned into a public-only denylist): 14 killed; the two survivors are
equivalent —
SecretMatchesguarding on the presented secret instead of theregistered one, and a non-constant-time comparison, neither observable by a
test.
Probe — the review also sent 159 Basic header variants (53 per endpoint:
Basic/basic/BASIC/bAsIc, unpadded and extra padding, double andtrailing spaces, the URL-safe alphabet, a
%00password — notpercent-decoded, so not empty —,
:alone, an emptyclient_idwith andwithout a secret) for an unknown client, a client with a secret and the
confidential, public and untyped clients without one, to
/token,/revokeand
/introspect: every one got 401invalid_clientwithWWW-Authenticate: Basic realm="oauth2", none authenticated. With Basic +form mixes and Post with an empty
client_secret, that is 165 refusals; theonly 200 was a public client posting an empty
client_secret, i.e.none,as designed.
Checks
make build— OK.make test— every package passes with-race:oauth2oauth2/clientauthThe changed functions,
basicAuth.AuthenticateandDefaultClient.SecretMatches, are 100% covered.make lint— 0 issues.gofmt -l— clean on every changed production file and on the new testfile.
values_test.goandclientauth_test.gokeep the gofmt drift theyalready have on
master(struct-field alignment unrelated to this PR); nonew hunk was added.
Follow-ups (out of scope)
client_credentials"MUST only be used byconfidential clients", but
grant/client_credentials.goonly checksGrantTypeValues: a public client authenticated withnonecan mintclient_credentialstokens. Documented inLIMITATIONS.mdfor now.Appendix B), which matters for interoperability (e.g. golang.org/x/oauth2
AuthStyleInHeaderencodes them). Keep the empty check on the decodedvalues when fixing it.
basic.Matchclaims anyAuthorizationheader starting withB(e.g.
Bearer …); it could be folded into (b).client exists (
unknown clientvsclient authentication failed,method not allowed for client,client cannot verify secret).and a form
client_secret) is silently resolved to Basic, while RFC 6749§2.3 says a client MUST NOT use more than one method per request
(
invalid_request, §5.2).Client.GrantTypesgodoc says values are compared withstrings.EqualFold, butgrantTypeAlloweduses an exactslices.Contains(fails closed).Notes for the maintainer
id == ""term goes one step beyond the issue text: it mirrorsclient_secret_postand RFC 6749 §2.3.1 makesclient_idREQUIRED.Dropping it only changes the description an empty-
client_idBasicheader gets (
unknown clientagain, after a store lookup).Basic <id>:fora public client break; golang.org/x/oauth2's auto-detection falls back to
form parameters and omits an empty
client_secret, which lands onnone.