Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 1818844

Browse files
authored
fix: ignore unrecognized parameters at the token endpoint (#29139)
fix(oauth2): ignore unrecognized token request parameters Align `/oauth2/tokens` with OAuth 2.1 §3.2 and `/oauth2/authorize` by ignoring unknown parameters and logging only their names at debug level, never values. Continue rejecting duplicate known parameters, with tests covering all nine and shared unknown-parameter inputs across both endpoints. Misspelled optional parameters are now ignored rather than returning 400. Changes are limited to `coderd/oauth2provider`, with no new endpoints or schema changes. Closes https://linear.app/codercom/issue/PLAT-577
1 parent 716a3fd commit 1818844

6 files changed

Lines changed: 219 additions & 16 deletions

File tree

coderd/oauth2provider/authorize.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -389,7 +389,7 @@ func extractAuthorizeParams(r *http.Request, logger slog.Logger, app database.OA
389389
return params, nil
390390
}
391391

392-
// ignoredParams returns the query parameters this endpoint does not read,
392+
// ignoredParams returns the parameters the calling endpoint did not read,
393393
// sorted so the log line is stable. A misspelled parameter (redirect_url for
394394
// redirect_uri) surfaces here instead of in the client's error.
395395
func ignoredParams(p *httpapi.QueryParamParser, vals url.Values) []string {

coderd/oauth2provider/authorize_test.go

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -784,18 +784,13 @@ func TestOAuth2AuthorizeErrorsReachTheClient(t *testing.T) {
784784
t.Parallel()
785785

786786
app := seedAppInCatalog(t)
787-
unrecognized := func(q url.Values) {
788-
q.Set("nonce", "n-0S6_WzA2Mj")
789-
q.Set("prompt", "consent")
790-
q.Set(`we"ird`, "1")
791-
}
792787

793788
t.Run(http.MethodGet, func(t *testing.T) {
794789
t.Parallel()
795790
ctx := testutil.Context(t, testutil.WaitLong)
796791

797792
query := authorizeQuery(t, app.ID.String(), scopeInCatalog)
798-
unrecognized(query)
793+
addUnrecognizedParams(query)
799794

800795
resp := sendAuthorizeRequest(ctx, t, client, http.MethodGet, query)
801796
defer resp.Body.Close()
@@ -809,7 +804,7 @@ func TestOAuth2AuthorizeErrorsReachTheClient(t *testing.T) {
809804
ctx := testutil.Context(t, testutil.WaitLong)
810805

811806
query := authorizeQuery(t, app.ID.String(), scopeInCatalog)
812-
unrecognized(query)
807+
addUnrecognizedParams(query)
813808

814809
resp := sendAuthorizeRequest(ctx, t, client, http.MethodPost, query)
815810
defer resp.Body.Close()
@@ -937,6 +932,17 @@ func authorizeQuery(t *testing.T, clientID, scope string) url.Values {
937932
return query
938933
}
939934

935+
// addUnrecognizedParams adds parameters neither OAuth2 endpoint reads. Both
936+
// endpoints' tests use it, so the same extras are accepted at both. The quoted
937+
// key proves a name containing a double quote is accepted rather than 400'd;
938+
// TestExtractTokenRequest_UnrecognizedParametersLogged pins that the log sink
939+
// escapes it.
940+
func addUnrecognizedParams(q url.Values) {
941+
q.Set("nonce", "n-0S6_WzA2Mj")
942+
q.Set("prompt", "consent")
943+
q.Set(`we"ird`, "1")
944+
}
945+
940946
// authorizeRequest issues an /oauth2/authorize request without following
941947
// redirects, so a successful POST surfaces as a 302 carrying the code.
942948
func authorizeRequest(ctx context.Context, t *testing.T, client *codersdk.Client, method, clientID, scope string) *http.Response {

coderd/oauth2provider/tokens.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ func scopeStringToAPIKeyScopes(scope string) (database.APIKeyScopes, error) {
155155
// extractTokenRequest parses and validates the /oauth2/tokens form. It takes
156156
// the app because whether client_secret is required depends on the client
157157
// type.
158-
func extractTokenRequest(r *http.Request, primary *url.URL, alternates []*url.URL, app database.OAuth2ProviderApp) (codersdk.OAuth2TokenRequest, []codersdk.ValidationError, error) {
158+
func extractTokenRequest(r *http.Request, logger slog.Logger, primary *url.URL, alternates []*url.URL, app database.OAuth2ProviderApp) (codersdk.OAuth2TokenRequest, []codersdk.ValidationError, error) {
159159
p := httpapi.NewQueryParamParser()
160160
err := r.ParseForm()
161161
if err != nil {
@@ -239,7 +239,14 @@ func extractTokenRequest(r *http.Request, primary *url.URL, alternates []*url.UR
239239
})
240240
}
241241

242-
p.ErrorExcessParams(vals)
242+
// RFC 6749 §3.2 and OAuth 2.1 §3.2: unrecognized parameters MUST be ignored,
243+
// so a client_assertion or a DPoP parameter is not this endpoint's business.
244+
// Repeats of the parameters read above are still rejected, by parseSingle.
245+
if ignored := ignoredParams(p, vals); len(ignored) > 0 {
246+
logger.Debug(r.Context(), "ignoring unrecognized token parameters",
247+
slog.F("params", ignored))
248+
}
249+
243250
if len(p.Errors) > 0 {
244251
return codersdk.OAuth2TokenRequest{}, p.Errors, xerrors.Errorf("invalid query params: %w", p.Errors)
245252
}
@@ -271,7 +278,7 @@ func Tokens(db database.Store, lifetimes codersdk.SessionLifetime, logger slog.L
271278
return
272279
}
273280

274-
req, validationErrs, err := extractTokenRequest(r, primary, alternates, app)
281+
req, validationErrs, err := extractTokenRequest(r, logger, primary, alternates, app)
275282
if err != nil {
276283
if errors.Is(err, errConflictingClientAuth) {
277284
writeTokenError(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidRequest, "Conflicting client credentials between Authorization header and request body")

coderd/oauth2provider/tokens_internal_test.go

Lines changed: 80 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package oauth2provider
22

33
import (
4+
"bytes"
45
"database/sql"
6+
"encoding/json"
57
"net/http"
68
"net/url"
79
"slices"
@@ -12,6 +14,8 @@ import (
1214
"github.com/stretchr/testify/assert"
1315
"github.com/stretchr/testify/require"
1416

17+
"cdr.dev/slog/v3"
18+
"cdr.dev/slog/v3/sloggers/slogjson"
1519
"cdr.dev/slog/v3/sloggers/slogtest"
1620
"github.com/coder/coder/v2/coderd/database"
1721
"github.com/coder/coder/v2/coderd/rbac"
@@ -430,7 +434,7 @@ func TestExtractTokenParams_Scopes(t *testing.T) {
430434
}
431435

432436
// Extract token request
433-
tokenReq, validationErrs, err := extractTokenRequest(req, callbackURL, nil, confidentialApp)
437+
tokenReq, validationErrs, err := extractTokenRequest(req, slogtest.Make(t, nil), callbackURL, nil, confidentialApp)
434438

435439
// Verify no errors occurred
436440
require.NoError(t, err, "extractTokenRequest should not return error for: %s", tc.description)
@@ -493,7 +497,7 @@ func TestExtractTokenParams_ScopesURLEncoded(t *testing.T) {
493497
}
494498

495499
// Extract token request
496-
tokenReq, validationErrs, err := extractTokenRequest(req, callbackURL, nil, confidentialApp)
500+
tokenReq, validationErrs, err := extractTokenRequest(req, slogtest.Make(t, nil), callbackURL, nil, confidentialApp)
497501

498502
// Verify no errors
499503
require.NoError(t, err)
@@ -576,7 +580,7 @@ func TestExtractTokenParams_ScopesEdgeCases(t *testing.T) {
576580
Form: form,
577581
}
578582

579-
tokenReq, validationErrs, err := extractTokenRequest(req, callbackURL, nil, confidentialApp)
583+
tokenReq, validationErrs, err := extractTokenRequest(req, slogtest.Make(t, nil), callbackURL, nil, confidentialApp)
580584

581585
require.NoError(t, err, "extractTokenRequest should not error for: %s", tc.description)
582586
require.Empty(t, validationErrs)
@@ -816,7 +820,7 @@ func TestExtractTokenRequest_ClientSecretRequirement(t *testing.T) {
816820
Form: form,
817821
}
818822

819-
_, validationErrs, err := extractTokenRequest(req, callbackURL, nil, tc.app)
823+
_, validationErrs, err := extractTokenRequest(req, slogtest.Make(t, nil), callbackURL, nil, tc.app)
820824

821825
if tc.wantErrorField == "" {
822826
require.NoError(t, err)
@@ -832,6 +836,77 @@ func TestExtractTokenRequest_ClientSecretRequirement(t *testing.T) {
832836
}
833837
}
834838

839+
// The parameters most likely to arrive unrecognized here are credentials
840+
// (client_assertion, DPoP proofs), so the log carries names only. It also
841+
// fires when the request fails on a parameter the endpoint does read, so one
842+
// log stream shows both facts.
843+
func TestExtractTokenRequest_UnrecognizedParametersLogged(t *testing.T) {
844+
t.Parallel()
845+
846+
callbackURL, err := url.Parse("http://localhost:3000/callback")
847+
require.NoError(t, err)
848+
849+
cases := []struct {
850+
name string
851+
missingCode bool
852+
}{
853+
{name: "ValidRequest"},
854+
{name: "MissingCode", missingCode: true},
855+
}
856+
857+
for _, tc := range cases {
858+
t.Run(tc.name, func(t *testing.T) {
859+
t.Parallel()
860+
861+
form := url.Values{}
862+
form.Set("grant_type", "authorization_code")
863+
form.Set("client_id", "test-client")
864+
form.Set("client_secret", "test-secret")
865+
form.Set("code_verifier", strings.Repeat("a", pkceVerifierMinLength))
866+
if !tc.missingCode {
867+
form.Set("code", "test-code")
868+
}
869+
form.Set("nonce", "nonce-value")
870+
form.Set("audience", "audience-value")
871+
// A double quote in the name would break the line if the sink
872+
// trusted the raw string; Unmarshal below would then fail.
873+
form.Set(`we"ird`, "1")
874+
875+
req := &http.Request{
876+
Method: http.MethodPost,
877+
PostForm: form,
878+
Form: form,
879+
}
880+
881+
var logs bytes.Buffer
882+
logger := slog.Make(slogjson.Sink(&logs)).Leveled(slog.LevelDebug)
883+
_, validationErrs, err := extractTokenRequest(req, logger, callbackURL, nil, confidentialApp)
884+
if tc.missingCode {
885+
require.Error(t, err)
886+
require.True(t, slices.ContainsFunc(validationErrs, func(v codersdk.ValidationError) bool {
887+
return v.Field == "code"
888+
}), "expected a validation error for code, got: %+v", validationErrs)
889+
} else {
890+
require.NoError(t, err)
891+
require.Empty(t, validationErrs)
892+
}
893+
894+
// Unmarshal fails on more than one line, which pins the count.
895+
var entry struct {
896+
Msg string `json:"msg"`
897+
Fields struct {
898+
Params []string `json:"params"`
899+
} `json:"fields"`
900+
}
901+
require.NoError(t, json.Unmarshal(logs.Bytes(), &entry), logs.String())
902+
require.Equal(t, "ignoring unrecognized token parameters", entry.Msg)
903+
require.Equal(t, []string{"audience", "nonce", `we"ird`}, entry.Fields.Params)
904+
require.NotContains(t, logs.String(), "nonce-value")
905+
require.NotContains(t, logs.String(), "audience-value")
906+
})
907+
}
908+
}
909+
835910
// TestRefreshTokenGrant_Scopes tests that scopes can be requested during refresh
836911
func TestRefreshTokenGrant_Scopes(t *testing.T) {
837912
t.Parallel()
@@ -852,7 +927,7 @@ func TestRefreshTokenGrant_Scopes(t *testing.T) {
852927
Form: form,
853928
}
854929

855-
tokenReq, validationErrs, err := extractTokenRequest(req, callbackURL, nil, confidentialApp)
930+
tokenReq, validationErrs, err := extractTokenRequest(req, slogtest.Make(t, nil), callbackURL, nil, confidentialApp)
856931

857932
require.NoError(t, err)
858933
require.Empty(t, validationErrs)

coderd/oauth2provider/tokens_test.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -894,3 +894,107 @@ func TestOAuth2TokenExchangeLoopbackRedirectPort(t *testing.T) {
894894
status, body := postTokenRequest(ctx, t, client, form)
895895
requireTokenResponse(t, status, body)
896896
}
897+
898+
// OAuth 2.1 §3.2: the token endpoint must ignore parameters it does not
899+
// recognize. Uses the same set as the authorize endpoint's test.
900+
func TestOAuth2TokenUnrecognizedParametersIgnored(t *testing.T) {
901+
t.Parallel()
902+
903+
db, pubsub := dbtestutil.NewDB(t)
904+
client := coderdtest.New(t, &coderdtest.Options{
905+
Database: db,
906+
Pubsub: pubsub,
907+
})
908+
owner := coderdtest.CreateFirstUser(t, client)
909+
910+
t.Run("AuthorizationCode", func(t *testing.T) {
911+
t.Parallel()
912+
ctx := testutil.Context(t, testutil.WaitLong)
913+
914+
app := seedAppWithSecret(t, db, sql.NullString{})
915+
code, verifier := authorizeCode(ctx, t, client, app.ID.String(), "")
916+
form := tokenExchangeForm(app, code, verifier)
917+
addUnrecognizedParams(form)
918+
919+
status, body := postTokenRequest(ctx, t, client, form)
920+
token := requireTokenResponse(t, status, body)
921+
requireTokenAuthenticates(ctx, t, client, token.AccessToken)
922+
})
923+
924+
t.Run("RefreshToken", func(t *testing.T) {
925+
t.Parallel()
926+
ctx := testutil.Context(t, testutil.WaitLong)
927+
928+
app := seedAppWithSecret(t, db, sql.NullString{})
929+
refreshToken := seedRefreshToken(ctx, t, db, app, owner.UserID, string(database.ApiKeyScopeCoderAll))
930+
form := refreshForm(app, refreshToken)
931+
addUnrecognizedParams(form)
932+
933+
status, body := postTokenRequest(ctx, t, client, form)
934+
token := requireTokenResponse(t, status, body)
935+
requireTokenAuthenticates(ctx, t, client, token.AccessToken)
936+
})
937+
}
938+
939+
// OAuth 2.1 §3.2: a known parameter sent more than once is still rejected.
940+
// The expected error codes are the handler's current behavior, not what
941+
// RFC 6749 §5.2 asks for (a repeated grant_type answers unsupported_grant_type).
942+
func TestOAuth2TokenRepeatedParameterRejected(t *testing.T) {
943+
t.Parallel()
944+
945+
db, pubsub := dbtestutil.NewDB(t)
946+
client := coderdtest.New(t, &coderdtest.Options{
947+
Database: db,
948+
Pubsub: pubsub,
949+
})
950+
owner := coderdtest.CreateFirstUser(t, client)
951+
952+
cases := []struct {
953+
param string
954+
// first is the value a valid request would carry, used only when the
955+
// base form does not already set the parameter. The parser refuses
956+
// the repeat before any grant runs, so it only has to be well formed.
957+
first string
958+
grant codersdk.OAuth2ProviderGrantType
959+
wantCode codersdk.OAuth2ErrorCode
960+
}{
961+
{param: "grant_type", grant: codersdk.OAuth2ProviderGrantTypeAuthorizationCode, wantCode: codersdk.OAuth2ErrorCodeUnsupportedGrantType},
962+
{param: "code", grant: codersdk.OAuth2ProviderGrantTypeAuthorizationCode, wantCode: codersdk.OAuth2ErrorCodeInvalidRequest},
963+
{param: "client_id", grant: codersdk.OAuth2ProviderGrantTypeAuthorizationCode, wantCode: codersdk.OAuth2ErrorCodeInvalidRequest},
964+
{param: "client_secret", grant: codersdk.OAuth2ProviderGrantTypeAuthorizationCode, wantCode: codersdk.OAuth2ErrorCodeInvalidRequest},
965+
{param: "code_verifier", grant: codersdk.OAuth2ProviderGrantTypeAuthorizationCode, wantCode: codersdk.OAuth2ErrorCodeInvalidRequest},
966+
{param: "redirect_uri", first: appCallbackURL, grant: codersdk.OAuth2ProviderGrantTypeAuthorizationCode, wantCode: codersdk.OAuth2ErrorCodeInvalidRequest},
967+
{param: "resource", first: "https://api.example.com", grant: codersdk.OAuth2ProviderGrantTypeAuthorizationCode, wantCode: codersdk.OAuth2ErrorCodeInvalidRequest},
968+
{param: "scope", first: "workspace:ssh", grant: codersdk.OAuth2ProviderGrantTypeAuthorizationCode, wantCode: codersdk.OAuth2ErrorCodeInvalidRequest},
969+
{param: "refresh_token", grant: codersdk.OAuth2ProviderGrantTypeRefreshToken, wantCode: codersdk.OAuth2ErrorCodeInvalidRequest},
970+
}
971+
972+
for _, tc := range cases {
973+
t.Run(tc.param, func(t *testing.T) {
974+
t.Parallel()
975+
ctx := testutil.Context(t, testutil.WaitLong)
976+
977+
app := seedAppWithSecret(t, db, sql.NullString{})
978+
var form url.Values
979+
if tc.grant == codersdk.OAuth2ProviderGrantTypeRefreshToken {
980+
refreshToken := seedRefreshToken(ctx, t, db, app, owner.UserID, string(database.ApiKeyScopeCoderAll))
981+
form = refreshForm(app, refreshToken)
982+
} else {
983+
code, verifier := authorizeCode(ctx, t, client, app.ID.String(), "")
984+
form = tokenExchangeForm(app, code, verifier)
985+
}
986+
if !form.Has(tc.param) {
987+
form.Set(tc.param, tc.first)
988+
}
989+
form.Add(tc.param, "second")
990+
991+
status, body := postTokenRequest(ctx, t, client, form)
992+
require.Equal(t, http.StatusBadRequest, status, body)
993+
var oauthErr struct {
994+
Error string `json:"error"`
995+
}
996+
require.NoError(t, json.Unmarshal([]byte(body), &oauthErr))
997+
require.Equal(t, string(tc.wantCode), oauthErr.Error, body)
998+
})
999+
}
1000+
}

docs/admin/integrations/oauth2-provider.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -578,6 +578,17 @@ Two failures stay on Coder rather than reaching your callback, because in both c
578578
Earlier releases answered on Coder for all of these: `GET` rendered an "Invalid Query Parameters" page and `POST` returned a 400 with a JSON body.
579579
An integration that watched for either now has to read the error from its own callback.
580580

581+
### "invalid_request" from `POST /oauth2/tokens` for a repeated parameter
582+
583+
The token endpoint ignores parameters it does not read, as RFC 6749 Section 3.2 requires, so an OIDC `nonce`, a `client_assertion`, or a vendor extension does not fail the exchange.
584+
A misspelled parameter is ignored on the same rule, so what you see is the failure caused by the parameter you meant to send being absent.
585+
586+
A known parameter sent more than once is rejected with a 400 and a JSON body.
587+
The error is `invalid_request`, except for a repeated `grant_type`, which answers `unsupported_grant_type`.
588+
589+
Earlier releases returned 400 `invalid_request` for any parameter the endpoint did not recognize.
590+
An integration that relied on that error to catch a misspelled optional parameter no longer receives it.
591+
581592
### "invalid_target" for a rejected `resource`
582593

583594
`resource` must be an absolute URI without a fragment (RFC 8707).

0 commit comments

Comments
 (0)