refactor: add RFC-compliant enum types and use SDK as source of truth (#21468)

Add comprehensive OAuth2 enum types to codersdk following RFC specifications:
- OAuth2ProviderGrantType (RFC 6749)
- OAuth2ProviderResponseType (RFC 6749)
- OAuth2TokenEndpointAuthMethod (RFC 7591)
- OAuth2PKCECodeChallengeMethod (RFC 7636)
- OAuth2TokenType (RFC 6749, RFC 9449)
- OAuth2RevocationTokenTypeHint (RFC 7009)
- OAuth2ErrorCode (RFC 6749, RFC 7009, RFC 8707)

Add OAuth2TokenRequest, OAuth2TokenResponse, OAuth2TokenRevocationRequest,
and OAuth2Error structs to the SDK. Update OAuth2ClientRegistrationRequest,
OAuth2ClientRegistrationResponse, OAuth2ClientConfiguration, and
OAuth2AuthorizationServerMetadata to use typed enums instead of raw strings.

This makes codersdk the single source of truth for OAuth2 types, eliminating
duplication between SDK and server-side structs.

Closes #21476
This commit is contained in:
Ehab Younes
2026-01-15 12:41:28 +03:00
committed by GitHub
parent 7c2479ce92
commit 6683d807ac
25 changed files with 1091 additions and 532 deletions
+74 -20
View File
@@ -2628,7 +2628,8 @@ const docTemplate = `{
},
{
"enum": [
"code"
"code",
"token"
],
"type": "string",
"description": "Response type",
@@ -2683,7 +2684,8 @@ const docTemplate = `{
},
{
"enum": [
"code"
"code",
"token"
],
"type": "string",
"description": "Response type",
@@ -2914,7 +2916,10 @@ const docTemplate = `{
{
"enum": [
"authorization_code",
"refresh_token"
"refresh_token",
"password",
"client_credentials",
"implicit"
],
"type": "string",
"description": "Grant type",
@@ -15844,13 +15849,13 @@ const docTemplate = `{
"code_challenge_methods_supported": {
"type": "array",
"items": {
"type": "string"
"$ref": "#/definitions/codersdk.OAuth2PKCECodeChallengeMethod"
}
},
"grant_types_supported": {
"type": "array",
"items": {
"type": "string"
"$ref": "#/definitions/codersdk.OAuth2ProviderGrantType"
}
},
"issuer": {
@@ -15862,7 +15867,7 @@ const docTemplate = `{
"response_types_supported": {
"type": "array",
"items": {
"type": "string"
"$ref": "#/definitions/codersdk.OAuth2ProviderResponseType"
}
},
"revocation_endpoint": {
@@ -15880,7 +15885,7 @@ const docTemplate = `{
"token_endpoint_auth_methods_supported": {
"type": "array",
"items": {
"type": "string"
"$ref": "#/definitions/codersdk.OAuth2TokenEndpointAuthMethod"
}
}
}
@@ -15912,7 +15917,7 @@ const docTemplate = `{
"grant_types": {
"type": "array",
"items": {
"type": "string"
"$ref": "#/definitions/codersdk.OAuth2ProviderGrantType"
}
},
"jwks": {
@@ -15934,10 +15939,7 @@ const docTemplate = `{
}
},
"registration_access_token": {
"type": "array",
"items": {
"type": "integer"
}
"type": "string"
},
"registration_client_uri": {
"type": "string"
@@ -15945,7 +15947,7 @@ const docTemplate = `{
"response_types": {
"type": "array",
"items": {
"type": "string"
"$ref": "#/definitions/codersdk.OAuth2ProviderResponseType"
}
},
"scope": {
@@ -15958,7 +15960,7 @@ const docTemplate = `{
"type": "string"
},
"token_endpoint_auth_method": {
"type": "string"
"$ref": "#/definitions/codersdk.OAuth2TokenEndpointAuthMethod"
},
"tos_uri": {
"type": "string"
@@ -15983,7 +15985,7 @@ const docTemplate = `{
"grant_types": {
"type": "array",
"items": {
"type": "string"
"$ref": "#/definitions/codersdk.OAuth2ProviderGrantType"
}
},
"jwks": {
@@ -16007,7 +16009,7 @@ const docTemplate = `{
"response_types": {
"type": "array",
"items": {
"type": "string"
"$ref": "#/definitions/codersdk.OAuth2ProviderResponseType"
}
},
"scope": {
@@ -16023,7 +16025,7 @@ const docTemplate = `{
"type": "string"
},
"token_endpoint_auth_method": {
"type": "string"
"$ref": "#/definitions/codersdk.OAuth2TokenEndpointAuthMethod"
},
"tos_uri": {
"type": "string"
@@ -16060,7 +16062,7 @@ const docTemplate = `{
"grant_types": {
"type": "array",
"items": {
"type": "string"
"$ref": "#/definitions/codersdk.OAuth2ProviderGrantType"
}
},
"jwks": {
@@ -16090,7 +16092,7 @@ const docTemplate = `{
"response_types": {
"type": "array",
"items": {
"type": "string"
"$ref": "#/definitions/codersdk.OAuth2ProviderResponseType"
}
},
"scope": {
@@ -16103,7 +16105,7 @@ const docTemplate = `{
"type": "string"
},
"token_endpoint_auth_method": {
"type": "string"
"$ref": "#/definitions/codersdk.OAuth2TokenEndpointAuthMethod"
},
"tos_uri": {
"type": "string"
@@ -16156,6 +16158,17 @@ const docTemplate = `{
}
}
},
"codersdk.OAuth2PKCECodeChallengeMethod": {
"type": "string",
"enum": [
"S256",
"plain"
],
"x-enum-varnames": [
"OAuth2PKCECodeChallengeMethodS256",
"OAuth2PKCECodeChallengeMethodPlain"
]
},
"codersdk.OAuth2ProtectedResourceMetadata": {
"type": "object",
"properties": {
@@ -16235,6 +16248,47 @@ const docTemplate = `{
}
}
},
"codersdk.OAuth2ProviderGrantType": {
"type": "string",
"enum": [
"authorization_code",
"refresh_token",
"password",
"client_credentials",
"implicit"
],
"x-enum-varnames": [
"OAuth2ProviderGrantTypeAuthorizationCode",
"OAuth2ProviderGrantTypeRefreshToken",
"OAuth2ProviderGrantTypePassword",
"OAuth2ProviderGrantTypeClientCredentials",
"OAuth2ProviderGrantTypeImplicit"
]
},
"codersdk.OAuth2ProviderResponseType": {
"type": "string",
"enum": [
"code",
"token"
],
"x-enum-varnames": [
"OAuth2ProviderResponseTypeCode",
"OAuth2ProviderResponseTypeToken"
]
},
"codersdk.OAuth2TokenEndpointAuthMethod": {
"type": "string",
"enum": [
"client_secret_basic",
"client_secret_post",
"none"
],
"x-enum-varnames": [
"OAuth2TokenEndpointAuthMethodClientSecretBasic",
"OAuth2TokenEndpointAuthMethodClientSecretPost",
"OAuth2TokenEndpointAuthMethodNone"
]
},
"codersdk.OAuthConversionResponse": {
"type": "object",
"properties": {
+65 -20
View File
@@ -2304,7 +2304,7 @@
"required": true
},
{
"enum": ["code"],
"enum": ["code", "token"],
"type": "string",
"description": "Response type",
"name": "response_type",
@@ -2355,7 +2355,7 @@
"required": true
},
{
"enum": ["code"],
"enum": ["code", "token"],
"type": "string",
"description": "Response type",
"name": "response_type",
@@ -2555,7 +2555,13 @@
"in": "formData"
},
{
"enum": ["authorization_code", "refresh_token"],
"enum": [
"authorization_code",
"refresh_token",
"password",
"client_credentials",
"implicit"
],
"type": "string",
"description": "Grant type",
"name": "grant_type",
@@ -14353,13 +14359,13 @@
"code_challenge_methods_supported": {
"type": "array",
"items": {
"type": "string"
"$ref": "#/definitions/codersdk.OAuth2PKCECodeChallengeMethod"
}
},
"grant_types_supported": {
"type": "array",
"items": {
"type": "string"
"$ref": "#/definitions/codersdk.OAuth2ProviderGrantType"
}
},
"issuer": {
@@ -14371,7 +14377,7 @@
"response_types_supported": {
"type": "array",
"items": {
"type": "string"
"$ref": "#/definitions/codersdk.OAuth2ProviderResponseType"
}
},
"revocation_endpoint": {
@@ -14389,7 +14395,7 @@
"token_endpoint_auth_methods_supported": {
"type": "array",
"items": {
"type": "string"
"$ref": "#/definitions/codersdk.OAuth2TokenEndpointAuthMethod"
}
}
}
@@ -14421,7 +14427,7 @@
"grant_types": {
"type": "array",
"items": {
"type": "string"
"$ref": "#/definitions/codersdk.OAuth2ProviderGrantType"
}
},
"jwks": {
@@ -14443,10 +14449,7 @@
}
},
"registration_access_token": {
"type": "array",
"items": {
"type": "integer"
}
"type": "string"
},
"registration_client_uri": {
"type": "string"
@@ -14454,7 +14457,7 @@
"response_types": {
"type": "array",
"items": {
"type": "string"
"$ref": "#/definitions/codersdk.OAuth2ProviderResponseType"
}
},
"scope": {
@@ -14467,7 +14470,7 @@
"type": "string"
},
"token_endpoint_auth_method": {
"type": "string"
"$ref": "#/definitions/codersdk.OAuth2TokenEndpointAuthMethod"
},
"tos_uri": {
"type": "string"
@@ -14492,7 +14495,7 @@
"grant_types": {
"type": "array",
"items": {
"type": "string"
"$ref": "#/definitions/codersdk.OAuth2ProviderGrantType"
}
},
"jwks": {
@@ -14516,7 +14519,7 @@
"response_types": {
"type": "array",
"items": {
"type": "string"
"$ref": "#/definitions/codersdk.OAuth2ProviderResponseType"
}
},
"scope": {
@@ -14532,7 +14535,7 @@
"type": "string"
},
"token_endpoint_auth_method": {
"type": "string"
"$ref": "#/definitions/codersdk.OAuth2TokenEndpointAuthMethod"
},
"tos_uri": {
"type": "string"
@@ -14569,7 +14572,7 @@
"grant_types": {
"type": "array",
"items": {
"type": "string"
"$ref": "#/definitions/codersdk.OAuth2ProviderGrantType"
}
},
"jwks": {
@@ -14599,7 +14602,7 @@
"response_types": {
"type": "array",
"items": {
"type": "string"
"$ref": "#/definitions/codersdk.OAuth2ProviderResponseType"
}
},
"scope": {
@@ -14612,7 +14615,7 @@
"type": "string"
},
"token_endpoint_auth_method": {
"type": "string"
"$ref": "#/definitions/codersdk.OAuth2TokenEndpointAuthMethod"
},
"tos_uri": {
"type": "string"
@@ -14665,6 +14668,14 @@
}
}
},
"codersdk.OAuth2PKCECodeChallengeMethod": {
"type": "string",
"enum": ["S256", "plain"],
"x-enum-varnames": [
"OAuth2PKCECodeChallengeMethodS256",
"OAuth2PKCECodeChallengeMethodPlain"
]
},
"codersdk.OAuth2ProtectedResourceMetadata": {
"type": "object",
"properties": {
@@ -14744,6 +14755,40 @@
}
}
},
"codersdk.OAuth2ProviderGrantType": {
"type": "string",
"enum": [
"authorization_code",
"refresh_token",
"password",
"client_credentials",
"implicit"
],
"x-enum-varnames": [
"OAuth2ProviderGrantTypeAuthorizationCode",
"OAuth2ProviderGrantTypeRefreshToken",
"OAuth2ProviderGrantTypePassword",
"OAuth2ProviderGrantTypeClientCredentials",
"OAuth2ProviderGrantTypeImplicit"
]
},
"codersdk.OAuth2ProviderResponseType": {
"type": "string",
"enum": ["code", "token"],
"x-enum-varnames": [
"OAuth2ProviderResponseTypeCode",
"OAuth2ProviderResponseTypeToken"
]
},
"codersdk.OAuth2TokenEndpointAuthMethod": {
"type": "string",
"enum": ["client_secret_basic", "client_secret_post", "none"],
"x-enum-varnames": [
"OAuth2TokenEndpointAuthMethodClientSecretBasic",
"OAuth2TokenEndpointAuthMethodClientSecretPost",
"OAuth2TokenEndpointAuthMethodNone"
]
},
"codersdk.OAuthConversionResponse": {
"type": "object",
"properties": {
+2 -8
View File
@@ -493,16 +493,10 @@ func OneWayWebSocketEventSender(rw http.ResponseWriter, r *http.Request) (
return sendEvent, closed, nil
}
// OAuth2Error represents an OAuth2-compliant error response per RFC 6749.
type OAuth2Error struct {
Error string `json:"error"`
ErrorDescription string `json:"error_description,omitempty"`
}
// WriteOAuth2Error writes an OAuth2-compliant error response per RFC 6749.
// This should be used for all OAuth2 endpoints (/oauth2/*) to ensure compliance.
func WriteOAuth2Error(ctx context.Context, rw http.ResponseWriter, status int, errorCode, description string) {
Write(ctx, rw, status, OAuth2Error{
func WriteOAuth2Error(ctx context.Context, rw http.ResponseWriter, status int, errorCode codersdk.OAuth2ErrorCode, description string) {
Write(ctx, rw, status, codersdk.OAuth2Error{
Error: errorCode,
ErrorDescription: description,
})
+3 -3
View File
@@ -290,15 +290,15 @@ func (*codersdkErrorWriter) writeClientNotFound(ctx context.Context, rw http.Res
type oauth2ErrorWriter struct{}
func (*oauth2ErrorWriter) writeMissingClientID(ctx context.Context, rw http.ResponseWriter) {
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, "invalid_request", "Missing client_id parameter")
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidRequest, "Missing client_id parameter")
}
func (*oauth2ErrorWriter) writeInvalidClientID(ctx context.Context, rw http.ResponseWriter, _ error) {
httpapi.WriteOAuth2Error(ctx, rw, http.StatusUnauthorized, "invalid_client", "The client credentials are invalid")
httpapi.WriteOAuth2Error(ctx, rw, http.StatusUnauthorized, codersdk.OAuth2ErrorCodeInvalidClient, "The client credentials are invalid")
}
func (*oauth2ErrorWriter) writeClientNotFound(ctx context.Context, rw http.ResponseWriter) {
httpapi.WriteOAuth2Error(ctx, rw, http.StatusUnauthorized, "invalid_client", "The client credentials are invalid")
httpapi.WriteOAuth2Error(ctx, rw, http.StatusUnauthorized, codersdk.OAuth2ErrorCodeInvalidClient, "The client credentials are invalid")
}
// extractOAuth2ProviderAppBase is the internal implementation that uses the strategy pattern
+2 -2
View File
@@ -99,7 +99,7 @@ func TestOAuth2RegistrationErrorCodes(t *testing.T) {
req: codersdk.OAuth2ClientRegistrationRequest{
RedirectURIs: []string{"https://example.com/callback"},
ClientName: fmt.Sprintf("test-client-%d", time.Now().UnixNano()),
GrantTypes: []string{"unsupported_grant_type"},
GrantTypes: []codersdk.OAuth2ProviderGrantType{"unsupported_grant_type"},
},
expectedError: "invalid_client_metadata",
expectedCode: http.StatusBadRequest,
@@ -109,7 +109,7 @@ func TestOAuth2RegistrationErrorCodes(t *testing.T) {
req: codersdk.OAuth2ClientRegistrationRequest{
RedirectURIs: []string{"https://example.com/callback"},
ClientName: fmt.Sprintf("test-client-%d", time.Now().UnixNano()),
ResponseTypes: []string{"unsupported_response_type"},
ResponseTypes: []codersdk.OAuth2ProviderResponseType{"unsupported_response_type"},
},
expectedError: "invalid_client_metadata",
expectedCode: http.StatusBadRequest,
+4 -4
View File
@@ -44,10 +44,10 @@ func TestOAuth2AuthorizationServerMetadata(t *testing.T) {
require.NotEmpty(t, metadata.Issuer)
require.NotEmpty(t, metadata.AuthorizationEndpoint)
require.NotEmpty(t, metadata.TokenEndpoint)
require.Contains(t, metadata.ResponseTypesSupported, "code")
require.Contains(t, metadata.GrantTypesSupported, "authorization_code")
require.Contains(t, metadata.GrantTypesSupported, "refresh_token")
require.Contains(t, metadata.CodeChallengeMethodsSupported, "S256")
require.Contains(t, metadata.ResponseTypesSupported, codersdk.OAuth2ProviderResponseTypeCode)
require.Contains(t, metadata.GrantTypesSupported, codersdk.OAuth2ProviderGrantTypeAuthorizationCode)
require.Contains(t, metadata.GrantTypesSupported, codersdk.OAuth2ProviderGrantTypeRefreshToken)
require.Contains(t, metadata.CodeChallengeMethodsSupported, codersdk.OAuth2PKCECodeChallengeMethodS256)
// Supported scopes are published from the curated catalog
require.Equal(t, rbac.ExternalScopeNames(), metadata.ScopesSupported)
}
+20 -20
View File
@@ -277,47 +277,47 @@ func TestOAuth2ClientMetadataValidation(t *testing.T) {
tests := []struct {
name string
grantTypes []string
grantTypes []codersdk.OAuth2ProviderGrantType
expectError bool
}{
{
name: "DefaultEmpty",
grantTypes: []string{},
grantTypes: []codersdk.OAuth2ProviderGrantType{},
expectError: false,
},
{
name: "ValidAuthorizationCode",
grantTypes: []string{"authorization_code"},
grantTypes: []codersdk.OAuth2ProviderGrantType{"authorization_code"},
expectError: false,
},
{
name: "InvalidRefreshTokenAlone",
grantTypes: []string{"refresh_token"},
grantTypes: []codersdk.OAuth2ProviderGrantType{"refresh_token"},
expectError: true, // refresh_token requires authorization_code to be present
},
{
name: "ValidMultiple",
grantTypes: []string{"authorization_code", "refresh_token"},
grantTypes: []codersdk.OAuth2ProviderGrantType{"authorization_code", "refresh_token"},
expectError: false,
},
{
name: "InvalidUnsupported",
grantTypes: []string{"client_credentials"},
grantTypes: []codersdk.OAuth2ProviderGrantType{"client_credentials"},
expectError: true,
},
{
name: "InvalidPassword",
grantTypes: []string{"password"},
grantTypes: []codersdk.OAuth2ProviderGrantType{"password"},
expectError: true,
},
{
name: "InvalidImplicit",
grantTypes: []string{"implicit"},
grantTypes: []codersdk.OAuth2ProviderGrantType{"implicit"},
expectError: true,
},
{
name: "MixedValidInvalid",
grantTypes: []string{"authorization_code", "client_credentials"},
grantTypes: []codersdk.OAuth2ProviderGrantType{"authorization_code", "client_credentials"},
expectError: true,
},
}
@@ -352,32 +352,32 @@ func TestOAuth2ClientMetadataValidation(t *testing.T) {
tests := []struct {
name string
responseTypes []string
responseTypes []codersdk.OAuth2ProviderResponseType
expectError bool
}{
{
name: "DefaultEmpty",
responseTypes: []string{},
responseTypes: []codersdk.OAuth2ProviderResponseType{},
expectError: false,
},
{
name: "ValidCode",
responseTypes: []string{"code"},
responseTypes: []codersdk.OAuth2ProviderResponseType{"code"},
expectError: false,
},
{
name: "InvalidToken",
responseTypes: []string{"token"},
responseTypes: []codersdk.OAuth2ProviderResponseType{"token"},
expectError: true,
},
{
name: "InvalidImplicit",
responseTypes: []string{"id_token"},
responseTypes: []codersdk.OAuth2ProviderResponseType{"id_token"},
expectError: true,
},
{
name: "InvalidMultiple",
responseTypes: []string{"code", "token"},
responseTypes: []codersdk.OAuth2ProviderResponseType{"code", "token"},
expectError: true,
},
}
@@ -412,7 +412,7 @@ func TestOAuth2ClientMetadataValidation(t *testing.T) {
tests := []struct {
name string
authMethod string
authMethod codersdk.OAuth2TokenEndpointAuthMethod
expectError bool
}{
{
@@ -659,14 +659,14 @@ func TestOAuth2ClientMetadataDefaults(t *testing.T) {
require.NoError(t, err)
// Should default to authorization_code
require.Contains(t, config.GrantTypes, "authorization_code")
require.Contains(t, config.GrantTypes, codersdk.OAuth2ProviderGrantTypeAuthorizationCode)
// Should default to code
require.Contains(t, config.ResponseTypes, "code")
require.Contains(t, config.ResponseTypes, codersdk.OAuth2ProviderResponseTypeCode)
// Should default to client_secret_basic or client_secret_post
require.True(t, config.TokenEndpointAuthMethod == "client_secret_basic" ||
config.TokenEndpointAuthMethod == "client_secret_post" ||
require.True(t, config.TokenEndpointAuthMethod == codersdk.OAuth2TokenEndpointAuthMethodClientSecretBasic ||
config.TokenEndpointAuthMethod == codersdk.OAuth2TokenEndpointAuthMethodClientSecretPost ||
config.TokenEndpointAuthMethod == "")
// Client secret should be generated
+7 -7
View File
@@ -1329,10 +1329,10 @@ func TestOAuth2DynamicClientRegistration(t *testing.T) {
require.Equal(t, int64(0), resp.ClientSecretExpiresAt) // Non-expiring
// Verify default values
require.Contains(t, resp.GrantTypes, "authorization_code")
require.Contains(t, resp.GrantTypes, "refresh_token")
require.Contains(t, resp.ResponseTypes, "code")
require.Equal(t, "client_secret_basic", resp.TokenEndpointAuthMethod)
require.Contains(t, resp.GrantTypes, codersdk.OAuth2ProviderGrantTypeAuthorizationCode)
require.Contains(t, resp.GrantTypes, codersdk.OAuth2ProviderGrantTypeRefreshToken)
require.Contains(t, resp.ResponseTypes, codersdk.OAuth2ProviderResponseTypeCode)
require.Equal(t, codersdk.OAuth2TokenEndpointAuthMethodClientSecretBasic, resp.TokenEndpointAuthMethod)
// Verify request values are preserved
require.Equal(t, req.RedirectURIs, resp.RedirectURIs)
@@ -1363,9 +1363,9 @@ func TestOAuth2DynamicClientRegistration(t *testing.T) {
require.NotEmpty(t, resp.RegistrationClientURI)
// Should have defaults applied
require.Contains(t, resp.GrantTypes, "authorization_code")
require.Contains(t, resp.ResponseTypes, "code")
require.Equal(t, "client_secret_basic", resp.TokenEndpointAuthMethod)
require.Contains(t, resp.GrantTypes, codersdk.OAuth2ProviderGrantTypeAuthorizationCode)
require.Contains(t, resp.ResponseTypes, codersdk.OAuth2ProviderResponseTypeCode)
require.Equal(t, codersdk.OAuth2TokenEndpointAuthMethodClientSecretBasic, resp.TokenEndpointAuthMethod)
})
t.Run("InvalidRedirectURI", func(t *testing.T) {
+7 -7
View File
@@ -137,13 +137,13 @@ func ProcessAuthorize(db database.Store) http.HandlerFunc {
callbackURL, err := url.Parse(app.CallbackURL)
if err != nil {
httpapi.WriteOAuth2Error(r.Context(), rw, http.StatusInternalServerError, "server_error", "Failed to validate query parameters")
httpapi.WriteOAuth2Error(r.Context(), rw, http.StatusInternalServerError, codersdk.OAuth2ErrorCodeServerError, "Failed to validate query parameters")
return
}
params, _, err := extractAuthorizeParams(r, callbackURL)
if err != nil {
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, "invalid_request", err.Error())
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidRequest, err.Error())
return
}
@@ -151,10 +151,10 @@ func ProcessAuthorize(db database.Store) http.HandlerFunc {
if params.codeChallenge != "" {
// If code_challenge is provided but method is not, default to S256
if params.codeChallengeMethod == "" {
params.codeChallengeMethod = "S256"
params.codeChallengeMethod = string(codersdk.OAuth2PKCECodeChallengeMethodS256)
}
if params.codeChallengeMethod != "S256" {
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, "invalid_request", "Invalid code_challenge_method: only S256 is supported")
if err := codersdk.ValidatePKCECodeChallengeMethod(params.codeChallengeMethod); err != nil {
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidRequest, err.Error())
return
}
}
@@ -162,7 +162,7 @@ func ProcessAuthorize(db database.Store) http.HandlerFunc {
// TODO: Ignoring scope for now, but should look into implementing.
code, err := GenerateSecret()
if err != nil {
httpapi.WriteOAuth2Error(r.Context(), rw, http.StatusInternalServerError, "server_error", "Failed to generate OAuth2 app authorization code")
httpapi.WriteOAuth2Error(r.Context(), rw, http.StatusInternalServerError, codersdk.OAuth2ErrorCodeServerError, "Failed to generate OAuth2 app authorization code")
return
}
err = db.InTx(func(tx database.Store) error {
@@ -202,7 +202,7 @@ func ProcessAuthorize(db database.Store) http.HandlerFunc {
return nil
}, nil)
if err != nil {
httpapi.WriteOAuth2Error(ctx, rw, http.StatusInternalServerError, "server_error", "Failed to generate OAuth2 authorization code")
httpapi.WriteOAuth2Error(ctx, rw, http.StatusInternalServerError, codersdk.OAuth2ErrorCodeServerError, "Failed to generate OAuth2 authorization code")
return
}
+4 -4
View File
@@ -19,11 +19,11 @@ func GetAuthorizationServerMetadata(accessURL *url.URL) http.HandlerFunc {
TokenEndpoint: accessURL.JoinPath("/oauth2/tokens").String(),
RegistrationEndpoint: accessURL.JoinPath("/oauth2/register").String(), // RFC 7591
RevocationEndpoint: accessURL.JoinPath("/oauth2/revoke").String(), // RFC 7009
ResponseTypesSupported: []string{"code"},
GrantTypesSupported: []string{"authorization_code", "refresh_token"},
CodeChallengeMethodsSupported: []string{"S256"},
ResponseTypesSupported: []codersdk.OAuth2ProviderResponseType{codersdk.OAuth2ProviderResponseTypeCode},
GrantTypesSupported: []codersdk.OAuth2ProviderGrantType{codersdk.OAuth2ProviderGrantTypeAuthorizationCode, codersdk.OAuth2ProviderGrantTypeRefreshToken},
CodeChallengeMethodsSupported: []codersdk.OAuth2PKCECodeChallengeMethod{codersdk.OAuth2PKCECodeChallengeMethodS256},
ScopesSupported: rbac.ExternalScopeNames(),
TokenEndpointAuthMethodsSupported: []string{"client_secret_post"},
TokenEndpointAuthMethodsSupported: []codersdk.OAuth2TokenEndpointAuthMethod{codersdk.OAuth2TokenEndpointAuthMethodClientSecretPost},
}
httpapi.Write(ctx, rw, http.StatusOK, metadata)
}
+4 -4
View File
@@ -32,10 +32,10 @@ func TestOAuth2AuthorizationServerMetadata(t *testing.T) {
require.NotEmpty(t, metadata.Issuer)
require.NotEmpty(t, metadata.AuthorizationEndpoint)
require.NotEmpty(t, metadata.TokenEndpoint)
require.Contains(t, metadata.ResponseTypesSupported, "code")
require.Contains(t, metadata.GrantTypesSupported, "authorization_code")
require.Contains(t, metadata.GrantTypesSupported, "refresh_token")
require.Contains(t, metadata.CodeChallengeMethodsSupported, "S256")
require.Contains(t, metadata.ResponseTypesSupported, codersdk.OAuth2ProviderResponseTypeCode)
require.Contains(t, metadata.GrantTypesSupported, codersdk.OAuth2ProviderGrantTypeAuthorizationCode)
require.Contains(t, metadata.GrantTypesSupported, codersdk.OAuth2ProviderGrantTypeRefreshToken)
require.Contains(t, metadata.CodeChallengeMethodsSupported, codersdk.OAuth2PKCECodeChallengeMethodS256)
// Supported scopes are published from the curated catalog
require.Equal(t, rbac.ExternalScopeNames(), metadata.ScopesSupported)
}
@@ -105,8 +105,9 @@ func GenerateState(t *testing.T) string {
return base64.RawURLEncoding.EncodeToString(bytes)
}
// AuthorizeOAuth2App performs the OAuth2 authorization flow and returns the authorization code
func AuthorizeOAuth2App(t *testing.T, client *codersdk.Client, baseURL string, params AuthorizeParams) string {
// doAuthorizeRequest performs the OAuth2 authorization request and returns the response.
// Caller is responsible for closing the response body.
func doAuthorizeRequest(t *testing.T, client *codersdk.Client, baseURL string, params AuthorizeParams) *http.Response {
t.Helper()
ctx := testutil.Context(t, testutil.WaitLong)
@@ -123,6 +124,8 @@ func AuthorizeOAuth2App(t *testing.T, client *codersdk.Client, baseURL string, p
if params.CodeChallenge != "" {
query.Set("code_challenge", params.CodeChallenge)
}
if params.CodeChallengeMethod != "" {
query.Set("code_challenge_method", params.CodeChallengeMethod)
}
if params.Resource != "" {
@@ -151,6 +154,15 @@ func AuthorizeOAuth2App(t *testing.T, client *codersdk.Client, baseURL string, p
resp, err := httpClient.Do(req)
require.NoError(t, err, "failed to perform authorization request")
return resp
}
// AuthorizeOAuth2App performs the OAuth2 authorization flow and returns the authorization code
func AuthorizeOAuth2App(t *testing.T, client *codersdk.Client, baseURL string, params AuthorizeParams) string {
t.Helper()
resp := doAuthorizeRequest(t, client, baseURL, params)
defer resp.Body.Close()
// Should get a redirect response (either 302 Found or 307 Temporary Redirect)
@@ -326,3 +338,13 @@ func CleanupOAuth2App(t *testing.T, client *codersdk.Client, appID uuid.UUID) {
t.Logf("Warning: failed to cleanup OAuth2 app %s: %v", appID, err)
}
}
// AuthorizeOAuth2AppExpectingError performs the OAuth2 authorization flow expecting an error
func AuthorizeOAuth2AppExpectingError(t *testing.T, client *codersdk.Client, baseURL string, params AuthorizeParams, expectedStatusCode int) {
t.Helper()
resp := doAuthorizeRequest(t, client, baseURL, params)
defer resp.Body.Close()
require.Equal(t, expectedStatusCode, resp.StatusCode, "unexpected status code")
}
@@ -7,6 +7,7 @@ import (
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/oauth2provider/oauth2providertest"
"github.com/coder/coder/v2/codersdk"
)
func TestOAuth2AuthorizationServerMetadata(t *testing.T) {
@@ -185,6 +186,38 @@ func TestOAuth2WithoutPKCE(t *testing.T) {
require.NotEmpty(t, token.RefreshToken, "should receive refresh token")
}
func TestOAuth2PKCEPlainMethodRejected(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: false,
})
_ = coderdtest.CreateFirstUser(t, client)
// Create OAuth2 app
app, _ := oauth2providertest.CreateTestOAuth2App(t, client)
t.Cleanup(func() {
oauth2providertest.CleanupOAuth2App(t, client, app.ID)
})
// Generate PKCE parameters but use "plain" method (should be rejected)
_, codeChallenge := oauth2providertest.GeneratePKCE(t)
state := oauth2providertest.GenerateState(t)
// Attempt authorization with plain method - should fail
authParams := oauth2providertest.AuthorizeParams{
ClientID: app.ID.String(),
ResponseType: string(codersdk.OAuth2ProviderResponseTypeCode),
RedirectURI: oauth2providertest.TestRedirectURI,
State: state,
CodeChallenge: codeChallenge,
CodeChallengeMethod: string(codersdk.OAuth2PKCECodeChallengeMethodPlain),
}
// Should get a 400 Bad Request
oauth2providertest.AuthorizeOAuth2AppExpectingError(t, client, client.URL.String(), authParams, 400)
}
func TestOAuth2ResourceParameter(t *testing.T) {
t.Parallel()
+50
View File
@@ -8,6 +8,7 @@ import (
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/oauth2provider"
"github.com/coder/coder/v2/codersdk"
)
func TestVerifyPKCE(t *testing.T) {
@@ -75,3 +76,52 @@ func TestPKCES256Generation(t *testing.T) {
require.Equal(t, expectedChallenge, challenge)
require.True(t, oauth2provider.VerifyPKCE(challenge, verifier))
}
func TestValidatePKCECodeChallengeMethod(t *testing.T) {
t.Parallel()
tests := []struct {
name string
method string
expectError bool
errorContains string
}{
{
name: "EmptyIsValid",
method: "",
expectError: false,
},
{
name: "S256IsValid",
method: string(codersdk.OAuth2PKCECodeChallengeMethodS256),
expectError: false,
},
{
name: "PlainIsRejected",
method: string(codersdk.OAuth2PKCECodeChallengeMethodPlain),
expectError: true,
errorContains: "plain",
},
{
name: "UnknownIsRejected",
method: "unknown_method",
expectError: true,
errorContains: "unsupported",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := codersdk.ValidatePKCECodeChallengeMethod(tt.method)
if tt.expectError {
require.Error(t, err)
if tt.errorContains != "" {
require.Contains(t, err.Error(), tt.errorContains)
}
} else {
require.NoError(t, err)
}
})
}
}
+4 -4
View File
@@ -248,7 +248,7 @@ func TestOAuth2ClientRegistrationValidation(t *testing.T) {
req := codersdk.OAuth2ClientRegistrationRequest{
RedirectURIs: []string{"https://example.com/callback"},
ClientName: fmt.Sprintf("valid-grant-types-client-%d", time.Now().UnixNano()),
GrantTypes: []string{"authorization_code", "refresh_token"},
GrantTypes: []codersdk.OAuth2ProviderGrantType{codersdk.OAuth2ProviderGrantTypeAuthorizationCode, codersdk.OAuth2ProviderGrantTypeRefreshToken},
}
resp, err := client.PostOAuth2ClientRegistration(ctx, req)
@@ -266,7 +266,7 @@ func TestOAuth2ClientRegistrationValidation(t *testing.T) {
req := codersdk.OAuth2ClientRegistrationRequest{
RedirectURIs: []string{"https://example.com/callback"},
ClientName: fmt.Sprintf("invalid-grant-types-client-%d", time.Now().UnixNano()),
GrantTypes: []string{"unsupported_grant"},
GrantTypes: []codersdk.OAuth2ProviderGrantType{"unsupported_grant"},
}
_, err := client.PostOAuth2ClientRegistration(ctx, req)
@@ -284,7 +284,7 @@ func TestOAuth2ClientRegistrationValidation(t *testing.T) {
req := codersdk.OAuth2ClientRegistrationRequest{
RedirectURIs: []string{"https://example.com/callback"},
ClientName: fmt.Sprintf("valid-response-types-client-%d", time.Now().UnixNano()),
ResponseTypes: []string{"code"},
ResponseTypes: []codersdk.OAuth2ProviderResponseType{codersdk.OAuth2ProviderResponseTypeCode},
}
resp, err := client.PostOAuth2ClientRegistration(ctx, req)
@@ -302,7 +302,7 @@ func TestOAuth2ClientRegistrationValidation(t *testing.T) {
req := codersdk.OAuth2ClientRegistrationRequest{
RedirectURIs: []string{"https://example.com/callback"},
ClientName: fmt.Sprintf("invalid-response-types-client-%d", time.Now().UnixNano()),
ResponseTypes: []string{"token"}, // Not supported
ResponseTypes: []codersdk.OAuth2ProviderResponseType{"token"}, // Not supported
}
_, err := client.PostOAuth2ClientRegistration(ctx, req)
+18 -17
View File
@@ -21,6 +21,7 @@ import (
"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/util/slice"
"github.com/coder/coder/v2/codersdk"
)
@@ -85,9 +86,9 @@ func CreateDynamicClientRegistration(db database.Store, accessURL *url.URL, audi
DynamicallyRegistered: sql.NullBool{Bool: true, Valid: true},
ClientIDIssuedAt: sql.NullTime{Time: now, Valid: true},
ClientSecretExpiresAt: sql.NullTime{}, // No expiration for now
GrantTypes: req.GrantTypes,
ResponseTypes: req.ResponseTypes,
TokenEndpointAuthMethod: sql.NullString{String: req.TokenEndpointAuthMethod, Valid: true},
GrantTypes: slice.ToStrings(req.GrantTypes),
ResponseTypes: slice.ToStrings(req.ResponseTypes),
TokenEndpointAuthMethod: sql.NullString{String: string(req.TokenEndpointAuthMethod), Valid: true},
Scope: sql.NullString{String: req.Scope, Valid: true},
Contacts: req.Contacts,
ClientUri: sql.NullString{String: req.ClientURI, Valid: req.ClientURI != ""},
@@ -154,9 +155,9 @@ func CreateDynamicClientRegistration(db database.Store, accessURL *url.URL, audi
JWKS: app.Jwks.RawMessage,
SoftwareID: app.SoftwareID.String,
SoftwareVersion: app.SoftwareVersion.String,
GrantTypes: app.GrantTypes,
ResponseTypes: app.ResponseTypes,
TokenEndpointAuthMethod: app.TokenEndpointAuthMethod.String,
GrantTypes: slice.StringEnums[codersdk.OAuth2ProviderGrantType](app.GrantTypes),
ResponseTypes: slice.StringEnums[codersdk.OAuth2ProviderResponseType](app.ResponseTypes),
TokenEndpointAuthMethod: codersdk.OAuth2TokenEndpointAuthMethod(app.TokenEndpointAuthMethod.String),
Scope: app.Scope.String,
Contacts: app.Contacts,
RegistrationAccessToken: registrationToken,
@@ -217,12 +218,12 @@ func GetClientConfiguration(db database.Store) http.HandlerFunc {
JWKS: app.Jwks.RawMessage,
SoftwareID: app.SoftwareID.String,
SoftwareVersion: app.SoftwareVersion.String,
GrantTypes: app.GrantTypes,
ResponseTypes: app.ResponseTypes,
TokenEndpointAuthMethod: app.TokenEndpointAuthMethod.String,
GrantTypes: slice.StringEnums[codersdk.OAuth2ProviderGrantType](app.GrantTypes),
ResponseTypes: slice.StringEnums[codersdk.OAuth2ProviderResponseType](app.ResponseTypes),
TokenEndpointAuthMethod: codersdk.OAuth2TokenEndpointAuthMethod(app.TokenEndpointAuthMethod.String),
Scope: app.Scope.String,
Contacts: app.Contacts,
RegistrationAccessToken: nil, // RFC 7592: Not returned in GET responses for security
RegistrationAccessToken: "", // RFC 7592: Not returned in GET responses for security
RegistrationClientURI: app.RegistrationClientUri.String,
}
@@ -303,9 +304,9 @@ func UpdateClientConfiguration(db database.Store, auditor *audit.Auditor, logger
RedirectUris: req.RedirectURIs,
ClientType: sql.NullString{String: req.DetermineClientType(), Valid: true},
ClientSecretExpiresAt: sql.NullTime{}, // No expiration for now
GrantTypes: req.GrantTypes,
ResponseTypes: req.ResponseTypes,
TokenEndpointAuthMethod: sql.NullString{String: req.TokenEndpointAuthMethod, Valid: true},
GrantTypes: slice.ToStrings(req.GrantTypes),
ResponseTypes: slice.ToStrings(req.ResponseTypes),
TokenEndpointAuthMethod: sql.NullString{String: string(req.TokenEndpointAuthMethod), Valid: true},
Scope: sql.NullString{String: req.Scope, Valid: true},
Contacts: req.Contacts,
ClientUri: sql.NullString{String: req.ClientURI, Valid: req.ClientURI != ""},
@@ -341,12 +342,12 @@ func UpdateClientConfiguration(db database.Store, auditor *audit.Auditor, logger
JWKS: updatedApp.Jwks.RawMessage,
SoftwareID: updatedApp.SoftwareID.String,
SoftwareVersion: updatedApp.SoftwareVersion.String,
GrantTypes: updatedApp.GrantTypes,
ResponseTypes: updatedApp.ResponseTypes,
TokenEndpointAuthMethod: updatedApp.TokenEndpointAuthMethod.String,
GrantTypes: slice.StringEnums[codersdk.OAuth2ProviderGrantType](updatedApp.GrantTypes),
ResponseTypes: slice.StringEnums[codersdk.OAuth2ProviderResponseType](updatedApp.ResponseTypes),
TokenEndpointAuthMethod: codersdk.OAuth2TokenEndpointAuthMethod(updatedApp.TokenEndpointAuthMethod.String),
Scope: updatedApp.Scope.String,
Contacts: updatedApp.Contacts,
RegistrationAccessToken: updatedApp.RegistrationAccessToken,
RegistrationAccessToken: "", // RFC 7592: Not returned for security
RegistrationClientURI: updatedApp.RegistrationClientUri.String,
}
+31 -16
View File
@@ -18,6 +18,7 @@ import (
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/codersdk"
)
var (
@@ -27,6 +28,26 @@ var (
ErrInvalidTokenFormat = xerrors.New("invalid token format")
)
func extractRevocationRequest(r *http.Request) (codersdk.OAuth2TokenRevocationRequest, error) {
if err := r.ParseForm(); err != nil {
return codersdk.OAuth2TokenRevocationRequest{}, xerrors.Errorf("invalid form data: %w", err)
}
req := codersdk.OAuth2TokenRevocationRequest{
Token: r.Form.Get("token"),
TokenTypeHint: codersdk.OAuth2RevocationTokenTypeHint(r.Form.Get("token_type_hint")),
ClientID: r.Form.Get("client_id"),
ClientSecret: r.Form.Get("client_secret"),
}
// RFC 7009 requires 'token' parameter.
if req.Token == "" {
return codersdk.OAuth2TokenRevocationRequest{}, xerrors.New("missing token parameter")
}
return req, nil
}
// RevokeToken implements RFC 7009 OAuth2 Token Revocation
// Authentication is unique for this endpoint in that it does not use the
// standard token authentication middleware. Instead, it expects the token that
@@ -41,35 +62,29 @@ func RevokeToken(db database.Store, logger slog.Logger) http.HandlerFunc {
// RFC 7009 requires POST method with application/x-www-form-urlencoded
if r.Method != http.MethodPost {
httpapi.WriteOAuth2Error(ctx, rw, http.StatusMethodNotAllowed, "invalid_request", "Method not allowed")
httpapi.WriteOAuth2Error(ctx, rw, http.StatusMethodNotAllowed, codersdk.OAuth2ErrorCodeInvalidRequest, "Method not allowed")
return
}
if err := r.ParseForm(); err != nil {
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, "invalid_request", "Invalid form data")
return
}
// RFC 7009 requires 'token' parameter
token := r.Form.Get("token")
if token == "" {
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, "invalid_request", "Missing token parameter")
req, err := extractRevocationRequest(r)
if err != nil {
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidRequest, err.Error())
return
}
// Determine if this is a refresh token (starts with "coder_") or API key
// APIKeys do not have the SecretIdentifier prefix.
const coderPrefix = SecretIdentifier + "_"
isRefreshToken := strings.HasPrefix(token, coderPrefix)
isRefreshToken := strings.HasPrefix(req.Token, coderPrefix)
// Revoke the token with ownership verification
err := db.InTx(func(tx database.Store) error {
err = db.InTx(func(tx database.Store) error {
if isRefreshToken {
// Handle refresh token revocation
return revokeRefreshTokenInTx(ctx, tx, token, app.ID)
return revokeRefreshTokenInTx(ctx, tx, req.Token, app.ID)
}
// Handle API key revocation
return revokeAPIKeyInTx(ctx, tx, token, app.ID)
return revokeAPIKeyInTx(ctx, tx, req.Token, app.ID)
}, nil)
if err != nil {
if errors.Is(err, ErrTokenNotBelongsToClient) {
@@ -85,14 +100,14 @@ func RevokeToken(db database.Store, logger slog.Logger) http.HandlerFunc {
logger.Debug(ctx, "token revocation failed: invalid token format",
slog.F("client_id", app.ID.String()),
slog.F("app_name", app.Name))
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, "invalid_request", "Invalid token format")
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidRequest, "Invalid token format")
return
}
logger.Error(ctx, "token revocation failed with internal server error",
slog.Error(err),
slog.F("client_id", app.ID.String()),
slog.F("app_name", app.Name))
httpapi.WriteOAuth2Error(ctx, rw, http.StatusInternalServerError, "server_error", "Internal server error")
httpapi.WriteOAuth2Error(ctx, rw, http.StatusInternalServerError, codersdk.OAuth2ErrorCodeServerError, "Internal server error")
return
}
+83 -91
View File
@@ -8,11 +8,9 @@ import (
"net/http"
"net/url"
"slices"
"strings"
"time"
"github.com/google/uuid"
"golang.org/x/oauth2"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/apikey"
@@ -38,28 +36,18 @@ var (
errInvalidResource = xerrors.New("invalid resource parameter")
)
type tokenParams struct {
clientID string
clientSecret string
code string
grantType codersdk.OAuth2ProviderGrantType
redirectURL *url.URL
refreshToken string
codeVerifier string // PKCE verifier
resource string // RFC 8707 resource for token binding
scopes []string
}
func extractTokenParams(r *http.Request, callbackURL *url.URL) (tokenParams, []codersdk.ValidationError, error) {
func extractTokenRequest(r *http.Request, callbackURL *url.URL) (codersdk.OAuth2TokenRequest, []codersdk.ValidationError, error) {
p := httpapi.NewQueryParamParser()
err := r.ParseForm()
if err != nil {
return tokenParams{}, nil, xerrors.Errorf("parse form: %w", err)
return codersdk.OAuth2TokenRequest{}, nil, xerrors.Errorf("parse form: %w", err)
}
vals := r.Form
p.RequiredNotEmpty("grant_type")
grantType := httpapi.ParseCustom(p, vals, "", "grant_type", httpapi.ParseEnum[codersdk.OAuth2ProviderGrantType])
// Grant-type specific validation - must be called before parsing values.
switch grantType {
case codersdk.OAuth2ProviderGrantTypeRefreshToken:
p.RequiredNotEmpty("refresh_token")
@@ -67,19 +55,23 @@ func extractTokenParams(r *http.Request, callbackURL *url.URL) (tokenParams, []c
p.RequiredNotEmpty("client_secret", "client_id", "code")
}
params := tokenParams{
clientID: p.String(vals, "", "client_id"),
clientSecret: p.String(vals, "", "client_secret"),
code: p.String(vals, "", "code"),
grantType: grantType,
redirectURL: p.RedirectURL(vals, callbackURL, "redirect_uri"),
refreshToken: p.String(vals, "", "refresh_token"),
codeVerifier: p.String(vals, "", "code_verifier"),
resource: p.String(vals, "", "resource"),
scopes: strings.Fields(strings.TrimSpace(p.String(vals, "", "scope"))),
req := codersdk.OAuth2TokenRequest{
GrantType: grantType,
ClientID: p.String(vals, "", "client_id"),
ClientSecret: p.String(vals, "", "client_secret"),
Code: p.String(vals, "", "code"),
RedirectURI: p.String(vals, "", "redirect_uri"),
RefreshToken: p.String(vals, "", "refresh_token"),
CodeVerifier: p.String(vals, "", "code_verifier"),
Resource: p.String(vals, "", "resource"),
Scope: p.String(vals, "", "scope"),
}
// Validate resource parameter syntax (RFC 8707): must be absolute URI without fragment
if err := validateResourceParameter(params.resource); err != nil {
// Validate redirect URI - errors are added to p.Errors.
_ = p.RedirectURL(vals, callbackURL, "redirect_uri")
// Validate resource parameter syntax (RFC 8707): must be absolute URI without fragment.
if err := validateResourceParameter(req.Resource); err != nil {
p.Errors = append(p.Errors, codersdk.ValidationError{
Field: "resource",
Detail: "must be an absolute URI without fragment",
@@ -88,9 +80,9 @@ func extractTokenParams(r *http.Request, callbackURL *url.URL) (tokenParams, []c
p.ErrorExcessParams(vals)
if len(p.Errors) > 0 {
return tokenParams{}, p.Errors, xerrors.Errorf("invalid query params: %w", p.Errors)
return codersdk.OAuth2TokenRequest{}, p.Errors, xerrors.Errorf("invalid query params: %w", p.Errors)
}
return params, nil, nil
return req, nil, nil
}
// Tokens
@@ -110,13 +102,13 @@ func Tokens(db database.Store, lifetimes codersdk.SessionLifetime) http.HandlerF
return
}
params, validationErrs, err := extractTokenParams(r, callbackURL)
req, validationErrs, err := extractTokenRequest(r, callbackURL)
if err != nil {
// Check for specific validation errors in priority order
if slices.ContainsFunc(validationErrs, func(validationError codersdk.ValidationError) bool {
return validationError.Field == "grant_type"
}) {
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, "unsupported_grant_type", "The grant type is missing or unsupported")
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeUnsupportedGrantType, "The grant type is missing or unsupported")
return
}
@@ -125,47 +117,47 @@ func Tokens(db database.Store, lifetimes codersdk.SessionLifetime) http.HandlerF
if slices.ContainsFunc(validationErrs, func(validationError codersdk.ValidationError) bool {
return validationError.Field == field
}) {
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, "invalid_request", fmt.Sprintf("Missing required parameter: %s", field))
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidRequest, fmt.Sprintf("Missing required parameter: %s", field))
return
}
}
// Generic invalid request for other validation errors
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, "invalid_request", "The request is missing required parameters or is otherwise malformed")
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidRequest, "The request is missing required parameters or is otherwise malformed")
return
}
var token oauth2.Token
var token codersdk.OAuth2TokenResponse
//nolint:gocritic,revive // More cases will be added later.
switch params.grantType {
switch req.GrantType {
// TODO: Client creds, device code.
case codersdk.OAuth2ProviderGrantTypeRefreshToken:
token, err = refreshTokenGrant(ctx, db, app, lifetimes, params)
token, err = refreshTokenGrant(ctx, db, app, lifetimes, req)
case codersdk.OAuth2ProviderGrantTypeAuthorizationCode:
token, err = authorizationCodeGrant(ctx, db, app, lifetimes, params)
token, err = authorizationCodeGrant(ctx, db, app, lifetimes, req)
default:
// This should handle truly invalid grant types
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, "unsupported_grant_type", fmt.Sprintf("The grant type %q is not supported", params.grantType))
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeUnsupportedGrantType, fmt.Sprintf("The grant type %q is not supported", req.GrantType))
return
}
if errors.Is(err, errBadSecret) {
httpapi.WriteOAuth2Error(ctx, rw, http.StatusUnauthorized, "invalid_client", "The client credentials are invalid")
httpapi.WriteOAuth2Error(ctx, rw, http.StatusUnauthorized, codersdk.OAuth2ErrorCodeInvalidClient, "The client credentials are invalid")
return
}
if errors.Is(err, errBadCode) {
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, "invalid_grant", "The authorization code is invalid or expired")
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidGrant, "The authorization code is invalid or expired")
return
}
if errors.Is(err, errInvalidPKCE) {
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, "invalid_grant", "The PKCE code verifier is invalid")
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidGrant, "The PKCE code verifier is invalid")
return
}
if errors.Is(err, errInvalidResource) {
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, "invalid_target", "The resource parameter is invalid")
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidTarget, "The resource parameter is invalid")
return
}
if errors.Is(err, errBadToken) {
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, "invalid_grant", "The refresh token is invalid or expired")
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidGrant, "The refresh token is invalid or expired")
return
}
if err != nil {
@@ -182,77 +174,77 @@ func Tokens(db database.Store, lifetimes codersdk.SessionLifetime) http.HandlerF
}
}
func authorizationCodeGrant(ctx context.Context, db database.Store, app database.OAuth2ProviderApp, lifetimes codersdk.SessionLifetime, params tokenParams) (oauth2.Token, 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(params.clientSecret)
secret, err := ParseFormattedSecret(req.ClientSecret)
if err != nil {
return oauth2.Token{}, errBadSecret
return codersdk.OAuth2TokenResponse{}, errBadSecret
}
//nolint:gocritic // Users cannot read secrets so we must use the system.
dbSecret, err := db.GetOAuth2ProviderAppSecretByPrefix(dbauthz.AsSystemRestricted(ctx), []byte(secret.Prefix))
if errors.Is(err, sql.ErrNoRows) {
return oauth2.Token{}, errBadSecret
return codersdk.OAuth2TokenResponse{}, errBadSecret
}
if err != nil {
return oauth2.Token{}, err
return codersdk.OAuth2TokenResponse{}, err
}
equalSecret := apikey.ValidateHash(dbSecret.HashedSecret, secret.Secret)
if !equalSecret {
return oauth2.Token{}, errBadSecret
return codersdk.OAuth2TokenResponse{}, errBadSecret
}
// Validate the authorization code.
code, err := ParseFormattedSecret(params.code)
code, err := ParseFormattedSecret(req.Code)
if err != nil {
return oauth2.Token{}, errBadCode
return codersdk.OAuth2TokenResponse{}, errBadCode
}
//nolint:gocritic // There is no user yet so we must use the system.
dbCode, err := db.GetOAuth2ProviderAppCodeByPrefix(dbauthz.AsSystemRestricted(ctx), []byte(code.Prefix))
if errors.Is(err, sql.ErrNoRows) {
return oauth2.Token{}, errBadCode
return codersdk.OAuth2TokenResponse{}, errBadCode
}
if err != nil {
return oauth2.Token{}, err
return codersdk.OAuth2TokenResponse{}, err
}
equalCode := apikey.ValidateHash(dbCode.HashedSecret, code.Secret)
if !equalCode {
return oauth2.Token{}, errBadCode
return codersdk.OAuth2TokenResponse{}, errBadCode
}
// Ensure the code has not expired.
if dbCode.ExpiresAt.Before(dbtime.Now()) {
return oauth2.Token{}, errBadCode
return codersdk.OAuth2TokenResponse{}, errBadCode
}
// Verify PKCE challenge if present
if dbCode.CodeChallenge.Valid && dbCode.CodeChallenge.String != "" {
if params.codeVerifier == "" {
return oauth2.Token{}, errInvalidPKCE
if req.CodeVerifier == "" {
return codersdk.OAuth2TokenResponse{}, errInvalidPKCE
}
if !VerifyPKCE(dbCode.CodeChallenge.String, params.codeVerifier) {
return oauth2.Token{}, errInvalidPKCE
if !VerifyPKCE(dbCode.CodeChallenge.String, req.CodeVerifier) {
return codersdk.OAuth2TokenResponse{}, errInvalidPKCE
}
}
// Verify resource parameter consistency (RFC 8707)
if dbCode.ResourceUri.Valid && dbCode.ResourceUri.String != "" {
// Resource was specified during authorization - it must match in token request
if params.resource == "" {
return oauth2.Token{}, errInvalidResource
if req.Resource == "" {
return codersdk.OAuth2TokenResponse{}, errInvalidResource
}
if params.resource != dbCode.ResourceUri.String {
return oauth2.Token{}, errInvalidResource
if req.Resource != dbCode.ResourceUri.String {
return codersdk.OAuth2TokenResponse{}, errInvalidResource
}
} else if params.resource != "" {
} else if req.Resource != "" {
// Resource was not specified during authorization but is now provided
return oauth2.Token{}, errInvalidResource
return codersdk.OAuth2TokenResponse{}, errInvalidResource
}
// Generate a refresh token.
refreshToken, err := GenerateSecret()
if err != nil {
return oauth2.Token{}, err
return codersdk.OAuth2TokenResponse{}, err
}
// Generate the API key we will swap for the code.
@@ -266,13 +258,13 @@ func authorizationCodeGrant(ctx context.Context, db database.Store, app database
TokenName: tokenName,
})
if err != nil {
return oauth2.Token{}, err
return codersdk.OAuth2TokenResponse{}, err
}
// Grab the user roles so we can perform the exchange as the user.
actor, _, err := httpmw.UserRBACSubject(ctx, db, dbCode.UserID, rbac.ScopeAll)
if err != nil {
return oauth2.Token{}, xerrors.Errorf("fetch user actor: %w", err)
return codersdk.OAuth2TokenResponse{}, xerrors.Errorf("fetch user actor: %w", err)
}
// Do the actual token exchange in the database.
@@ -324,47 +316,47 @@ func authorizationCodeGrant(ctx context.Context, db database.Store, app database
return nil
}, nil)
if err != nil {
return oauth2.Token{}, err
return codersdk.OAuth2TokenResponse{}, err
}
return oauth2.Token{
return codersdk.OAuth2TokenResponse{
AccessToken: sessionToken,
TokenType: "Bearer",
TokenType: codersdk.OAuth2TokenTypeBearer,
RefreshToken: refreshToken.Formatted,
Expiry: key.ExpiresAt,
ExpiresIn: int64(time.Until(key.ExpiresAt).Seconds()),
Expiry: &key.ExpiresAt,
}, nil
}
func refreshTokenGrant(ctx context.Context, db database.Store, app database.OAuth2ProviderApp, lifetimes codersdk.SessionLifetime, params tokenParams) (oauth2.Token, error) {
func refreshTokenGrant(ctx context.Context, db database.Store, app database.OAuth2ProviderApp, lifetimes codersdk.SessionLifetime, req codersdk.OAuth2TokenRequest) (codersdk.OAuth2TokenResponse, error) {
// Validate the token.
token, err := ParseFormattedSecret(params.refreshToken)
token, err := ParseFormattedSecret(req.RefreshToken)
if err != nil {
return oauth2.Token{}, errBadToken
return codersdk.OAuth2TokenResponse{}, errBadToken
}
//nolint:gocritic // There is no user yet so we must use the system.
dbToken, err := db.GetOAuth2ProviderAppTokenByPrefix(dbauthz.AsSystemRestricted(ctx), []byte(token.Prefix))
if errors.Is(err, sql.ErrNoRows) {
return oauth2.Token{}, errBadToken
return codersdk.OAuth2TokenResponse{}, errBadToken
}
if err != nil {
return oauth2.Token{}, err
return codersdk.OAuth2TokenResponse{}, err
}
equal := apikey.ValidateHash(dbToken.RefreshHash, token.Secret)
if !equal {
return oauth2.Token{}, errBadToken
return codersdk.OAuth2TokenResponse{}, errBadToken
}
// Ensure the token has not expired.
if dbToken.ExpiresAt.Before(dbtime.Now()) {
return oauth2.Token{}, errBadToken
return codersdk.OAuth2TokenResponse{}, errBadToken
}
// Verify resource parameter consistency for refresh tokens (RFC 8707)
if params.resource != "" {
if req.Resource != "" {
// If resource is provided in refresh request, it must match the original token's audience
if !dbToken.Audience.Valid || dbToken.Audience.String != params.resource {
return oauth2.Token{}, errInvalidResource
if !dbToken.Audience.Valid || dbToken.Audience.String != req.Resource {
return codersdk.OAuth2TokenResponse{}, errInvalidResource
}
}
@@ -372,18 +364,18 @@ func refreshTokenGrant(ctx context.Context, db database.Store, app database.OAut
//nolint:gocritic // There is no user yet so we must use the system.
prevKey, err := db.GetAPIKeyByID(dbauthz.AsSystemRestricted(ctx), dbToken.APIKeyID)
if err != nil {
return oauth2.Token{}, err
return codersdk.OAuth2TokenResponse{}, err
}
actor, _, err := httpmw.UserRBACSubject(ctx, db, prevKey.UserID, rbac.ScopeAll)
if err != nil {
return oauth2.Token{}, xerrors.Errorf("fetch user actor: %w", err)
return codersdk.OAuth2TokenResponse{}, xerrors.Errorf("fetch user actor: %w", err)
}
// Generate a new refresh token.
refreshToken, err := GenerateSecret()
if err != nil {
return oauth2.Token{}, err
return codersdk.OAuth2TokenResponse{}, err
}
// Generate the new API key.
@@ -397,7 +389,7 @@ func refreshTokenGrant(ctx context.Context, db database.Store, app database.OAut
TokenName: tokenName,
})
if err != nil {
return oauth2.Token{}, err
return codersdk.OAuth2TokenResponse{}, err
}
// Replace the token.
@@ -437,15 +429,15 @@ func refreshTokenGrant(ctx context.Context, db database.Store, app database.OAut
return nil
}, nil)
if err != nil {
return oauth2.Token{}, err
return codersdk.OAuth2TokenResponse{}, err
}
return oauth2.Token{
return codersdk.OAuth2TokenResponse{
AccessToken: sessionToken,
TokenType: "Bearer",
TokenType: codersdk.OAuth2TokenTypeBearer,
RefreshToken: refreshToken.Formatted,
Expiry: key.ExpiresAt,
ExpiresIn: int64(time.Until(key.ExpiresAt).Seconds()),
Expiry: &key.ExpiresAt,
}, nil
}
+20 -13
View File
@@ -3,6 +3,7 @@ package oauth2provider
import (
"net/http"
"net/url"
"strings"
"testing"
"github.com/stretchr/testify/require"
@@ -10,6 +11,12 @@ import (
"github.com/coder/coder/v2/codersdk"
)
// parseScopes parses a space-delimited scope string into a slice of scopes
// per RFC 6749.
func parseScopes(scope string) []string {
return strings.Fields(strings.TrimSpace(scope))
}
// TestExtractTokenParams_Scopes tests OAuth2 scope parameter parsing
// to ensure RFC 6749 compliance where scopes are space-delimited
func TestExtractTokenParams_Scopes(t *testing.T) {
@@ -115,15 +122,15 @@ func TestExtractTokenParams_Scopes(t *testing.T) {
Form: form, // Form is the combination of PostForm and URL query
}
// Extract token params
params, validationErrs, err := extractTokenParams(req, callbackURL)
// Extract token request
tokenReq, validationErrs, err := extractTokenRequest(req, callbackURL)
// Verify no errors occurred
require.NoError(t, err, "extractTokenParams should not return error for: %s", tc.description)
require.NoError(t, err, "extractTokenRequest should not return error for: %s", tc.description)
require.Empty(t, validationErrs, "should have no validation errors for: %s", tc.description)
// Verify scopes match expected
require.Equal(t, tc.expectedScopes, params.scopes, "scope parsing failed for: %s", tc.description)
require.Equal(t, tc.expectedScopes, parseScopes(tokenReq.Scope), "scope parsing failed for: %s", tc.description)
})
}
}
@@ -178,15 +185,15 @@ func TestExtractTokenParams_ScopesURLEncoded(t *testing.T) {
Form: values,
}
// Extract token params
params, validationErrs, err := extractTokenParams(req, callbackURL)
// Extract token request
tokenReq, validationErrs, err := extractTokenRequest(req, callbackURL)
// Verify no errors
require.NoError(t, err)
require.Empty(t, validationErrs)
// Verify scopes
require.Equal(t, tc.expectedScopes, params.scopes)
require.Equal(t, tc.expectedScopes, parseScopes(tokenReq.Scope))
})
}
}
@@ -259,11 +266,11 @@ func TestExtractTokenParams_ScopesEdgeCases(t *testing.T) {
Form: form,
}
params, validationErrs, err := extractTokenParams(req, callbackURL)
tokenReq, validationErrs, err := extractTokenRequest(req, callbackURL)
require.NoError(t, err, "extractTokenParams should not error for: %s", tc.description)
require.NoError(t, err, "extractTokenRequest should not error for: %s", tc.description)
require.Empty(t, validationErrs)
require.Equal(t, tc.expectedScopes, params.scopes, "scope mismatch for: %s", tc.description)
require.Equal(t, tc.expectedScopes, parseScopes(tokenReq.Scope), "scope mismatch for: %s", tc.description)
})
}
}
@@ -354,10 +361,10 @@ func TestRefreshTokenGrant_Scopes(t *testing.T) {
Form: form,
}
params, validationErrs, err := extractTokenParams(req, callbackURL)
tokenReq, validationErrs, err := extractTokenRequest(req, callbackURL)
require.NoError(t, err)
require.Empty(t, validationErrs)
require.Equal(t, codersdk.OAuth2ProviderGrantTypeRefreshToken, params.grantType)
require.Equal(t, []string{"reduced:scope", "subset:scope"}, params.scopes)
require.Equal(t, codersdk.OAuth2ProviderGrantTypeRefreshToken, tokenReq.GrantType)
require.Equal(t, []string{"reduced:scope", "subset:scope"}, parseScopes(tokenReq.Scope))
}
+26 -26
View File
@@ -277,47 +277,47 @@ func TestOAuth2ClientMetadataValidation(t *testing.T) {
tests := []struct {
name string
grantTypes []string
grantTypes []codersdk.OAuth2ProviderGrantType
expectError bool
}{
{
name: "DefaultEmpty",
grantTypes: []string{},
grantTypes: []codersdk.OAuth2ProviderGrantType{},
expectError: false,
},
{
name: "ValidAuthorizationCode",
grantTypes: []string{"authorization_code"},
grantTypes: []codersdk.OAuth2ProviderGrantType{codersdk.OAuth2ProviderGrantTypeAuthorizationCode},
expectError: false,
},
{
name: "InvalidRefreshTokenAlone",
grantTypes: []string{"refresh_token"},
grantTypes: []codersdk.OAuth2ProviderGrantType{codersdk.OAuth2ProviderGrantTypeRefreshToken},
expectError: true, // refresh_token requires authorization_code to be present
},
{
name: "ValidMultiple",
grantTypes: []string{"authorization_code", "refresh_token"},
grantTypes: []codersdk.OAuth2ProviderGrantType{codersdk.OAuth2ProviderGrantTypeAuthorizationCode, codersdk.OAuth2ProviderGrantTypeRefreshToken},
expectError: false,
},
{
name: "InvalidUnsupported",
grantTypes: []string{"client_credentials"},
grantTypes: []codersdk.OAuth2ProviderGrantType{codersdk.OAuth2ProviderGrantTypeClientCredentials},
expectError: true,
},
{
name: "InvalidPassword",
grantTypes: []string{"password"},
grantTypes: []codersdk.OAuth2ProviderGrantType{codersdk.OAuth2ProviderGrantTypePassword},
expectError: true,
},
{
name: "InvalidImplicit",
grantTypes: []string{"implicit"},
grantTypes: []codersdk.OAuth2ProviderGrantType{codersdk.OAuth2ProviderGrantTypeImplicit},
expectError: true,
},
{
name: "MixedValidInvalid",
grantTypes: []string{"authorization_code", "client_credentials"},
grantTypes: []codersdk.OAuth2ProviderGrantType{codersdk.OAuth2ProviderGrantTypeAuthorizationCode, codersdk.OAuth2ProviderGrantTypeClientCredentials},
expectError: true,
},
}
@@ -352,32 +352,32 @@ func TestOAuth2ClientMetadataValidation(t *testing.T) {
tests := []struct {
name string
responseTypes []string
responseTypes []codersdk.OAuth2ProviderResponseType
expectError bool
}{
{
name: "DefaultEmpty",
responseTypes: []string{},
responseTypes: []codersdk.OAuth2ProviderResponseType{},
expectError: false,
},
{
name: "ValidCode",
responseTypes: []string{"code"},
responseTypes: []codersdk.OAuth2ProviderResponseType{codersdk.OAuth2ProviderResponseTypeCode},
expectError: false,
},
{
name: "InvalidToken",
responseTypes: []string{"token"},
responseTypes: []codersdk.OAuth2ProviderResponseType{codersdk.OAuth2ProviderResponseTypeToken},
expectError: true,
},
{
name: "InvalidImplicit",
responseTypes: []string{"id_token"},
name: "InvalidIDToken",
responseTypes: []codersdk.OAuth2ProviderResponseType{"id_token"}, // OIDC-specific, no constant
expectError: true,
},
{
name: "InvalidMultiple",
responseTypes: []string{"code", "token"},
responseTypes: []codersdk.OAuth2ProviderResponseType{codersdk.OAuth2ProviderResponseTypeCode, codersdk.OAuth2ProviderResponseTypeToken},
expectError: true,
},
}
@@ -412,7 +412,7 @@ func TestOAuth2ClientMetadataValidation(t *testing.T) {
tests := []struct {
name string
authMethod string
authMethod codersdk.OAuth2TokenEndpointAuthMethod
expectError bool
}{
{
@@ -422,27 +422,27 @@ func TestOAuth2ClientMetadataValidation(t *testing.T) {
},
{
name: "ValidClientSecretBasic",
authMethod: "client_secret_basic",
authMethod: codersdk.OAuth2TokenEndpointAuthMethodClientSecretBasic,
expectError: false,
},
{
name: "ValidClientSecretPost",
authMethod: "client_secret_post",
authMethod: codersdk.OAuth2TokenEndpointAuthMethodClientSecretPost,
expectError: false,
},
{
name: "ValidNone",
authMethod: "none",
authMethod: codersdk.OAuth2TokenEndpointAuthMethodNone,
expectError: false, // "none" is valid for public clients per RFC 7591
},
{
name: "InvalidPrivateKeyJWT",
authMethod: "private_key_jwt",
authMethod: "private_key_jwt", // OIDC-specific, no constant defined
expectError: true,
},
{
name: "InvalidClientSecretJWT",
authMethod: "client_secret_jwt",
authMethod: "client_secret_jwt", // OIDC-specific, no constant defined
expectError: true,
},
{
@@ -659,14 +659,14 @@ func TestOAuth2ClientMetadataDefaults(t *testing.T) {
require.NoError(t, err)
// Should default to authorization_code
require.Contains(t, config.GrantTypes, "authorization_code")
require.Contains(t, config.GrantTypes, codersdk.OAuth2ProviderGrantTypeAuthorizationCode)
// Should default to code
require.Contains(t, config.ResponseTypes, "code")
require.Contains(t, config.ResponseTypes, codersdk.OAuth2ProviderResponseTypeCode)
// Should default to client_secret_basic or client_secret_post
require.True(t, config.TokenEndpointAuthMethod == "client_secret_basic" ||
config.TokenEndpointAuthMethod == "client_secret_post" ||
require.True(t, config.TokenEndpointAuthMethod == codersdk.OAuth2TokenEndpointAuthMethodClientSecretBasic ||
config.TokenEndpointAuthMethod == codersdk.OAuth2TokenEndpointAuthMethodClientSecretPost ||
config.TokenEndpointAuthMethod == "")
// Client secret should be generated