diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 576e942ec5..3027f6cad1 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -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": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 1dbab4fa34..dc35d71e5f 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -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": { diff --git a/coderd/httpapi/httpapi.go b/coderd/httpapi/httpapi.go index 84a5a7b32d..bccc58ab37 100644 --- a/coderd/httpapi/httpapi.go +++ b/coderd/httpapi/httpapi.go @@ -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, }) diff --git a/coderd/httpmw/oauth2.go b/coderd/httpmw/oauth2.go index 0e2b99e907..a851299e66 100644 --- a/coderd/httpmw/oauth2.go +++ b/coderd/httpmw/oauth2.go @@ -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 diff --git a/coderd/oauth2_error_compliance_test.go b/coderd/oauth2_error_compliance_test.go index ce481e6af3..653d6b8717 100644 --- a/coderd/oauth2_error_compliance_test.go +++ b/coderd/oauth2_error_compliance_test.go @@ -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, diff --git a/coderd/oauth2_metadata_test.go b/coderd/oauth2_metadata_test.go index 0e7ff4b1a8..02189540d4 100644 --- a/coderd/oauth2_metadata_test.go +++ b/coderd/oauth2_metadata_test.go @@ -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) } diff --git a/coderd/oauth2_metadata_validation_test.go b/coderd/oauth2_metadata_validation_test.go index 1f70d42b45..889f402be2 100644 --- a/coderd/oauth2_metadata_validation_test.go +++ b/coderd/oauth2_metadata_validation_test.go @@ -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 diff --git a/coderd/oauth2_test.go b/coderd/oauth2_test.go index 72564a2a0d..f8b203581f 100644 --- a/coderd/oauth2_test.go +++ b/coderd/oauth2_test.go @@ -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) { diff --git a/coderd/oauth2provider/authorize.go b/coderd/oauth2provider/authorize.go index e5f6314b39..241547702c 100644 --- a/coderd/oauth2provider/authorize.go +++ b/coderd/oauth2provider/authorize.go @@ -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 } diff --git a/coderd/oauth2provider/metadata.go b/coderd/oauth2provider/metadata.go index ecc80049df..16749fe44c 100644 --- a/coderd/oauth2provider/metadata.go +++ b/coderd/oauth2provider/metadata.go @@ -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) } diff --git a/coderd/oauth2provider/metadata_test.go b/coderd/oauth2provider/metadata_test.go index 006c341f75..27f7ef5e31 100644 --- a/coderd/oauth2provider/metadata_test.go +++ b/coderd/oauth2provider/metadata_test.go @@ -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) } diff --git a/coderd/oauth2provider/oauth2providertest/helpers.go b/coderd/oauth2provider/oauth2providertest/helpers.go index d0a90c6d34..59b0c38f7f 100644 --- a/coderd/oauth2provider/oauth2providertest/helpers.go +++ b/coderd/oauth2provider/oauth2providertest/helpers.go @@ -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") +} diff --git a/coderd/oauth2provider/oauth2providertest/oauth2_test.go b/coderd/oauth2provider/oauth2providertest/oauth2_test.go index cb33c8914a..737acd1628 100644 --- a/coderd/oauth2provider/oauth2providertest/oauth2_test.go +++ b/coderd/oauth2provider/oauth2providertest/oauth2_test.go @@ -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() diff --git a/coderd/oauth2provider/pkce_test.go b/coderd/oauth2provider/pkce_test.go index f0ed74ca1b..c854c87e62 100644 --- a/coderd/oauth2provider/pkce_test.go +++ b/coderd/oauth2provider/pkce_test.go @@ -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) + } + }) + } +} diff --git a/coderd/oauth2provider/provider_test.go b/coderd/oauth2provider/provider_test.go index 572b3f6daf..8848a6ff18 100644 --- a/coderd/oauth2provider/provider_test.go +++ b/coderd/oauth2provider/provider_test.go @@ -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) diff --git a/coderd/oauth2provider/registration.go b/coderd/oauth2provider/registration.go index 0fbb656e1d..1891db358a 100644 --- a/coderd/oauth2provider/registration.go +++ b/coderd/oauth2provider/registration.go @@ -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, } diff --git a/coderd/oauth2provider/revoke.go b/coderd/oauth2provider/revoke.go index 6c553423d1..5893c674ec 100644 --- a/coderd/oauth2provider/revoke.go +++ b/coderd/oauth2provider/revoke.go @@ -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 } diff --git a/coderd/oauth2provider/tokens.go b/coderd/oauth2provider/tokens.go index d0f1aad105..956f91a127 100644 --- a/coderd/oauth2provider/tokens.go +++ b/coderd/oauth2provider/tokens.go @@ -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 } diff --git a/coderd/oauth2provider/tokens_internal_test.go b/coderd/oauth2provider/tokens_internal_test.go index 09dcd49f34..5a6dcf3d93 100644 --- a/coderd/oauth2provider/tokens_internal_test.go +++ b/coderd/oauth2provider/tokens_internal_test.go @@ -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)) } diff --git a/coderd/oauth2provider/validation_test.go b/coderd/oauth2provider/validation_test.go index c13c2756a5..8e556e0937 100644 --- a/coderd/oauth2provider/validation_test.go +++ b/coderd/oauth2provider/validation_test.go @@ -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 diff --git a/codersdk/oauth2.go b/codersdk/oauth2.go index 6b4d220df0..3d86de2271 100644 --- a/codersdk/oauth2.go +++ b/codersdk/oauth2.go @@ -8,6 +8,7 @@ import ( "net/http" "net/url" "strings" + "time" "github.com/google/uuid" ) @@ -186,14 +187,22 @@ func (c *Client) DeleteOAuth2ProviderAppSecret(ctx context.Context, appID uuid.U type OAuth2ProviderGrantType string +// OAuth2ProviderGrantType values (RFC 6749). const ( OAuth2ProviderGrantTypeAuthorizationCode OAuth2ProviderGrantType = "authorization_code" OAuth2ProviderGrantTypeRefreshToken OAuth2ProviderGrantType = "refresh_token" + OAuth2ProviderGrantTypePassword OAuth2ProviderGrantType = "password" + OAuth2ProviderGrantTypeClientCredentials OAuth2ProviderGrantType = "client_credentials" + OAuth2ProviderGrantTypeImplicit OAuth2ProviderGrantType = "implicit" ) func (e OAuth2ProviderGrantType) Valid() bool { switch e { - case OAuth2ProviderGrantTypeAuthorizationCode, OAuth2ProviderGrantTypeRefreshToken: + case OAuth2ProviderGrantTypeAuthorizationCode, + OAuth2ProviderGrantTypeRefreshToken, + OAuth2ProviderGrantTypePassword, + OAuth2ProviderGrantTypeClientCredentials, + OAuth2ProviderGrantTypeImplicit: return true } return false @@ -201,19 +210,171 @@ func (e OAuth2ProviderGrantType) Valid() bool { type OAuth2ProviderResponseType string +// OAuth2ProviderResponseType values (RFC 6749). const ( - OAuth2ProviderResponseTypeCode OAuth2ProviderResponseType = "code" + OAuth2ProviderResponseTypeCode OAuth2ProviderResponseType = "code" + OAuth2ProviderResponseTypeToken OAuth2ProviderResponseType = "token" ) func (e OAuth2ProviderResponseType) Valid() bool { - //nolint:gocritic,revive // More cases might be added later. switch e { - case OAuth2ProviderResponseTypeCode: + case OAuth2ProviderResponseTypeCode, OAuth2ProviderResponseTypeToken: return true } return false } +type OAuth2TokenEndpointAuthMethod string + +const ( + OAuth2TokenEndpointAuthMethodClientSecretBasic OAuth2TokenEndpointAuthMethod = "client_secret_basic" + OAuth2TokenEndpointAuthMethodClientSecretPost OAuth2TokenEndpointAuthMethod = "client_secret_post" + OAuth2TokenEndpointAuthMethodNone OAuth2TokenEndpointAuthMethod = "none" +) + +func (m OAuth2TokenEndpointAuthMethod) Valid() bool { + switch m { + case OAuth2TokenEndpointAuthMethodClientSecretBasic, + OAuth2TokenEndpointAuthMethodClientSecretPost, + OAuth2TokenEndpointAuthMethodNone: + return true + } + return false +} + +type OAuth2PKCECodeChallengeMethod string + +// OAuth2PKCECodeChallengeMethod values (RFC 7636). +const ( + OAuth2PKCECodeChallengeMethodS256 OAuth2PKCECodeChallengeMethod = "S256" + OAuth2PKCECodeChallengeMethodPlain OAuth2PKCECodeChallengeMethod = "plain" +) + +func (m OAuth2PKCECodeChallengeMethod) Valid() bool { + switch m { + case OAuth2PKCECodeChallengeMethodS256, OAuth2PKCECodeChallengeMethodPlain: + return true + } + return false +} + +type OAuth2TokenType string + +// OAuth2TokenType values (RFC 6749, RFC 9449). +const ( + OAuth2TokenTypeBearer OAuth2TokenType = "Bearer" + OAuth2TokenTypeDPoP OAuth2TokenType = "DPoP" +) + +func (t OAuth2TokenType) Valid() bool { + switch t { + case OAuth2TokenTypeBearer, OAuth2TokenTypeDPoP: + return true + } + return false +} + +type OAuth2RevocationTokenTypeHint string + +const ( + OAuth2RevocationTokenTypeHintAccessToken OAuth2RevocationTokenTypeHint = "access_token" + OAuth2RevocationTokenTypeHintRefreshToken OAuth2RevocationTokenTypeHint = "refresh_token" +) + +func (h OAuth2RevocationTokenTypeHint) Valid() bool { + switch h { + case OAuth2RevocationTokenTypeHintAccessToken, OAuth2RevocationTokenTypeHintRefreshToken: + return true + } + return false +} + +type OAuth2ErrorCode string + +// OAuth2 error codes per RFC 6749, RFC 7009, RFC 8707. +// This is not comprehensive; it includes only codes relevant to this implementation. +const ( + // RFC 6749 - Token endpoint errors. + OAuth2ErrorCodeInvalidRequest OAuth2ErrorCode = "invalid_request" + OAuth2ErrorCodeInvalidClient OAuth2ErrorCode = "invalid_client" + OAuth2ErrorCodeInvalidGrant OAuth2ErrorCode = "invalid_grant" + OAuth2ErrorCodeUnauthorizedClient OAuth2ErrorCode = "unauthorized_client" + OAuth2ErrorCodeUnsupportedGrantType OAuth2ErrorCode = "unsupported_grant_type" + OAuth2ErrorCodeInvalidScope OAuth2ErrorCode = "invalid_scope" + + // RFC 6749 - Authorization endpoint errors. + OAuth2ErrorCodeAccessDenied OAuth2ErrorCode = "access_denied" + OAuth2ErrorCodeUnsupportedResponseType OAuth2ErrorCode = "unsupported_response_type" + OAuth2ErrorCodeServerError OAuth2ErrorCode = "server_error" + OAuth2ErrorCodeTemporarilyUnavailable OAuth2ErrorCode = "temporarily_unavailable" + + // RFC 7009 - Token revocation errors. + OAuth2ErrorCodeUnsupportedTokenType OAuth2ErrorCode = "unsupported_token_type" + + // RFC 8707 - Resource indicator errors. + OAuth2ErrorCodeInvalidTarget OAuth2ErrorCode = "invalid_target" +) + +func (c OAuth2ErrorCode) Valid() bool { + switch c { + case OAuth2ErrorCodeInvalidRequest, + OAuth2ErrorCodeInvalidClient, + OAuth2ErrorCodeInvalidGrant, + OAuth2ErrorCodeUnauthorizedClient, + OAuth2ErrorCodeUnsupportedGrantType, + OAuth2ErrorCodeInvalidScope, + OAuth2ErrorCodeAccessDenied, + OAuth2ErrorCodeUnsupportedResponseType, + OAuth2ErrorCodeServerError, + OAuth2ErrorCodeTemporarilyUnavailable, + OAuth2ErrorCodeUnsupportedTokenType, + OAuth2ErrorCodeInvalidTarget: + return true + } + return false +} + +// OAuth2Error represents an OAuth2-compliant error response per RFC 6749. +type OAuth2Error struct { + Error OAuth2ErrorCode `json:"error"` + ErrorDescription string `json:"error_description,omitempty"` + ErrorURI string `json:"error_uri,omitempty"` +} + +// OAuth2TokenRequest represents a token request per RFC 6749. The actual wire +// format is application/x-www-form-urlencoded; this struct is for SDK docs. +type OAuth2TokenRequest struct { + GrantType OAuth2ProviderGrantType `json:"grant_type"` + Code string `json:"code,omitempty"` + RedirectURI string `json:"redirect_uri,omitempty"` + ClientID string `json:"client_id,omitempty"` + ClientSecret string `json:"client_secret,omitempty"` + CodeVerifier string `json:"code_verifier,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + Resource string `json:"resource,omitempty"` + Scope string `json:"scope,omitempty"` +} + +// OAuth2TokenResponse represents a successful token response per RFC 6749. +type OAuth2TokenResponse struct { + AccessToken string `json:"access_token"` + TokenType OAuth2TokenType `json:"token_type"` + ExpiresIn int64 `json:"expires_in,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + Scope string `json:"scope,omitempty"` + // Expiry is not part of RFC 6749 but is included for compatibility with + // golang.org/x/oauth2.Token and clients that expect a timestamp. + Expiry *time.Time `json:"expiry,omitempty" format:"date-time"` +} + +// OAuth2TokenRevocationRequest represents a token revocation request per RFC 7009. +type OAuth2TokenRevocationRequest struct { + Token string `json:"token"` + TokenTypeHint OAuth2RevocationTokenTypeHint `json:"token_type_hint,omitempty"` + ClientID string `json:"client_id,omitempty"` + ClientSecret string `json:"client_secret,omitempty"` +} + // RevokeOAuth2Token revokes a specific OAuth2 token using RFC 7009 token revocation. func (c *Client) RevokeOAuth2Token(ctx context.Context, clientID uuid.UUID, token string) error { form := url.Values{} @@ -256,18 +417,18 @@ type OAuth2DeviceFlowCallbackResponse struct { RedirectURL string `json:"redirect_url"` } -// OAuth2AuthorizationServerMetadata represents RFC 8414 OAuth 2.0 Authorization Server Metadata +// OAuth2AuthorizationServerMetadata represents RFC 8414 OAuth 2.0 Authorization Server Metadata. type OAuth2AuthorizationServerMetadata struct { - Issuer string `json:"issuer"` - AuthorizationEndpoint string `json:"authorization_endpoint"` - TokenEndpoint string `json:"token_endpoint"` - RegistrationEndpoint string `json:"registration_endpoint,omitempty"` - RevocationEndpoint string `json:"revocation_endpoint,omitempty"` - ResponseTypesSupported []string `json:"response_types_supported"` - GrantTypesSupported []string `json:"grant_types_supported"` - CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"` - ScopesSupported []string `json:"scopes_supported,omitempty"` - TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported,omitempty"` + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + RegistrationEndpoint string `json:"registration_endpoint,omitempty"` + RevocationEndpoint string `json:"revocation_endpoint,omitempty"` + ResponseTypesSupported []OAuth2ProviderResponseType `json:"response_types_supported"` + GrantTypesSupported []OAuth2ProviderGrantType `json:"grant_types_supported,omitempty"` + CodeChallengeMethodsSupported []OAuth2PKCECodeChallengeMethod `json:"code_challenge_methods_supported,omitempty"` + ScopesSupported []string `json:"scopes_supported,omitempty"` + TokenEndpointAuthMethodsSupported []OAuth2TokenEndpointAuthMethod `json:"token_endpoint_auth_methods_supported,omitempty"` } // OAuth2ProtectedResourceMetadata represents RFC 9728 OAuth 2.0 Protected Resource Metadata @@ -278,50 +439,50 @@ type OAuth2ProtectedResourceMetadata struct { BearerMethodsSupported []string `json:"bearer_methods_supported,omitempty"` } -// OAuth2ClientRegistrationRequest represents RFC 7591 Dynamic Client Registration Request +// OAuth2ClientRegistrationRequest represents RFC 7591 Dynamic Client Registration Request. type OAuth2ClientRegistrationRequest struct { - RedirectURIs []string `json:"redirect_uris,omitempty"` - ClientName string `json:"client_name,omitempty"` - ClientURI string `json:"client_uri,omitempty"` - LogoURI string `json:"logo_uri,omitempty"` - TOSURI string `json:"tos_uri,omitempty"` - PolicyURI string `json:"policy_uri,omitempty"` - JWKSURI string `json:"jwks_uri,omitempty"` - JWKS json.RawMessage `json:"jwks,omitempty" swaggertype:"object"` - SoftwareID string `json:"software_id,omitempty"` - SoftwareVersion string `json:"software_version,omitempty"` - SoftwareStatement string `json:"software_statement,omitempty"` - GrantTypes []string `json:"grant_types,omitempty"` - ResponseTypes []string `json:"response_types,omitempty"` - TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"` - Scope string `json:"scope,omitempty"` - Contacts []string `json:"contacts,omitempty"` + RedirectURIs []string `json:"redirect_uris,omitempty"` + ClientName string `json:"client_name,omitempty"` + ClientURI string `json:"client_uri,omitempty"` + LogoURI string `json:"logo_uri,omitempty"` + TOSURI string `json:"tos_uri,omitempty"` + PolicyURI string `json:"policy_uri,omitempty"` + JWKSURI string `json:"jwks_uri,omitempty"` + JWKS json.RawMessage `json:"jwks,omitempty" swaggertype:"object"` + SoftwareID string `json:"software_id,omitempty"` + SoftwareVersion string `json:"software_version,omitempty"` + SoftwareStatement string `json:"software_statement,omitempty"` + GrantTypes []OAuth2ProviderGrantType `json:"grant_types,omitempty"` + ResponseTypes []OAuth2ProviderResponseType `json:"response_types,omitempty"` + TokenEndpointAuthMethod OAuth2TokenEndpointAuthMethod `json:"token_endpoint_auth_method,omitempty"` + Scope string `json:"scope,omitempty"` + Contacts []string `json:"contacts,omitempty"` } func (req OAuth2ClientRegistrationRequest) ApplyDefaults() OAuth2ClientRegistrationRequest { - // Apply grant type defaults + // Apply grant type defaults. if len(req.GrantTypes) == 0 { - req.GrantTypes = []string{ - string(OAuth2ProviderGrantTypeAuthorizationCode), - string(OAuth2ProviderGrantTypeRefreshToken), + req.GrantTypes = []OAuth2ProviderGrantType{ + OAuth2ProviderGrantTypeAuthorizationCode, + OAuth2ProviderGrantTypeRefreshToken, } } - // Apply response type defaults + // Apply response type defaults. if len(req.ResponseTypes) == 0 { - req.ResponseTypes = []string{ - string(OAuth2ProviderResponseTypeCode), + req.ResponseTypes = []OAuth2ProviderResponseType{ + OAuth2ProviderResponseTypeCode, } } - // Apply token endpoint auth method default (RFC 7591 section 2) + // Apply token endpoint auth method default (RFC 7591 section 2). if req.TokenEndpointAuthMethod == "" { - // Default according to RFC 7591: "client_secret_basic" for confidential clients - // For public clients, should be explicitly set to "none" - req.TokenEndpointAuthMethod = "client_secret_basic" + // Default according to RFC 7591: "client_secret_basic" for confidential clients. + // For public clients, should be explicitly set to "none". + req.TokenEndpointAuthMethod = OAuth2TokenEndpointAuthMethodClientSecretBasic } - // Apply client name default if not provided + // Apply client name default if not provided. if req.ClientName == "" { req.ClientName = "Dynamically Registered Client" } @@ -377,29 +538,29 @@ func (req *OAuth2ClientRegistrationRequest) GenerateClientName() string { return "Dynamically Registered Client" } -// OAuth2ClientRegistrationResponse represents RFC 7591 Dynamic Client Registration Response +// OAuth2ClientRegistrationResponse represents RFC 7591 Dynamic Client Registration Response. type OAuth2ClientRegistrationResponse struct { - ClientID string `json:"client_id"` - ClientSecret string `json:"client_secret,omitempty"` - ClientIDIssuedAt int64 `json:"client_id_issued_at"` - ClientSecretExpiresAt int64 `json:"client_secret_expires_at,omitempty"` - RedirectURIs []string `json:"redirect_uris,omitempty"` - ClientName string `json:"client_name,omitempty"` - ClientURI string `json:"client_uri,omitempty"` - LogoURI string `json:"logo_uri,omitempty"` - TOSURI string `json:"tos_uri,omitempty"` - PolicyURI string `json:"policy_uri,omitempty"` - JWKSURI string `json:"jwks_uri,omitempty"` - JWKS json.RawMessage `json:"jwks,omitempty" swaggertype:"object"` - SoftwareID string `json:"software_id,omitempty"` - SoftwareVersion string `json:"software_version,omitempty"` - GrantTypes []string `json:"grant_types"` - ResponseTypes []string `json:"response_types"` - TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` - Scope string `json:"scope,omitempty"` - Contacts []string `json:"contacts,omitempty"` - RegistrationAccessToken string `json:"registration_access_token"` - RegistrationClientURI string `json:"registration_client_uri"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret,omitempty"` + ClientIDIssuedAt int64 `json:"client_id_issued_at,omitempty"` + ClientSecretExpiresAt int64 `json:"client_secret_expires_at,omitempty"` + RedirectURIs []string `json:"redirect_uris,omitempty"` + ClientName string `json:"client_name,omitempty"` + ClientURI string `json:"client_uri,omitempty"` + LogoURI string `json:"logo_uri,omitempty"` + TOSURI string `json:"tos_uri,omitempty"` + PolicyURI string `json:"policy_uri,omitempty"` + JWKSURI string `json:"jwks_uri,omitempty"` + JWKS json.RawMessage `json:"jwks,omitempty" swaggertype:"object"` + SoftwareID string `json:"software_id,omitempty"` + SoftwareVersion string `json:"software_version,omitempty"` + GrantTypes []OAuth2ProviderGrantType `json:"grant_types"` + ResponseTypes []OAuth2ProviderResponseType `json:"response_types"` + TokenEndpointAuthMethod OAuth2TokenEndpointAuthMethod `json:"token_endpoint_auth_method"` + Scope string `json:"scope,omitempty"` + Contacts []string `json:"contacts,omitempty"` + RegistrationAccessToken string `json:"registration_access_token"` + RegistrationClientURI string `json:"registration_client_uri"` } // PostOAuth2ClientRegistration dynamically registers a new OAuth2 client (RFC 7591) @@ -466,27 +627,26 @@ func (c *Client) DeleteOAuth2ClientConfiguration(ctx context.Context, clientID s return nil } -// OAuth2ClientConfiguration represents RFC 7592 Client Configuration (for GET/PUT operations) -// Same as OAuth2ClientRegistrationResponse but without client_secret in GET responses +// OAuth2ClientConfiguration represents RFC 7592 Client Read Response. type OAuth2ClientConfiguration struct { - ClientID string `json:"client_id"` - ClientIDIssuedAt int64 `json:"client_id_issued_at"` - ClientSecretExpiresAt int64 `json:"client_secret_expires_at,omitempty"` - RedirectURIs []string `json:"redirect_uris,omitempty"` - ClientName string `json:"client_name,omitempty"` - ClientURI string `json:"client_uri,omitempty"` - LogoURI string `json:"logo_uri,omitempty"` - TOSURI string `json:"tos_uri,omitempty"` - PolicyURI string `json:"policy_uri,omitempty"` - JWKSURI string `json:"jwks_uri,omitempty"` - JWKS json.RawMessage `json:"jwks,omitempty" swaggertype:"object"` - SoftwareID string `json:"software_id,omitempty"` - SoftwareVersion string `json:"software_version,omitempty"` - GrantTypes []string `json:"grant_types"` - ResponseTypes []string `json:"response_types"` - TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` - Scope string `json:"scope,omitempty"` - Contacts []string `json:"contacts,omitempty"` - RegistrationAccessToken []byte `json:"registration_access_token"` - RegistrationClientURI string `json:"registration_client_uri"` + ClientID string `json:"client_id"` + ClientIDIssuedAt int64 `json:"client_id_issued_at"` + ClientSecretExpiresAt int64 `json:"client_secret_expires_at,omitempty"` + RedirectURIs []string `json:"redirect_uris,omitempty"` + ClientName string `json:"client_name,omitempty"` + ClientURI string `json:"client_uri,omitempty"` + LogoURI string `json:"logo_uri,omitempty"` + TOSURI string `json:"tos_uri,omitempty"` + PolicyURI string `json:"policy_uri,omitempty"` + JWKSURI string `json:"jwks_uri,omitempty"` + JWKS json.RawMessage `json:"jwks,omitempty" swaggertype:"object"` + SoftwareID string `json:"software_id,omitempty"` + SoftwareVersion string `json:"software_version,omitempty"` + GrantTypes []OAuth2ProviderGrantType `json:"grant_types"` + ResponseTypes []OAuth2ProviderResponseType `json:"response_types"` + TokenEndpointAuthMethod OAuth2TokenEndpointAuthMethod `json:"token_endpoint_auth_method"` + Scope string `json:"scope,omitempty"` + Contacts []string `json:"contacts,omitempty"` + RegistrationAccessToken string `json:"registration_access_token,omitempty"` + RegistrationClientURI string `json:"registration_client_uri"` } diff --git a/codersdk/oauth2_validation.go b/codersdk/oauth2_validation.go index ad9375f4ef..58627e6efa 100644 --- a/codersdk/oauth2_validation.go +++ b/codersdk/oauth2_validation.go @@ -76,7 +76,7 @@ func (req *OAuth2ClientRegistrationRequest) Validate() error { } // validateRedirectURIs validates redirect URIs according to RFC 7591, 8252 -func validateRedirectURIs(uris []string, tokenEndpointAuthMethod string) error { +func validateRedirectURIs(uris []string, tokenEndpointAuthMethod OAuth2TokenEndpointAuthMethod) error { if len(uris) == 0 { return xerrors.New("at least one redirect URI is required") } @@ -115,7 +115,7 @@ func validateRedirectURIs(uris []string, tokenEndpointAuthMethod string) error { } // Determine if this is a public client based on token endpoint auth method - isPublicClient := tokenEndpointAuthMethod == "none" + isPublicClient := tokenEndpointAuthMethod == OAuth2TokenEndpointAuthMethodNone // Handle different validation for public vs confidential clients if uri.Scheme == "http" || uri.Scheme == "https" { @@ -155,23 +155,15 @@ func validateRedirectURIs(uris []string, tokenEndpointAuthMethod string) error { } // validateGrantTypes validates OAuth2 grant types -func validateGrantTypes(grantTypes []string) error { - validGrants := []string{ - string(OAuth2ProviderGrantTypeAuthorizationCode), - string(OAuth2ProviderGrantTypeRefreshToken), - // Add more grant types as they are implemented - // "client_credentials", - // "urn:ietf:params:oauth:grant-type:device_code", - } - +func validateGrantTypes(grantTypes []OAuth2ProviderGrantType) error { for _, grant := range grantTypes { - if !slices.Contains(validGrants, grant) { + if !isSupportedGrantType(grant) { return xerrors.Errorf("unsupported grant type: %s", grant) } } // Ensure authorization_code is present if redirect_uris are specified - hasAuthCode := slices.Contains(grantTypes, string(OAuth2ProviderGrantTypeAuthorizationCode)) + hasAuthCode := slices.Contains(grantTypes, OAuth2ProviderGrantTypeAuthorizationCode) if !hasAuthCode { return xerrors.New("authorization_code grant type is required when redirect_uris are specified") } @@ -179,15 +171,18 @@ func validateGrantTypes(grantTypes []string) error { return nil } -// validateResponseTypes validates OAuth2 response types -func validateResponseTypes(responseTypes []string) error { - validResponses := []string{ - string(OAuth2ProviderResponseTypeCode), - // Add more response types as they are implemented +func isSupportedGrantType(grant OAuth2ProviderGrantType) bool { + switch grant { + case OAuth2ProviderGrantTypeAuthorizationCode, OAuth2ProviderGrantTypeRefreshToken: + return true } + return false +} +// validateResponseTypes validates OAuth2 response types +func validateResponseTypes(responseTypes []OAuth2ProviderResponseType) error { for _, responseType := range responseTypes { - if !slices.Contains(validResponses, responseType) { + if !isSupportedResponseType(responseType) { return xerrors.Errorf("unsupported response type: %s", responseType) } } @@ -195,19 +190,34 @@ func validateResponseTypes(responseTypes []string) error { return nil } +func isSupportedResponseType(responseType OAuth2ProviderResponseType) bool { + return responseType == OAuth2ProviderResponseTypeCode +} + // validateTokenEndpointAuthMethod validates token endpoint authentication method -func validateTokenEndpointAuthMethod(method string) error { - validMethods := []string{ - "client_secret_post", - "client_secret_basic", - "none", // for public clients (RFC 7591) - // Add more methods as they are implemented - // "private_key_jwt", - // "client_secret_jwt", +func validateTokenEndpointAuthMethod(method OAuth2TokenEndpointAuthMethod) error { + if !method.Valid() { + return xerrors.Errorf("unsupported token endpoint auth method: %s", method) } - if !slices.Contains(validMethods, method) { - return xerrors.Errorf("unsupported token endpoint auth method: %s", method) + return nil +} + +// ValidatePKCECodeChallengeMethod validates PKCE code_challenge_method parameter. +// Per OAuth 2.1, only S256 is supported; plain is rejected for security reasons. +func ValidatePKCECodeChallengeMethod(method string) error { + if method == "" { + return nil // Optional, defaults to S256 if code_challenge is provided + } + + m := OAuth2PKCECodeChallengeMethod(method) + + if m == OAuth2PKCECodeChallengeMethodPlain { + return xerrors.New("code_challenge_method 'plain' is not supported; use 'S256'") + } + + if m != OAuth2PKCECodeChallengeMethodS256 { + return xerrors.Errorf("unsupported code_challenge_method: %s", method) } return nil diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md index b99868a259..1f4d739641 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -20,15 +20,15 @@ curl -X GET http://coder-server:8080/api/v2/.well-known/oauth-authorization-serv { "authorization_endpoint": "string", "code_challenge_methods_supported": [ - "string" + "S256" ], "grant_types_supported": [ - "string" + "authorization_code" ], "issuer": "string", "registration_endpoint": "string", "response_types_supported": [ - "string" + "code" ], "revocation_endpoint": "string", "scopes_supported": [ @@ -36,7 +36,7 @@ curl -X GET http://coder-server:8080/api/v2/.well-known/oauth-authorization-serv ], "token_endpoint": "string", "token_endpoint_auth_methods_supported": [ - "string" + "client_secret_basic" ] } ``` @@ -1265,9 +1265,9 @@ curl -X GET http://coder-server:8080/api/v2/oauth2/authorize?client_id=string&st #### Enumerated Values -| Parameter | Value(s) | -|-----------------|----------| -| `response_type` | `code` | +| Parameter | Value(s) | +|-----------------|-----------------| +| `response_type` | `code`, `token` | ### Responses @@ -1301,9 +1301,9 @@ curl -X POST http://coder-server:8080/api/v2/oauth2/authorize?client_id=string&s #### Enumerated Values -| Parameter | Value(s) | -|-----------------|----------| -| `response_type` | `code` | +| Parameter | Value(s) | +|-----------------|-----------------| +| `response_type` | `code`, `token` | ### Responses @@ -1346,7 +1346,7 @@ curl -X GET http://coder-server:8080/api/v2/oauth2/clients/{client_id} \ "string" ], "grant_types": [ - "string" + "authorization_code" ], "jwks": {}, "jwks_uri": "string", @@ -1355,17 +1355,15 @@ curl -X GET http://coder-server:8080/api/v2/oauth2/clients/{client_id} \ "redirect_uris": [ "string" ], - "registration_access_token": [ - 0 - ], + "registration_access_token": "string", "registration_client_uri": "string", "response_types": [ - "string" + "code" ], "scope": "string", "software_id": "string", "software_version": "string", - "token_endpoint_auth_method": "string", + "token_endpoint_auth_method": "client_secret_basic", "tos_uri": "string" } ``` @@ -1399,7 +1397,7 @@ curl -X PUT http://coder-server:8080/api/v2/oauth2/clients/{client_id} \ "string" ], "grant_types": [ - "string" + "authorization_code" ], "jwks": {}, "jwks_uri": "string", @@ -1409,13 +1407,13 @@ curl -X PUT http://coder-server:8080/api/v2/oauth2/clients/{client_id} \ "string" ], "response_types": [ - "string" + "code" ], "scope": "string", "software_id": "string", "software_statement": "string", "software_version": "string", - "token_endpoint_auth_method": "string", + "token_endpoint_auth_method": "client_secret_basic", "tos_uri": "string" } ``` @@ -1442,7 +1440,7 @@ curl -X PUT http://coder-server:8080/api/v2/oauth2/clients/{client_id} \ "string" ], "grant_types": [ - "string" + "authorization_code" ], "jwks": {}, "jwks_uri": "string", @@ -1451,17 +1449,15 @@ curl -X PUT http://coder-server:8080/api/v2/oauth2/clients/{client_id} \ "redirect_uris": [ "string" ], - "registration_access_token": [ - 0 - ], + "registration_access_token": "string", "registration_client_uri": "string", "response_types": [ - "string" + "code" ], "scope": "string", "software_id": "string", "software_version": "string", - "token_endpoint_auth_method": "string", + "token_endpoint_auth_method": "client_secret_basic", "tos_uri": "string" } ``` @@ -1519,7 +1515,7 @@ curl -X POST http://coder-server:8080/api/v2/oauth2/register \ "string" ], "grant_types": [ - "string" + "authorization_code" ], "jwks": {}, "jwks_uri": "string", @@ -1529,13 +1525,13 @@ curl -X POST http://coder-server:8080/api/v2/oauth2/register \ "string" ], "response_types": [ - "string" + "code" ], "scope": "string", "software_id": "string", "software_statement": "string", "software_version": "string", - "token_endpoint_auth_method": "string", + "token_endpoint_auth_method": "client_secret_basic", "tos_uri": "string" } ``` @@ -1562,7 +1558,7 @@ curl -X POST http://coder-server:8080/api/v2/oauth2/register \ "string" ], "grant_types": [ - "string" + "authorization_code" ], "jwks": {}, "jwks_uri": "string", @@ -1574,12 +1570,12 @@ curl -X POST http://coder-server:8080/api/v2/oauth2/register \ "registration_access_token": "string", "registration_client_uri": "string", "response_types": [ - "string" + "code" ], "scope": "string", "software_id": "string", "software_version": "string", - "token_endpoint_auth_method": "string", + "token_endpoint_auth_method": "client_secret_basic", "tos_uri": "string" } ``` @@ -1662,9 +1658,9 @@ grant_type: authorization_code #### Enumerated Values -| Parameter | Value(s) | -|----------------|---------------------------------------| -| `» grant_type` | `authorization_code`, `refresh_token` | +| Parameter | Value(s) | +|----------------|-------------------------------------------------------------------------------------| +| `» grant_type` | `authorization_code`, `client_credentials`, `implicit`, `password`, `refresh_token` | ### Example responses diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 010fc9dc3a..095512443a 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -5188,15 +5188,15 @@ Only certain features set these fields: - FeatureManagedAgentLimit| { "authorization_endpoint": "string", "code_challenge_methods_supported": [ - "string" + "S256" ], "grant_types_supported": [ - "string" + "authorization_code" ], "issuer": "string", "registration_endpoint": "string", "response_types_supported": [ - "string" + "code" ], "revocation_endpoint": "string", "scopes_supported": [ @@ -5204,25 +5204,25 @@ Only certain features set these fields: - FeatureManagedAgentLimit| ], "token_endpoint": "string", "token_endpoint_auth_methods_supported": [ - "string" + "client_secret_basic" ] } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -|-----------------------------------------|-----------------|----------|--------------|-------------| -| `authorization_endpoint` | string | false | | | -| `code_challenge_methods_supported` | array of string | false | | | -| `grant_types_supported` | array of string | false | | | -| `issuer` | string | false | | | -| `registration_endpoint` | string | false | | | -| `response_types_supported` | array of string | false | | | -| `revocation_endpoint` | string | false | | | -| `scopes_supported` | array of string | false | | | -| `token_endpoint` | string | false | | | -| `token_endpoint_auth_methods_supported` | array of string | false | | | +| Name | Type | Required | Restrictions | Description | +|-----------------------------------------|-------------------------------------------------------------------------------------------|----------|--------------|-------------| +| `authorization_endpoint` | string | false | | | +| `code_challenge_methods_supported` | array of [codersdk.OAuth2PKCECodeChallengeMethod](#codersdkoauth2pkcecodechallengemethod) | false | | | +| `grant_types_supported` | array of [codersdk.OAuth2ProviderGrantType](#codersdkoauth2providergranttype) | false | | | +| `issuer` | string | false | | | +| `registration_endpoint` | string | false | | | +| `response_types_supported` | array of [codersdk.OAuth2ProviderResponseType](#codersdkoauth2providerresponsetype) | false | | | +| `revocation_endpoint` | string | false | | | +| `scopes_supported` | array of string | false | | | +| `token_endpoint` | string | false | | | +| `token_endpoint_auth_methods_supported` | array of [codersdk.OAuth2TokenEndpointAuthMethod](#codersdkoauth2tokenendpointauthmethod) | false | | | ## codersdk.OAuth2ClientConfiguration @@ -5237,7 +5237,7 @@ Only certain features set these fields: - FeatureManagedAgentLimit| "string" ], "grant_types": [ - "string" + "authorization_code" ], "jwks": {}, "jwks_uri": "string", @@ -5246,45 +5246,43 @@ Only certain features set these fields: - FeatureManagedAgentLimit| "redirect_uris": [ "string" ], - "registration_access_token": [ - 0 - ], + "registration_access_token": "string", "registration_client_uri": "string", "response_types": [ - "string" + "code" ], "scope": "string", "software_id": "string", "software_version": "string", - "token_endpoint_auth_method": "string", + "token_endpoint_auth_method": "client_secret_basic", "tos_uri": "string" } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -|------------------------------|------------------|----------|--------------|-------------| -| `client_id` | string | false | | | -| `client_id_issued_at` | integer | false | | | -| `client_name` | string | false | | | -| `client_secret_expires_at` | integer | false | | | -| `client_uri` | string | false | | | -| `contacts` | array of string | false | | | -| `grant_types` | array of string | false | | | -| `jwks` | object | false | | | -| `jwks_uri` | string | false | | | -| `logo_uri` | string | false | | | -| `policy_uri` | string | false | | | -| `redirect_uris` | array of string | false | | | -| `registration_access_token` | array of integer | false | | | -| `registration_client_uri` | string | false | | | -| `response_types` | array of string | false | | | -| `scope` | string | false | | | -| `software_id` | string | false | | | -| `software_version` | string | false | | | -| `token_endpoint_auth_method` | string | false | | | -| `tos_uri` | string | false | | | +| Name | Type | Required | Restrictions | Description | +|------------------------------|-------------------------------------------------------------------------------------|----------|--------------|-------------| +| `client_id` | string | false | | | +| `client_id_issued_at` | integer | false | | | +| `client_name` | string | false | | | +| `client_secret_expires_at` | integer | false | | | +| `client_uri` | string | false | | | +| `contacts` | array of string | false | | | +| `grant_types` | array of [codersdk.OAuth2ProviderGrantType](#codersdkoauth2providergranttype) | false | | | +| `jwks` | object | false | | | +| `jwks_uri` | string | false | | | +| `logo_uri` | string | false | | | +| `policy_uri` | string | false | | | +| `redirect_uris` | array of string | false | | | +| `registration_access_token` | string | false | | | +| `registration_client_uri` | string | false | | | +| `response_types` | array of [codersdk.OAuth2ProviderResponseType](#codersdkoauth2providerresponsetype) | false | | | +| `scope` | string | false | | | +| `software_id` | string | false | | | +| `software_version` | string | false | | | +| `token_endpoint_auth_method` | [codersdk.OAuth2TokenEndpointAuthMethod](#codersdkoauth2tokenendpointauthmethod) | false | | | +| `tos_uri` | string | false | | | ## codersdk.OAuth2ClientRegistrationRequest @@ -5296,7 +5294,7 @@ Only certain features set these fields: - FeatureManagedAgentLimit| "string" ], "grant_types": [ - "string" + "authorization_code" ], "jwks": {}, "jwks_uri": "string", @@ -5306,37 +5304,37 @@ Only certain features set these fields: - FeatureManagedAgentLimit| "string" ], "response_types": [ - "string" + "code" ], "scope": "string", "software_id": "string", "software_statement": "string", "software_version": "string", - "token_endpoint_auth_method": "string", + "token_endpoint_auth_method": "client_secret_basic", "tos_uri": "string" } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -|------------------------------|-----------------|----------|--------------|-------------| -| `client_name` | string | false | | | -| `client_uri` | string | false | | | -| `contacts` | array of string | false | | | -| `grant_types` | array of string | false | | | -| `jwks` | object | false | | | -| `jwks_uri` | string | false | | | -| `logo_uri` | string | false | | | -| `policy_uri` | string | false | | | -| `redirect_uris` | array of string | false | | | -| `response_types` | array of string | false | | | -| `scope` | string | false | | | -| `software_id` | string | false | | | -| `software_statement` | string | false | | | -| `software_version` | string | false | | | -| `token_endpoint_auth_method` | string | false | | | -| `tos_uri` | string | false | | | +| Name | Type | Required | Restrictions | Description | +|------------------------------|-------------------------------------------------------------------------------------|----------|--------------|-------------| +| `client_name` | string | false | | | +| `client_uri` | string | false | | | +| `contacts` | array of string | false | | | +| `grant_types` | array of [codersdk.OAuth2ProviderGrantType](#codersdkoauth2providergranttype) | false | | | +| `jwks` | object | false | | | +| `jwks_uri` | string | false | | | +| `logo_uri` | string | false | | | +| `policy_uri` | string | false | | | +| `redirect_uris` | array of string | false | | | +| `response_types` | array of [codersdk.OAuth2ProviderResponseType](#codersdkoauth2providerresponsetype) | false | | | +| `scope` | string | false | | | +| `software_id` | string | false | | | +| `software_statement` | string | false | | | +| `software_version` | string | false | | | +| `token_endpoint_auth_method` | [codersdk.OAuth2TokenEndpointAuthMethod](#codersdkoauth2tokenendpointauthmethod) | false | | | +| `tos_uri` | string | false | | | ## codersdk.OAuth2ClientRegistrationResponse @@ -5352,7 +5350,7 @@ Only certain features set these fields: - FeatureManagedAgentLimit| "string" ], "grant_types": [ - "string" + "authorization_code" ], "jwks": {}, "jwks_uri": "string", @@ -5364,41 +5362,41 @@ Only certain features set these fields: - FeatureManagedAgentLimit| "registration_access_token": "string", "registration_client_uri": "string", "response_types": [ - "string" + "code" ], "scope": "string", "software_id": "string", "software_version": "string", - "token_endpoint_auth_method": "string", + "token_endpoint_auth_method": "client_secret_basic", "tos_uri": "string" } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -|------------------------------|-----------------|----------|--------------|-------------| -| `client_id` | string | false | | | -| `client_id_issued_at` | integer | false | | | -| `client_name` | string | false | | | -| `client_secret` | string | false | | | -| `client_secret_expires_at` | integer | false | | | -| `client_uri` | string | false | | | -| `contacts` | array of string | false | | | -| `grant_types` | array of string | false | | | -| `jwks` | object | false | | | -| `jwks_uri` | string | false | | | -| `logo_uri` | string | false | | | -| `policy_uri` | string | false | | | -| `redirect_uris` | array of string | false | | | -| `registration_access_token` | string | false | | | -| `registration_client_uri` | string | false | | | -| `response_types` | array of string | false | | | -| `scope` | string | false | | | -| `software_id` | string | false | | | -| `software_version` | string | false | | | -| `token_endpoint_auth_method` | string | false | | | -| `tos_uri` | string | false | | | +| Name | Type | Required | Restrictions | Description | +|------------------------------|-------------------------------------------------------------------------------------|----------|--------------|-------------| +| `client_id` | string | false | | | +| `client_id_issued_at` | integer | false | | | +| `client_name` | string | false | | | +| `client_secret` | string | false | | | +| `client_secret_expires_at` | integer | false | | | +| `client_uri` | string | false | | | +| `contacts` | array of string | false | | | +| `grant_types` | array of [codersdk.OAuth2ProviderGrantType](#codersdkoauth2providergranttype) | false | | | +| `jwks` | object | false | | | +| `jwks_uri` | string | false | | | +| `logo_uri` | string | false | | | +| `policy_uri` | string | false | | | +| `redirect_uris` | array of string | false | | | +| `registration_access_token` | string | false | | | +| `registration_client_uri` | string | false | | | +| `response_types` | array of [codersdk.OAuth2ProviderResponseType](#codersdkoauth2providerresponsetype) | false | | | +| `scope` | string | false | | | +| `software_id` | string | false | | | +| `software_version` | string | false | | | +| `token_endpoint_auth_method` | [codersdk.OAuth2TokenEndpointAuthMethod](#codersdkoauth2tokenendpointauthmethod) | false | | | +| `tos_uri` | string | false | | | ## codersdk.OAuth2Config @@ -5462,6 +5460,20 @@ Only certain features set these fields: - FeatureManagedAgentLimit| | `device_flow` | boolean | false | | | | `enterprise_base_url` | string | false | | | +## codersdk.OAuth2PKCECodeChallengeMethod + +```json +"S256" +``` + +### Properties + +#### Enumerated Values + +| Value(s) | +|-----------------| +| `S256`, `plain` | + ## codersdk.OAuth2ProtectedResourceMetadata ```json @@ -5549,6 +5561,48 @@ Only certain features set these fields: - FeatureManagedAgentLimit| | `client_secret_full` | string | false | | | | `id` | string | false | | | +## codersdk.OAuth2ProviderGrantType + +```json +"authorization_code" +``` + +### Properties + +#### Enumerated Values + +| Value(s) | +|-------------------------------------------------------------------------------------| +| `authorization_code`, `client_credentials`, `implicit`, `password`, `refresh_token` | + +## codersdk.OAuth2ProviderResponseType + +```json +"code" +``` + +### Properties + +#### Enumerated Values + +| Value(s) | +|-----------------| +| `code`, `token` | + +## codersdk.OAuth2TokenEndpointAuthMethod + +```json +"client_secret_basic" +``` + +### Properties + +#### Enumerated Values + +| Value(s) | +|-----------------------------------------------------| +| `client_secret_basic`, `client_secret_post`, `none` | + ## codersdk.OAuthConversionResponse ```json diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index f124b3132b..56fbc207ff 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2934,7 +2934,7 @@ export interface OAuth2AppEndpoints { // From codersdk/oauth2.go /** - * OAuth2AuthorizationServerMetadata represents RFC 8414 OAuth 2.0 Authorization Server Metadata + * OAuth2AuthorizationServerMetadata represents RFC 8414 OAuth 2.0 Authorization Server Metadata. */ export interface OAuth2AuthorizationServerMetadata { readonly issuer: string; @@ -2942,17 +2942,16 @@ export interface OAuth2AuthorizationServerMetadata { readonly token_endpoint: string; readonly registration_endpoint?: string; readonly revocation_endpoint?: string; - readonly response_types_supported: readonly string[]; - readonly grant_types_supported: readonly string[]; - readonly code_challenge_methods_supported: readonly string[]; + readonly response_types_supported: readonly OAuth2ProviderResponseType[]; + readonly grant_types_supported?: readonly OAuth2ProviderGrantType[]; + readonly code_challenge_methods_supported?: readonly OAuth2PKCECodeChallengeMethod[]; readonly scopes_supported?: readonly string[]; - readonly token_endpoint_auth_methods_supported?: readonly string[]; + readonly token_endpoint_auth_methods_supported?: readonly OAuth2TokenEndpointAuthMethod[]; } // From codersdk/oauth2.go /** - * OAuth2ClientConfiguration represents RFC 7592 Client Configuration (for GET/PUT operations) - * Same as OAuth2ClientRegistrationResponse but without client_secret in GET responses + * OAuth2ClientConfiguration represents RFC 7592 Client Read Response. */ export interface OAuth2ClientConfiguration { readonly client_id: string; @@ -2968,18 +2967,18 @@ export interface OAuth2ClientConfiguration { readonly jwks?: Record; readonly software_id?: string; readonly software_version?: string; - readonly grant_types: readonly string[]; - readonly response_types: readonly string[]; - readonly token_endpoint_auth_method: string; + readonly grant_types: readonly OAuth2ProviderGrantType[]; + readonly response_types: readonly OAuth2ProviderResponseType[]; + readonly token_endpoint_auth_method: OAuth2TokenEndpointAuthMethod; readonly scope?: string; readonly contacts?: readonly string[]; - readonly registration_access_token: string; + readonly registration_access_token?: string; readonly registration_client_uri: string; } // From codersdk/oauth2.go /** - * OAuth2ClientRegistrationRequest represents RFC 7591 Dynamic Client Registration Request + * OAuth2ClientRegistrationRequest represents RFC 7591 Dynamic Client Registration Request. */ export interface OAuth2ClientRegistrationRequest { readonly redirect_uris?: readonly string[]; @@ -2993,21 +2992,21 @@ export interface OAuth2ClientRegistrationRequest { readonly software_id?: string; readonly software_version?: string; readonly software_statement?: string; - readonly grant_types?: readonly string[]; - readonly response_types?: readonly string[]; - readonly token_endpoint_auth_method?: string; + readonly grant_types?: readonly OAuth2ProviderGrantType[]; + readonly response_types?: readonly OAuth2ProviderResponseType[]; + readonly token_endpoint_auth_method?: OAuth2TokenEndpointAuthMethod; readonly scope?: string; readonly contacts?: readonly string[]; } // From codersdk/oauth2.go /** - * OAuth2ClientRegistrationResponse represents RFC 7591 Dynamic Client Registration Response + * OAuth2ClientRegistrationResponse represents RFC 7591 Dynamic Client Registration Response. */ export interface OAuth2ClientRegistrationResponse { readonly client_id: string; readonly client_secret?: string; - readonly client_id_issued_at: number; + readonly client_id_issued_at?: number; readonly client_secret_expires_at?: number; readonly redirect_uris?: readonly string[]; readonly client_name?: string; @@ -3019,9 +3018,9 @@ export interface OAuth2ClientRegistrationResponse { readonly jwks?: Record; readonly software_id?: string; readonly software_version?: string; - readonly grant_types: readonly string[]; - readonly response_types: readonly string[]; - readonly token_endpoint_auth_method: string; + readonly grant_types: readonly OAuth2ProviderGrantType[]; + readonly response_types: readonly OAuth2ProviderResponseType[]; + readonly token_endpoint_auth_method: OAuth2TokenEndpointAuthMethod; readonly scope?: string; readonly contacts?: readonly string[]; readonly registration_access_token: string; @@ -3038,6 +3037,46 @@ export interface OAuth2DeviceFlowCallbackResponse { readonly redirect_url: string; } +// From codersdk/oauth2.go +/** + * OAuth2Error represents an OAuth2-compliant error response per RFC 6749. + */ +export interface OAuth2Error { + readonly error: OAuth2ErrorCode; + readonly error_description?: string; + readonly error_uri?: string; +} + +// From codersdk/oauth2.go +export type OAuth2ErrorCode = + | "access_denied" + | "invalid_client" + | "invalid_grant" + | "invalid_request" + | "invalid_scope" + | "invalid_target" + | "server_error" + | "temporarily_unavailable" + | "unauthorized_client" + | "unsupported_grant_type" + | "unsupported_response_type" + | "unsupported_token_type"; + +export const OAuth2ErrorCodes: OAuth2ErrorCode[] = [ + "access_denied", + "invalid_client", + "invalid_grant", + "invalid_request", + "invalid_scope", + "invalid_target", + "server_error", + "temporarily_unavailable", + "unauthorized_client", + "unsupported_grant_type", + "unsupported_response_type", + "unsupported_token_type", +]; + // From codersdk/deployment.go export interface OAuth2GithubConfig { readonly client_id: string; @@ -3051,6 +3090,14 @@ export interface OAuth2GithubConfig { readonly enterprise_base_url: string; } +// From codersdk/oauth2.go +export type OAuth2PKCECodeChallengeMethod = "plain" | "S256"; + +export const OAuth2PKCECodeChallengeMethods: OAuth2PKCECodeChallengeMethod[] = [ + "plain", + "S256", +]; + // From codersdk/client.go /** * OAuth2PKCEVerifier is the name of the cookie that stores the oauth2 PKCE @@ -3103,18 +3150,27 @@ export interface OAuth2ProviderAppSecretFull { } // From codersdk/oauth2.go -export type OAuth2ProviderGrantType = "authorization_code" | "refresh_token"; +export type OAuth2ProviderGrantType = + | "authorization_code" + | "client_credentials" + | "implicit" + | "password" + | "refresh_token"; export const OAuth2ProviderGrantTypes: OAuth2ProviderGrantType[] = [ "authorization_code", + "client_credentials", + "implicit", + "password", "refresh_token", ]; // From codersdk/oauth2.go -export type OAuth2ProviderResponseType = "code"; +export type OAuth2ProviderResponseType = "code" | "token"; export const OAuth2ProviderResponseTypes: OAuth2ProviderResponseType[] = [ "code", + "token", ]; // From codersdk/client.go @@ -3123,12 +3179,82 @@ export const OAuth2ProviderResponseTypes: OAuth2ProviderResponseType[] = [ */ export const OAuth2RedirectCookie = "oauth_redirect"; +// From codersdk/oauth2.go +export type OAuth2RevocationTokenTypeHint = "access_token" | "refresh_token"; + +export const OAuth2RevocationTokenTypeHints: OAuth2RevocationTokenTypeHint[] = [ + "access_token", + "refresh_token", +]; + // From codersdk/client.go /** * OAuth2StateCookie is the name of the cookie that stores the oauth2 state. */ export const OAuth2StateCookie = "oauth_state"; +// From codersdk/oauth2.go +export type OAuth2TokenEndpointAuthMethod = + | "client_secret_basic" + | "client_secret_post" + | "none"; + +export const OAuth2TokenEndpointAuthMethods: OAuth2TokenEndpointAuthMethod[] = [ + "client_secret_basic", + "client_secret_post", + "none", +]; + +// From codersdk/oauth2.go +/** + * OAuth2TokenRequest represents a token request per RFC 6749. The actual wire + * format is application/x-www-form-urlencoded; this struct is for SDK docs. + */ +export interface OAuth2TokenRequest { + readonly grant_type: OAuth2ProviderGrantType; + readonly code?: string; + readonly redirect_uri?: string; + readonly client_id?: string; + readonly client_secret?: string; + readonly code_verifier?: string; + readonly refresh_token?: string; + readonly resource?: string; + readonly scope?: string; +} + +// From codersdk/oauth2.go +/** + * OAuth2TokenResponse represents a successful token response per RFC 6749. + */ +export interface OAuth2TokenResponse { + readonly access_token: string; + readonly token_type: OAuth2TokenType; + readonly expires_in?: number; + readonly refresh_token?: string; + readonly scope?: string; + /** + * Expiry is not part of RFC 6749 but is included for compatibility with + * golang.org/x/oauth2.Token and clients that expect a timestamp. + */ + readonly expiry?: string; +} + +// From codersdk/oauth2.go +/** + * OAuth2TokenRevocationRequest represents a token revocation request per RFC 7009. + */ +export interface OAuth2TokenRevocationRequest { + readonly token: string; + readonly token_type_hint?: OAuth2RevocationTokenTypeHint; + readonly client_id?: string; + readonly client_secret?: string; +} + +// From codersdk/oauth2.go +export type OAuth2TokenType = "Bearer" | "DPoP"; + +export const OAuth2TokenTypes: OAuth2TokenType[] = ["Bearer", "DPoP"]; + // From codersdk/users.go export interface OAuthConversionResponse { readonly state_string: string;