fix: reject PKCE code_verifier below RFC 7636 length floor (#28003)

The token endpoint accepted any non-empty `code_verifier`, so a
one-character verifier was enough to authenticate. RFC 7636 §4.1
requires 43 to 128 characters from the unreserved set.

That fix plus the related gaps review surfaced in the same path:

- Enforce the length and charset floor on the verifier before the S256
comparison runs.
- Validate the challenge at the authorize endpoint too. It was only
checked for non-emptiness, so a malformed challenge was stored and then
failed late at token exchange, blaming the wrong parameter.
- A malformed verifier now returns `invalid_request` (RFC 6749 §5.2); a
well-formed but wrong one still returns `invalid_grant` (RFC 7636 §4.6).
Both looked identical before, so a client had no way to tell a syntax
error from a hash mismatch and would retry the same bad verifier
forever.
- Revoke the authorization code when a PKCE check fails. Without that, a
leaked code could be replayed with unlimited verifier guesses for its
remaining lifetime, and RFC 6749 §10.5 requires codes to be single use.
- Fix verifier generation in `scripts/oauth2/*.sh` and the docs example.
They deleted reserved base64 characters instead of translating them to
the URL-safe alphabet, so most runs produced verifiers under the new
floor.

Also carries #28041, which merged into this branch: public clients may
register bare custom schemes such as `vscode://` again, with `mailto`,
`tel`, and `sms` rejected.

Split out of #27873 (public OAuth2 client support). PKCE is already
mandatory for every client, so this stands on its own.

<details>
<summary>Manual verification</summary>

Ran against a local dev server on this branch, using a session token and
a throwaway app from `scripts/oauth2/setup-test-app.sh`.

1. Happy path unchanged: HTTP 200, verifier length 43.
2. `code_verifier=short`, and a 43-character verifier ending in `!`:
both HTTP 400 `invalid_request`, so charset is enforced and not just
length.
3. `code_challenge=tooshort` at authorize: HTTP 400 `invalid_request`,
no code issued. An empty challenge still hits the older "required and
cannot be empty" message.
4. Well-formed but wrong verifier: HTTP 400 `invalid_grant`, distinct
from the cases above.
5. Retrying that same code with the correct verifier: HTTP 400, code
already revoked by the failed check.
6. `generate-pkce.sh` produces a 43-character verifier (20 out of 20
runs); the docs example produces 128.
7. `scripts/oauth2/test-mcp-oauth2.sh` passes end to end. The two
bearer-token failures in its output are a pre-existing script bug
(`09c50559f3`, July 2025) that reuses a resource-scoped token against
the real API, not a regression here.

</details>
This commit is contained in:
Bobby Ho
2026-08-12 13:36:52 -07:00
committed by GitHub
parent 0acd9785fa
commit 209d1ca498
14 changed files with 549 additions and 55 deletions
+61
View File
@@ -267,6 +267,19 @@ func TestOAuth2PrivilegeEscalation(t *testing.T) {
ClientName: fmt.Sprintf("native-app-3-%d", time.Now().UnixNano()),
TokenEndpointAuthMethod: "none", // Required for public clients
},
{
// Bare custom schemes (no reverse-domain notation) are the
// schemes real native apps register with the OS, and PKCE,
// not the scheme's spelling, is what secures the redirect.
RedirectURIs: []string{"vscode://coder.authenticate"},
ClientName: fmt.Sprintf("native-app-vscode-%d", time.Now().UnixNano()),
TokenEndpointAuthMethod: "none",
},
{
RedirectURIs: []string{"jetbrains://coder-callback"},
ClientName: fmt.Sprintf("native-app-jetbrains-%d", time.Now().UnixNano()),
TokenEndpointAuthMethod: "none",
},
}
for i, req := range validCustomSchemeRequests {
@@ -312,6 +325,54 @@ func TestOAuth2PrivilegeEscalation(t *testing.T) {
require.Contains(t, err.Error(), "dangerous scheme")
})
}
// mailto, tel, and sms are not in the dangerous-scheme blocklist
// above: they hand off to a mail client, dialer, or SMS app rather
// than injecting content, so they are harmless for a confidential
// client's redirect. A public client has no secret, so the redirect
// URI's scheme is its only mechanism for regaining control, and
// none of these three return control to it the way a real redirect
// scheme does. They are rejected for public clients specifically,
// with a distinct error from the dangerous-scheme case above.
publicClientDisallowedSchemeRequests := []struct {
req codersdk.OAuth2ClientRegistrationRequest
scheme string
}{
{
req: codersdk.OAuth2ClientRegistrationRequest{
RedirectURIs: []string{"mailto:user@example.com"},
ClientName: fmt.Sprintf("native-app-mailto-%d", time.Now().UnixNano()),
TokenEndpointAuthMethod: "none",
},
scheme: "mailto",
},
{
req: codersdk.OAuth2ClientRegistrationRequest{
RedirectURIs: []string{"tel:+15555550100"},
ClientName: fmt.Sprintf("native-app-tel-%d", time.Now().UnixNano()),
TokenEndpointAuthMethod: "none",
},
scheme: "tel",
},
{
req: codersdk.OAuth2ClientRegistrationRequest{
RedirectURIs: []string{"sms:+15555550100"},
ClientName: fmt.Sprintf("native-app-sms-%d", time.Now().UnixNano()),
TokenEndpointAuthMethod: "none",
},
scheme: "sms",
},
}
for _, test := range publicClientDisallowedSchemeRequests {
t.Run(fmt.Sprintf("PublicClientDisallowedScheme_%s", test.scheme), func(t *testing.T) {
t.Parallel()
_, err := client.PostOAuth2ClientRegistration(ctx, test.req)
require.Error(t, err)
require.Contains(t, err.Error(), "public clients may not use the "+test.scheme+" scheme")
})
}
})
}
+5
View File
@@ -498,6 +498,11 @@ func TestOAuth2ProviderTokenExchange(t *testing.T) {
var verifier string
if test.defaultCode != nil {
code = *test.defaultCode
// These subtests exercise malformed/expired code_
// handling; the code lookup fails before code_verifier is
// ever compared, but it still has to satisfy RFC 7636
// §4.1's format floor to reach that point.
verifier = strings.Repeat("a", 43)
} else {
var err error
code, verifier, err = authorizationFlow(ctx, userClient, valid)
+18 -6
View File
@@ -54,12 +54,24 @@ func extractAuthorizeParams(r *http.Request, callbackURL *url.URL) (authorizePar
codeChallengeMethod: p.String(vals, "", "code_challenge_method"),
}
// PKCE is required for authorization code flow requests.
if params.responseType == codersdk.OAuth2ProviderResponseTypeCode && params.codeChallenge == "" {
p.Errors = append(p.Errors, codersdk.ValidationError{
Field: "code_challenge",
Detail: `Query param "code_challenge" is required and cannot be empty`,
})
// PKCE is required for authorization code flow requests. Reject a
// malformed code_challenge here (RFC 7636 §4.4.1) rather than storing it
// verbatim and failing later at token exchange, where the error would
// point at the code_verifier instead of the parameter that was actually
// invalid.
if params.responseType == codersdk.OAuth2ProviderResponseTypeCode {
switch {
case params.codeChallenge == "":
p.Errors = append(p.Errors, codersdk.ValidationError{
Field: "code_challenge",
Detail: `Query param "code_challenge" is required and cannot be empty`,
})
case !ValidPKCEFormat(params.codeChallenge):
p.Errors = append(p.Errors, codersdk.ValidationError{
Field: "code_challenge",
Detail: "must be 43 to 128 characters from the unreserved character set [A-Za-z0-9-._~]",
})
}
}
// Validate resource indicator syntax (RFC 8707): must be absolute URI without fragment
@@ -13,8 +13,16 @@ const (
// TestResourceURI is used for testing resource parameter
TestResourceURI = "https://api.example.com"
// Invalid PKCE verifier for negative testing
InvalidCodeVerifier = "wrong-verifier"
// InvalidCodeVerifier is well-formed (43 characters, RFC 7636 §4.1's
// unreserved set) but does not hash to any issued challenge, so it
// exercises the PKCE comparison failure (invalid_grant) rather than the
// length/charset check (invalid_request).
InvalidCodeVerifier = "wrong-verifier-that-is-well-formed-43-chars"
// MalformedCodeVerifier is below RFC 7636 §4.1's 43-character floor, so
// it exercises the length/charset check (invalid_request) instead of the
// PKCE comparison.
MalformedCodeVerifier = "too-short"
)
// OAuth2ErrorTypes contains standard OAuth2 error codes
@@ -158,6 +158,113 @@ func TestOAuth2InvalidPKCE(t *testing.T) {
)
}
// TestOAuth2PKCEFailureConsumesCode verifies that a code_verifier that fails
// the PKCE hash comparison consumes the authorization code (RFC 6749 §10.5:
// codes are single-use). Without this, a leaked code could be replayed with
// unlimited further code_verifier guesses for the rest of its lifetime.
func TestOAuth2PKCEFailureConsumesCode(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: false,
})
_ = coderdtest.CreateFirstUser(t, client)
app, clientSecret := oauth2providertest.CreateTestOAuth2App(t, client)
t.Cleanup(func() {
oauth2providertest.CleanupOAuth2App(t, client, app.ID)
})
codeVerifier, codeChallenge := oauth2providertest.GeneratePKCE(t)
state := oauth2providertest.GenerateState(t)
authParams := oauth2providertest.AuthorizeParams{
ClientID: app.ID.String(),
ResponseType: "code",
RedirectURI: oauth2providertest.TestRedirectURI,
State: state,
CodeChallenge: codeChallenge,
CodeChallengeMethod: "S256",
}
code := oauth2providertest.AuthorizeOAuth2App(t, client, client.URL.String(), authParams)
require.NotEmpty(t, code, "should receive authorization code")
// Attempt the exchange with a well-formed but wrong verifier. This fails
// the PKCE hash comparison (invalid_grant) and must consume the code.
failedParams := oauth2providertest.TokenExchangeParams{
GrantType: "authorization_code",
Code: code,
ClientID: app.ID.String(),
ClientSecret: clientSecret,
CodeVerifier: oauth2providertest.InvalidCodeVerifier,
RedirectURI: oauth2providertest.TestRedirectURI,
}
oauth2providertest.PerformTokenExchangeExpectingError(
t, client.URL.String(), failedParams, oauth2providertest.OAuth2ErrorTypes.InvalidGrant,
)
// The correct verifier can no longer redeem the code: the failed PKCE
// comparison above already consumed it.
retryParams := oauth2providertest.TokenExchangeParams{
GrantType: "authorization_code",
Code: code,
ClientID: app.ID.String(),
ClientSecret: clientSecret,
CodeVerifier: codeVerifier,
RedirectURI: oauth2providertest.TestRedirectURI,
}
oauth2providertest.PerformTokenExchangeExpectingError(
t, client.URL.String(), retryParams, oauth2providertest.OAuth2ErrorTypes.InvalidGrant,
)
}
// TestOAuth2MalformedCodeVerifierIsRejected verifies that a code_verifier
// below the RFC 7636 §4.1 length floor is rejected as invalid_request,
// distinct from a well-formed verifier that fails the PKCE hash comparison
// (invalid_grant, covered by TestOAuth2InvalidPKCE).
func TestOAuth2MalformedCodeVerifierIsRejected(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: false,
})
_ = coderdtest.CreateFirstUser(t, client)
app, clientSecret := oauth2providertest.CreateTestOAuth2App(t, client)
t.Cleanup(func() {
oauth2providertest.CleanupOAuth2App(t, client, app.ID)
})
_, codeChallenge := oauth2providertest.GeneratePKCE(t)
state := oauth2providertest.GenerateState(t)
authParams := oauth2providertest.AuthorizeParams{
ClientID: app.ID.String(),
ResponseType: "code",
RedirectURI: oauth2providertest.TestRedirectURI,
State: state,
CodeChallenge: codeChallenge,
CodeChallengeMethod: "S256",
}
code := oauth2providertest.AuthorizeOAuth2App(t, client, client.URL.String(), authParams)
require.NotEmpty(t, code, "should receive authorization code")
tokenParams := oauth2providertest.TokenExchangeParams{
GrantType: "authorization_code",
Code: code,
ClientID: app.ID.String(),
ClientSecret: clientSecret,
CodeVerifier: oauth2providertest.MalformedCodeVerifier,
RedirectURI: oauth2providertest.TestRedirectURI,
}
oauth2providertest.PerformTokenExchangeExpectingError(
t, client.URL.String(), tokenParams, oauth2providertest.OAuth2ErrorTypes.InvalidRequest,
)
}
// TestOAuth2WithoutPKCEIsRejected verifies that authorization requests without
// a code_challenge are rejected now that PKCE is mandatory.
func TestOAuth2WithoutPKCEIsRejected(t *testing.T) {
@@ -189,6 +296,39 @@ func TestOAuth2WithoutPKCEIsRejected(t *testing.T) {
)
}
// TestOAuth2MalformedCodeChallengeIsRejected verifies that a code_challenge
// below the RFC 7636 §4.1 length floor is rejected at the authorization
// request, rather than being stored and only failing once a client attempts
// to exchange the resulting code.
func TestOAuth2MalformedCodeChallengeIsRejected(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: false,
})
_ = coderdtest.CreateFirstUser(t, client)
app, _ := oauth2providertest.CreateTestOAuth2App(t, client)
t.Cleanup(func() {
oauth2providertest.CleanupOAuth2App(t, client, app.ID)
})
state := oauth2providertest.GenerateState(t)
authParams := oauth2providertest.AuthorizeParams{
ClientID: app.ID.String(),
ResponseType: "code",
RedirectURI: oauth2providertest.TestRedirectURI,
State: state,
CodeChallenge: "too-short",
CodeChallengeMethod: "S256",
}
oauth2providertest.AuthorizeOAuth2AppExpectingError(
t, client, client.URL.String(), authParams, http.StatusBadRequest,
)
}
func TestOAuth2TokenExchangeClientSecretBasic(t *testing.T) {
t.Parallel()
+39
View File
@@ -6,6 +6,45 @@ import (
"encoding/base64"
)
// PKCE code verifier bounds from RFC 7636 §4.1.
const (
pkceVerifierMinLength = 43
pkceVerifierMaxLength = 128
)
// ValidPKCEFormat reports whether s meets RFC 7636 §4.1: 43 to 128 characters
// of the unreserved set [A-Za-z0-9-._~]. RFC 7636 gives code_verifier and
// code_challenge the same ABNF, so this check applies to both: a code_verifier
// directly, and a code_challenge because the S256 method that produces it
// (base64url(SHA256(verifier))) always yields a string within these bounds.
//
// The length floor matters because the challenge and code both travel
// through the authorization URL and redirect, landing in browser history,
// referrer headers, and proxy logs. An attacker who recovers either one
// brute-forces the verifier offline at whatever entropy the client chose,
// with no server-side rate limit to slow them down. A client secret also
// authenticates the token request today, but public clients (#27873) will
// rely on this bound alone, so it must hold on its own merit. The same
// bound on code_challenge keeps a malformed value from being persisted
// verbatim and failing late, at token exchange, instead of at the
// authorization request where RFC 7636 §4.4.1 expects it to be rejected.
func ValidPKCEFormat(s string) bool {
if len(s) < pkceVerifierMinLength || len(s) > pkceVerifierMaxLength {
return false
}
for _, r := range s {
switch {
case r >= 'A' && r <= 'Z',
r >= 'a' && r <= 'z',
r >= '0' && r <= '9',
r == '-', r == '.', r == '_', r == '~':
default:
return false
}
}
return true
}
// VerifyPKCE verifies that the code_verifier matches the code_challenge
// using the S256 method as specified in RFC 7636.
func VerifyPKCE(challenge, verifier string) bool {
+74
View File
@@ -3,6 +3,7 @@ package oauth2provider_test
import (
"crypto/sha256"
"encoding/base64"
"strings"
"testing"
"github.com/stretchr/testify/require"
@@ -124,3 +125,76 @@ func TestValidatePKCECodeChallengeMethod(t *testing.T) {
})
}
}
func TestValidPKCEFormat(t *testing.T) {
t.Parallel()
tests := []struct {
name string
verifier string
expectValid bool
}{
{
name: "Empty",
verifier: "",
expectValid: false,
},
{
name: "OneCharacter",
verifier: "a",
expectValid: false,
},
{
name: "OneBelowMinLength",
verifier: strings.Repeat("a", 42),
expectValid: false,
},
{
name: "AtMinLength",
verifier: strings.Repeat("a", 43),
expectValid: true,
},
{
name: "AtMaxLength",
verifier: strings.Repeat("a", 128),
expectValid: true,
},
{
name: "OneAboveMaxLength",
verifier: strings.Repeat("a", 129),
expectValid: false,
},
{
name: "AllowedCharacters",
verifier: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~",
expectValid: true,
},
{
name: "PlusIsRejected",
verifier: strings.Repeat("a", 42) + "+",
expectValid: false,
},
{
name: "SlashIsRejected",
verifier: strings.Repeat("a", 42) + "/",
expectValid: false,
},
{
name: "EqualsIsRejected",
verifier: strings.Repeat("a", 42) + "=",
expectValid: false,
},
{
name: "SpaceIsRejected",
verifier: strings.Repeat("a", 42) + " ",
expectValid: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
require.Equal(t, tt.expectValid, oauth2provider.ValidPKCEFormat(tt.verifier))
})
}
}
+68 -7
View File
@@ -13,12 +13,14 @@ import (
"github.com/google/uuid"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/apikey"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/httpmw/loggermw"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/codersdk"
)
@@ -97,6 +99,17 @@ func extractTokenRequest(r *http.Request, callbackURL *url.URL) (codersdk.OAuth2
Detail: "Parameter \"client_secret\" is required and cannot be empty",
})
}
// A code_verifier outside RFC 7636 §4.1's bounds is a syntax error
// (RFC 6749 §5.2), distinct from a well-formed verifier that fails the
// PKCE hash comparison in authorizationCodeGrant, which RFC 7636 §4.6
// maps to invalid_grant instead. Checking it here, alongside the other
// syntax validation, keeps the two failure modes distinguishable.
if !ValidPKCEFormat(req.CodeVerifier) {
p.Errors = append(p.Errors, codersdk.ValidationError{
Field: "code_verifier",
Detail: "must be 43 to 128 characters from the unreserved character set [A-Za-z0-9-._~] (RFC 7636 §4.1)",
})
}
}
// Validate redirect URI - errors are added to p.Errors.
@@ -158,6 +171,18 @@ func Tokens(db database.Store, lifetimes codersdk.SessionLifetime) http.HandlerF
return
}
}
// A malformed code_verifier gets its own message so a client that
// sent a well-formed but wrong verifier (rejected later, as
// invalid_grant, by the PKCE hash comparison) can tell the two
// failures apart instead of retrying the same bad verifier forever.
if slices.ContainsFunc(validationErrs, func(validationError codersdk.ValidationError) bool {
return validationError.Field == "code_verifier"
}) {
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidRequest, "The code_verifier parameter must be 43 to 128 characters from the unreserved character set [A-Za-z0-9-._~] (RFC 7636 §4.1)")
return
}
// Generic invalid request for other validation errors
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidRequest, "The request is missing required parameters or is otherwise malformed")
return
@@ -211,6 +236,35 @@ func Tokens(db database.Store, lifetimes codersdk.SessionLifetime) http.HandlerF
}
}
// revokeOAuth2CodeOnPKCEFailure deletes a code that failed PKCE verification
// so it cannot be replayed with further code_verifier guesses (RFC 6749
// §10.5). Deletion failure does not change the response returned to the
// caller: surfacing it as a different error would let a caller distinguish
// "delete succeeded" from "delete failed," defeating the point of revoking
// the code in the first place. It is instead noted on the request's log line
// so operators can see it happened.
//
// A code that is already gone satisfies the goal, so sql.ErrNoRows is not a
// failure worth logging. It surfaces because the authorization check reads
// the code before deleting it, and that read reports a missing row when a
// concurrent attempt already revoked the code or it was reaped after expiry.
//
// The delete runs on a context detached from the request. The request context
// is canceled when the client disconnects, so a caller that fails PKCE and
// then drops the connection would otherwise leave its own code redeemable for
// the rest of its lifetime, which is the replay this function prevents.
func revokeOAuth2CodeOnPKCEFailure(ctx context.Context, db database.Store, codeID uuid.UUID) {
revokeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer cancel()
//nolint:gocritic // OAuth2 system context, no authenticated user during token exchange
if err := db.DeleteOAuth2ProviderAppCodeByID(dbauthz.AsSystemOAuth2(revokeCtx), codeID); err != nil && !errors.Is(err, sql.ErrNoRows) {
if rlogger := loggermw.RequestLoggerFromContext(ctx); rlogger != nil {
rlogger.WithFields(slog.F("oauth2_pkce_failure_code_revoke_error", err.Error()))
}
}
}
func authorizationCodeGrant(ctx context.Context, db database.Store, app database.OAuth2ProviderApp, lifetimes codersdk.SessionLifetime, req codersdk.OAuth2TokenRequest) (codersdk.OAuth2TokenResponse, error) {
// Validate the client secret.
secret, err := ParseFormattedSecret(req.ClientSecret)
@@ -277,18 +331,25 @@ func authorizationCodeGrant(ctx context.Context, db database.Store, app database
}
}
// PKCE is mandatory for all authorization code flows
// (OAuth 2.1). Verify the code verifier against the stored
// challenge.
if req.CodeVerifier == "" {
return codersdk.OAuth2TokenResponse{}, errInvalidPKCE
}
// PKCE is mandatory for all authorization code flows (OAuth 2.1). Verify
// the code verifier against the stored challenge. extractTokenRequest
// already rejected a malformed verifier as invalid_request, so
// req.CodeVerifier is guaranteed to meet RFC 7636 §4.1's bounds here; a
// mismatch below is a wrong-but-well-formed verifier, RFC 7636 §4.6's
// invalid_grant case.
//
// RFC 6749 §10.5 requires codes to be single-use. A code that survives a
// failed PKCE check would otherwise let a leaked code (the exact threat
// PKCE defends against) be replayed with different code_verifier guesses
// for the rest of its lifetime, unthrottled.
if !dbCode.CodeChallenge.Valid || dbCode.CodeChallenge.String == "" {
// Code was issued without a challenge — should not happen
// Code was issued without a challenge, which should not happen
// with authorize endpoint enforcement, but defend in depth.
revokeOAuth2CodeOnPKCEFailure(ctx, db, dbCode.ID)
return codersdk.OAuth2TokenResponse{}, errInvalidPKCE
}
if !VerifyPKCE(dbCode.CodeChallenge.String, req.CodeVerifier) {
revokeOAuth2CodeOnPKCEFailure(ctx, db, dbCode.ID)
return codersdk.OAuth2TokenResponse{}, errInvalidPKCE
}
+83 -5
View File
@@ -110,6 +110,10 @@ func TestExtractTokenParams_Scopes(t *testing.T) {
form.Set("client_id", "test-client")
form.Set("client_secret", "test-secret")
form.Set("code", "test-code")
// This test only exercises scope parsing, but code_verifier is
// validated unconditionally for this grant type, so use a value
// that satisfies the RFC 7636 §4.1 length floor.
form.Set("code_verifier", strings.Repeat("a", 43))
if tc.scopeParam != "" {
form.Set("scope", tc.scopeParam)
}
@@ -147,22 +151,22 @@ func TestExtractTokenParams_ScopesURLEncoded(t *testing.T) {
}{
{
name: "PlusEncodedSpaces",
rawQuery: "grant_type=authorization_code&client_id=test&client_secret=secret&code=code&scope=scope1+scope2+scope3",
rawQuery: "grant_type=authorization_code&client_id=test&client_secret=secret&code=code&code_verifier=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&scope=scope1+scope2+scope3",
expectedScopes: []string{"scope1", "scope2", "scope3"},
},
{
name: "PercentEncodedSpaces",
rawQuery: "grant_type=authorization_code&client_id=test&client_secret=secret&code=code&scope=scope1%20scope2%20scope3",
rawQuery: "grant_type=authorization_code&client_id=test&client_secret=secret&code=code&code_verifier=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&scope=scope1%20scope2%20scope3",
expectedScopes: []string{"scope1", "scope2", "scope3"},
},
{
name: "MixedEncoding",
rawQuery: "grant_type=authorization_code&client_id=test&client_secret=secret&code=code&scope=scope1+scope2%20scope3",
rawQuery: "grant_type=authorization_code&client_id=test&client_secret=secret&code=code&code_verifier=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&scope=scope1+scope2%20scope3",
expectedScopes: []string{"scope1", "scope2", "scope3"},
},
{
name: "ColonEncodedInScope",
rawQuery: "grant_type=authorization_code&client_id=test&client_secret=secret&code=code&scope=coder%3Aworkspace.create+coder%3Aworkspace.operate",
rawQuery: "grant_type=authorization_code&client_id=test&client_secret=secret&code=code&code_verifier=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&scope=coder%3Aworkspace.create+coder%3Aworkspace.operate",
expectedScopes: []string{"coder:workspace.create", "coder:workspace.operate"},
},
}
@@ -216,6 +220,7 @@ func TestExtractTokenParams_ScopesEdgeCases(t *testing.T) {
form.Set("client_id", "test-client")
form.Set("client_secret", "test-secret")
form.Set("code", "test-code")
form.Set("code_verifier", strings.Repeat("a", 43))
return form
},
expectedScopes: []string{},
@@ -229,6 +234,7 @@ func TestExtractTokenParams_ScopesEdgeCases(t *testing.T) {
form.Set("client_id", "test-client")
form.Set("client_secret", "test-secret")
form.Set("code", "test-code")
form.Set("code_verifier", strings.Repeat("a", 43))
form.Set("scope", " ")
return form
},
@@ -244,6 +250,7 @@ func TestExtractTokenParams_ScopesEdgeCases(t *testing.T) {
form.Set("client_id", "test-client")
form.Set("client_secret", "test-secret")
form.Set("code", "test-code")
form.Set("code_verifier", strings.Repeat("a", 43))
form.Set("scope", longScope)
return form
},
@@ -318,7 +325,10 @@ func TestExtractAuthorizeParams_Scopes(t *testing.T) {
query.Set("response_type", "code")
query.Set("client_id", "test-client")
query.Set("redirect_uri", "http://localhost:3000/callback")
query.Set("code_challenge", "test-challenge")
// This test only exercises scope parsing, but code_challenge is
// still required for response_type=code and must satisfy the
// RFC 7636 §4.1 length floor, so use a valid-length value.
query.Set("code_challenge", strings.Repeat("a", 43))
if tc.scopeParam != "" {
query.Set("scope", tc.scopeParam)
}
@@ -342,6 +352,74 @@ func TestExtractAuthorizeParams_Scopes(t *testing.T) {
}
}
// TestExtractAuthorizeParams_CodeChallengeFormat ensures a code_challenge is
// rejected at the authorization request (RFC 7636 §4.4.1) when it does not
// meet the same length and character bounds as a code_verifier, rather than
// being stored and failing later at token exchange.
func TestExtractAuthorizeParams_CodeChallengeFormat(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
codeChallenge string
expectValid bool
}{
{
name: "ValidLength",
codeChallenge: strings.Repeat("a", 43),
expectValid: true,
},
{
name: "TooShort",
codeChallenge: strings.Repeat("a", 42),
expectValid: false,
},
{
name: "TooLong",
codeChallenge: strings.Repeat("a", 129),
expectValid: false,
},
{
name: "DisallowedCharacter",
codeChallenge: strings.Repeat("a", 42) + "+",
expectValid: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
callbackURL, err := url.Parse("http://localhost:3000/callback")
require.NoError(t, err)
query := url.Values{}
query.Set("response_type", "code")
query.Set("client_id", "test-client")
query.Set("redirect_uri", "http://localhost:3000/callback")
query.Set("code_challenge", tc.codeChallenge)
reqURL, err := url.Parse("http://localhost:8080/oauth2/authorize?" + query.Encode())
require.NoError(t, err)
req := &http.Request{
Method: http.MethodGet,
URL: reqURL,
}
_, validationErrs, err := extractAuthorizeParams(req, callbackURL)
if tc.expectValid {
require.NoError(t, err)
require.Empty(t, validationErrs)
} else {
require.Error(t, err)
require.Len(t, validationErrs, 1)
require.Equal(t, "code_challenge", validationErrs[0].Field)
}
})
}
}
// TestExtractAuthorizeParams_TokenResponseTypeDoesNotRequirePKCE ensures
// response_type=token is parsed without requiring PKCE fields so callers can
// return unsupported_response_type instead of invalid_request.