mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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")
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user