From c65013384a56b7f53267c4a599b9929090e79a9e Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 3 Jul 2025 20:24:45 +0200 Subject: [PATCH] refactor: move OAuth2 provider code to dedicated package (#18746) # Refactor OAuth2 Provider Code into Dedicated Package This PR refactors the OAuth2 provider functionality by moving it from the main `coderd` package into a dedicated `oauth2provider` package. The change improves code organization and maintainability without changing functionality. Key changes: - Created a new `oauth2provider` package to house all OAuth2 provider-related code - Moved existing OAuth2 provider functionality from `coderd/identityprovider` to the new package - Refactored handler functions to follow a consistent pattern of returning `http.HandlerFunc` instead of being handlers directly - Split large files into smaller, more focused files organized by functionality: - `app_secrets.go` - Manages OAuth2 application secrets - `apps.go` - Handles OAuth2 application CRUD operations - `authorize.go` - Implements the authorization flow - `metadata.go` - Provides OAuth2 metadata endpoints - `registration.go` - Handles dynamic client registration - `revoke.go` - Implements token revocation - `secrets.go` - Manages secret generation and validation - `tokens.go` - Handles token issuance and validation This refactoring improves code organization and makes the OAuth2 provider functionality more maintainable while preserving all existing behavior. --- coderd/coderd.go | 31 +- coderd/oauth2.go | 880 +----------------- coderd/oauth2_test.go | 4 +- coderd/oauth2provider/app_secrets.go | 116 +++ coderd/oauth2provider/apps.go | 208 +++++ .../authorize.go | 2 +- coderd/oauth2provider/metadata.go | 45 + .../middleware.go | 2 +- .../oauth2providertest}/fixtures.go | 2 +- .../oauth2providertest}/helpers.go | 4 +- .../oauth2providertest}/oauth2_test.go | 138 +-- .../pkce.go | 2 +- .../pkce_test.go | 8 +- coderd/oauth2provider/registration.go | 584 ++++++++++++ .../revoke.go | 2 +- .../secrets.go | 40 +- .../tokens.go | 8 +- 17 files changed, 1095 insertions(+), 981 deletions(-) create mode 100644 coderd/oauth2provider/app_secrets.go create mode 100644 coderd/oauth2provider/apps.go rename coderd/{identityprovider => oauth2provider}/authorize.go (99%) create mode 100644 coderd/oauth2provider/metadata.go rename coderd/{identityprovider => oauth2provider}/middleware.go (99%) rename coderd/{identityprovider/identityprovidertest => oauth2provider/oauth2providertest}/fixtures.go (97%) rename coderd/{identityprovider/identityprovidertest => oauth2provider/oauth2providertest}/helpers.go (98%) rename coderd/{identityprovider/identityprovidertest => oauth2provider/oauth2providertest}/oauth2_test.go (60%) rename coderd/{identityprovider => oauth2provider}/pkce.go (95%) rename coderd/{identityprovider => oauth2provider}/pkce_test.go (88%) create mode 100644 coderd/oauth2provider/registration.go rename coderd/{identityprovider => oauth2provider}/revoke.go (97%) rename coderd/{identityprovider => oauth2provider}/secrets.go (57%) rename coderd/{identityprovider => oauth2provider}/tokens.go (98%) diff --git a/coderd/coderd.go b/coderd/coderd.go index 08915bc29d..72316d1ea1 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -19,6 +19,7 @@ import ( "sync/atomic" "time" + "github.com/coder/coder/v2/coderd/oauth2provider" "github.com/coder/coder/v2/coderd/prebuilds" "github.com/andybalholm/brotli" @@ -913,9 +914,9 @@ func New(options *Options) *API { } // OAuth2 metadata endpoint for RFC 8414 discovery - r.Get("/.well-known/oauth-authorization-server", api.oauth2AuthorizationServerMetadata) + r.Get("/.well-known/oauth-authorization-server", api.oauth2AuthorizationServerMetadata()) // OAuth2 protected resource metadata endpoint for RFC 9728 discovery - r.Get("/.well-known/oauth-protected-resource", api.oauth2ProtectedResourceMetadata) + r.Get("/.well-known/oauth-protected-resource", api.oauth2ProtectedResourceMetadata()) // OAuth2 linking routes do not make sense under the /api/v2 path. These are // for an external application to use Coder as an OAuth2 provider, not for @@ -952,17 +953,17 @@ func New(options *Options) *API { }) // RFC 7591 Dynamic Client Registration - Public endpoint - r.Post("/register", api.postOAuth2ClientRegistration) + r.Post("/register", api.postOAuth2ClientRegistration()) // RFC 7592 Client Configuration Management - Protected by registration access token r.Route("/clients/{client_id}", func(r chi.Router) { r.Use( // Middleware to validate registration access token - api.requireRegistrationAccessToken, + oauth2provider.RequireRegistrationAccessToken(api.Database), ) - r.Get("/", api.oauth2ClientConfiguration) // Read client configuration - r.Put("/", api.putOAuth2ClientConfiguration) // Update client configuration - r.Delete("/", api.deleteOAuth2ClientConfiguration) // Delete client + r.Get("/", api.oauth2ClientConfiguration()) // Read client configuration + r.Put("/", api.putOAuth2ClientConfiguration()) // Update client configuration + r.Delete("/", api.deleteOAuth2ClientConfiguration()) // Delete client }) }) @@ -1479,22 +1480,22 @@ func New(options *Options) *API { httpmw.RequireExperimentWithDevBypass(api.Experiments, codersdk.ExperimentOAuth2), ) r.Route("/apps", func(r chi.Router) { - r.Get("/", api.oAuth2ProviderApps) - r.Post("/", api.postOAuth2ProviderApp) + r.Get("/", api.oAuth2ProviderApps()) + r.Post("/", api.postOAuth2ProviderApp()) r.Route("/{app}", func(r chi.Router) { r.Use(httpmw.ExtractOAuth2ProviderApp(options.Database)) - r.Get("/", api.oAuth2ProviderApp) - r.Put("/", api.putOAuth2ProviderApp) - r.Delete("/", api.deleteOAuth2ProviderApp) + r.Get("/", api.oAuth2ProviderApp()) + r.Put("/", api.putOAuth2ProviderApp()) + r.Delete("/", api.deleteOAuth2ProviderApp()) r.Route("/secrets", func(r chi.Router) { - r.Get("/", api.oAuth2ProviderAppSecrets) - r.Post("/", api.postOAuth2ProviderAppSecret) + r.Get("/", api.oAuth2ProviderAppSecrets()) + r.Post("/", api.postOAuth2ProviderAppSecret()) r.Route("/{secretID}", func(r chi.Router) { r.Use(httpmw.ExtractOAuth2ProviderAppSecret(options.Database)) - r.Delete("/", api.deleteOAuth2ProviderAppSecret) + r.Delete("/", api.deleteOAuth2ProviderAppSecret()) }) }) }) diff --git a/coderd/oauth2.go b/coderd/oauth2.go index 88f108c5fc..9195876b9e 100644 --- a/coderd/oauth2.go +++ b/coderd/oauth2.go @@ -1,39 +1,9 @@ package coderd import ( - "context" - "database/sql" - "encoding/json" - "fmt" "net/http" - "strings" - "github.com/go-chi/chi/v5" - "github.com/google/uuid" - "golang.org/x/xerrors" - - "cdr.dev/slog" - - "github.com/sqlc-dev/pqtype" - - "github.com/coder/coder/v2/coderd/audit" - "github.com/coder/coder/v2/coderd/database" - "github.com/coder/coder/v2/coderd/database/db2sdk" - "github.com/coder/coder/v2/coderd/database/dbauthz" - "github.com/coder/coder/v2/coderd/database/dbtime" - "github.com/coder/coder/v2/coderd/httpapi" - "github.com/coder/coder/v2/coderd/httpmw" - "github.com/coder/coder/v2/coderd/identityprovider" - "github.com/coder/coder/v2/coderd/userpassword" - "github.com/coder/coder/v2/codersdk" - "github.com/coder/coder/v2/cryptorand" -) - -// Constants for OAuth2 secret generation (RFC 7591) -const ( - secretLength = 40 // Length of the actual secret part - secretPrefixLength = 10 // Length of the prefix for database lookup - displaySecretLength = 6 // Length of visible part in UI (last 6 characters) + "github.com/coder/coder/v2/coderd/oauth2provider" ) // @Summary Get OAuth2 applications. @@ -44,40 +14,8 @@ const ( // @Param user_id query string false "Filter by applications authorized for a user" // @Success 200 {array} codersdk.OAuth2ProviderApp // @Router /oauth2-provider/apps [get] -func (api *API) oAuth2ProviderApps(rw http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - rawUserID := r.URL.Query().Get("user_id") - if rawUserID == "" { - dbApps, err := api.Database.GetOAuth2ProviderApps(ctx) - if err != nil { - httpapi.InternalServerError(rw, err) - return - } - httpapi.Write(ctx, rw, http.StatusOK, db2sdk.OAuth2ProviderApps(api.AccessURL, dbApps)) - return - } - - userID, err := uuid.Parse(rawUserID) - if err != nil { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Invalid user UUID", - Detail: fmt.Sprintf("queried user_id=%q", userID), - }) - return - } - - userApps, err := api.Database.GetOAuth2ProviderAppsByUserID(ctx, userID) - if err != nil { - httpapi.InternalServerError(rw, err) - return - } - - var sdkApps []codersdk.OAuth2ProviderApp - for _, app := range userApps { - sdkApps = append(sdkApps, db2sdk.OAuth2ProviderApp(api.AccessURL, app.OAuth2ProviderApp)) - } - httpapi.Write(ctx, rw, http.StatusOK, sdkApps) +func (api *API) oAuth2ProviderApps() http.HandlerFunc { + return oauth2provider.ListApps(api.Database, api.AccessURL) } // @Summary Get OAuth2 application. @@ -88,10 +26,8 @@ func (api *API) oAuth2ProviderApps(rw http.ResponseWriter, r *http.Request) { // @Param app path string true "App ID" // @Success 200 {object} codersdk.OAuth2ProviderApp // @Router /oauth2-provider/apps/{app} [get] -func (api *API) oAuth2ProviderApp(rw http.ResponseWriter, r *http.Request) { - ctx := r.Context() - app := httpmw.OAuth2ProviderApp(r) - httpapi.Write(ctx, rw, http.StatusOK, db2sdk.OAuth2ProviderApp(api.AccessURL, app)) +func (api *API) oAuth2ProviderApp() http.HandlerFunc { + return oauth2provider.GetApp(api.AccessURL) } // @Summary Create OAuth2 application. @@ -103,59 +39,8 @@ func (api *API) oAuth2ProviderApp(rw http.ResponseWriter, r *http.Request) { // @Param request body codersdk.PostOAuth2ProviderAppRequest true "The OAuth2 application to create." // @Success 200 {object} codersdk.OAuth2ProviderApp // @Router /oauth2-provider/apps [post] -func (api *API) postOAuth2ProviderApp(rw http.ResponseWriter, r *http.Request) { - var ( - ctx = r.Context() - auditor = api.Auditor.Load() - aReq, commitAudit = audit.InitRequest[database.OAuth2ProviderApp](rw, &audit.RequestParams{ - Audit: *auditor, - Log: api.Logger, - Request: r, - Action: database.AuditActionCreate, - }) - ) - defer commitAudit() - var req codersdk.PostOAuth2ProviderAppRequest - if !httpapi.Read(ctx, rw, r, &req) { - return - } - app, err := api.Database.InsertOAuth2ProviderApp(ctx, database.InsertOAuth2ProviderAppParams{ - ID: uuid.New(), - CreatedAt: dbtime.Now(), - UpdatedAt: dbtime.Now(), - Name: req.Name, - Icon: req.Icon, - CallbackURL: req.CallbackURL, - RedirectUris: []string{}, - ClientType: sql.NullString{String: "confidential", Valid: true}, - DynamicallyRegistered: sql.NullBool{Bool: false, Valid: true}, - ClientIDIssuedAt: sql.NullTime{}, - ClientSecretExpiresAt: sql.NullTime{}, - GrantTypes: []string{"authorization_code", "refresh_token"}, - ResponseTypes: []string{"code"}, - TokenEndpointAuthMethod: sql.NullString{String: "client_secret_post", Valid: true}, - Scope: sql.NullString{}, - Contacts: []string{}, - ClientUri: sql.NullString{}, - LogoUri: sql.NullString{}, - TosUri: sql.NullString{}, - PolicyUri: sql.NullString{}, - JwksUri: sql.NullString{}, - Jwks: pqtype.NullRawMessage{}, - SoftwareID: sql.NullString{}, - SoftwareVersion: sql.NullString{}, - RegistrationAccessToken: sql.NullString{}, - RegistrationClientUri: sql.NullString{}, - }) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error creating OAuth2 application.", - Detail: err.Error(), - }) - return - } - aReq.New = app - httpapi.Write(ctx, rw, http.StatusCreated, db2sdk.OAuth2ProviderApp(api.AccessURL, app)) +func (api *API) postOAuth2ProviderApp() http.HandlerFunc { + return oauth2provider.CreateApp(api.Database, api.AccessURL, api.Auditor.Load(), api.Logger) } // @Summary Update OAuth2 application. @@ -168,57 +53,8 @@ func (api *API) postOAuth2ProviderApp(rw http.ResponseWriter, r *http.Request) { // @Param request body codersdk.PutOAuth2ProviderAppRequest true "Update an OAuth2 application." // @Success 200 {object} codersdk.OAuth2ProviderApp // @Router /oauth2-provider/apps/{app} [put] -func (api *API) putOAuth2ProviderApp(rw http.ResponseWriter, r *http.Request) { - var ( - ctx = r.Context() - app = httpmw.OAuth2ProviderApp(r) - auditor = api.Auditor.Load() - aReq, commitAudit = audit.InitRequest[database.OAuth2ProviderApp](rw, &audit.RequestParams{ - Audit: *auditor, - Log: api.Logger, - Request: r, - Action: database.AuditActionWrite, - }) - ) - aReq.Old = app - defer commitAudit() - var req codersdk.PutOAuth2ProviderAppRequest - if !httpapi.Read(ctx, rw, r, &req) { - return - } - app, err := api.Database.UpdateOAuth2ProviderAppByID(ctx, database.UpdateOAuth2ProviderAppByIDParams{ - ID: app.ID, - UpdatedAt: dbtime.Now(), - Name: req.Name, - Icon: req.Icon, - CallbackURL: req.CallbackURL, - RedirectUris: app.RedirectUris, // Keep existing value - ClientType: app.ClientType, // Keep existing value - DynamicallyRegistered: app.DynamicallyRegistered, // Keep existing value - ClientSecretExpiresAt: app.ClientSecretExpiresAt, // Keep existing value - GrantTypes: app.GrantTypes, // Keep existing value - ResponseTypes: app.ResponseTypes, // Keep existing value - TokenEndpointAuthMethod: app.TokenEndpointAuthMethod, // Keep existing value - Scope: app.Scope, // Keep existing value - Contacts: app.Contacts, // Keep existing value - ClientUri: app.ClientUri, // Keep existing value - LogoUri: app.LogoUri, // Keep existing value - TosUri: app.TosUri, // Keep existing value - PolicyUri: app.PolicyUri, // Keep existing value - JwksUri: app.JwksUri, // Keep existing value - Jwks: app.Jwks, // Keep existing value - SoftwareID: app.SoftwareID, // Keep existing value - SoftwareVersion: app.SoftwareVersion, // Keep existing value - }) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error updating OAuth2 application.", - Detail: err.Error(), - }) - return - } - aReq.New = app - httpapi.Write(ctx, rw, http.StatusOK, db2sdk.OAuth2ProviderApp(api.AccessURL, app)) +func (api *API) putOAuth2ProviderApp() http.HandlerFunc { + return oauth2provider.UpdateApp(api.Database, api.AccessURL, api.Auditor.Load(), api.Logger) } // @Summary Delete OAuth2 application. @@ -228,29 +64,8 @@ func (api *API) putOAuth2ProviderApp(rw http.ResponseWriter, r *http.Request) { // @Param app path string true "App ID" // @Success 204 // @Router /oauth2-provider/apps/{app} [delete] -func (api *API) deleteOAuth2ProviderApp(rw http.ResponseWriter, r *http.Request) { - var ( - ctx = r.Context() - app = httpmw.OAuth2ProviderApp(r) - auditor = api.Auditor.Load() - aReq, commitAudit = audit.InitRequest[database.OAuth2ProviderApp](rw, &audit.RequestParams{ - Audit: *auditor, - Log: api.Logger, - Request: r, - Action: database.AuditActionDelete, - }) - ) - aReq.Old = app - defer commitAudit() - err := api.Database.DeleteOAuth2ProviderAppByID(ctx, app.ID) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error deleting OAuth2 application.", - Detail: err.Error(), - }) - return - } - rw.WriteHeader(http.StatusNoContent) +func (api *API) deleteOAuth2ProviderApp() http.HandlerFunc { + return oauth2provider.DeleteApp(api.Database, api.Auditor.Load(), api.Logger) } // @Summary Get OAuth2 application secrets. @@ -261,26 +76,8 @@ func (api *API) deleteOAuth2ProviderApp(rw http.ResponseWriter, r *http.Request) // @Param app path string true "App ID" // @Success 200 {array} codersdk.OAuth2ProviderAppSecret // @Router /oauth2-provider/apps/{app}/secrets [get] -func (api *API) oAuth2ProviderAppSecrets(rw http.ResponseWriter, r *http.Request) { - ctx := r.Context() - app := httpmw.OAuth2ProviderApp(r) - dbSecrets, err := api.Database.GetOAuth2ProviderAppSecretsByAppID(ctx, app.ID) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error getting OAuth2 client secrets.", - Detail: err.Error(), - }) - return - } - secrets := []codersdk.OAuth2ProviderAppSecret{} - for _, secret := range dbSecrets { - secrets = append(secrets, codersdk.OAuth2ProviderAppSecret{ - ID: secret.ID, - LastUsedAt: codersdk.NullTime{NullTime: secret.LastUsedAt}, - ClientSecretTruncated: secret.DisplaySecret, - }) - } - httpapi.Write(ctx, rw, http.StatusOK, secrets) +func (api *API) oAuth2ProviderAppSecrets() http.HandlerFunc { + return oauth2provider.GetAppSecrets(api.Database) } // @Summary Create OAuth2 application secret. @@ -291,50 +88,8 @@ func (api *API) oAuth2ProviderAppSecrets(rw http.ResponseWriter, r *http.Request // @Param app path string true "App ID" // @Success 200 {array} codersdk.OAuth2ProviderAppSecretFull // @Router /oauth2-provider/apps/{app}/secrets [post] -func (api *API) postOAuth2ProviderAppSecret(rw http.ResponseWriter, r *http.Request) { - var ( - ctx = r.Context() - app = httpmw.OAuth2ProviderApp(r) - auditor = api.Auditor.Load() - aReq, commitAudit = audit.InitRequest[database.OAuth2ProviderAppSecret](rw, &audit.RequestParams{ - Audit: *auditor, - Log: api.Logger, - Request: r, - Action: database.AuditActionCreate, - }) - ) - defer commitAudit() - secret, err := identityprovider.GenerateSecret() - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to generate OAuth2 client secret.", - Detail: err.Error(), - }) - return - } - dbSecret, err := api.Database.InsertOAuth2ProviderAppSecret(ctx, database.InsertOAuth2ProviderAppSecretParams{ - ID: uuid.New(), - CreatedAt: dbtime.Now(), - SecretPrefix: []byte(secret.Prefix), - HashedSecret: []byte(secret.Hashed), - // DisplaySecret is the last six characters of the original unhashed secret. - // This is done so they can be differentiated and it matches how GitHub - // displays their client secrets. - DisplaySecret: secret.Formatted[len(secret.Formatted)-6:], - AppID: app.ID, - }) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error creating OAuth2 client secret.", - Detail: err.Error(), - }) - return - } - aReq.New = dbSecret - httpapi.Write(ctx, rw, http.StatusCreated, codersdk.OAuth2ProviderAppSecretFull{ - ID: dbSecret.ID, - ClientSecretFull: secret.Formatted, - }) +func (api *API) postOAuth2ProviderAppSecret() http.HandlerFunc { + return oauth2provider.CreateAppSecret(api.Database, api.Auditor.Load(), api.Logger) } // @Summary Delete OAuth2 application secret. @@ -345,29 +100,8 @@ func (api *API) postOAuth2ProviderAppSecret(rw http.ResponseWriter, r *http.Requ // @Param secretID path string true "Secret ID" // @Success 204 // @Router /oauth2-provider/apps/{app}/secrets/{secretID} [delete] -func (api *API) deleteOAuth2ProviderAppSecret(rw http.ResponseWriter, r *http.Request) { - var ( - ctx = r.Context() - secret = httpmw.OAuth2ProviderAppSecret(r) - auditor = api.Auditor.Load() - aReq, commitAudit = audit.InitRequest[database.OAuth2ProviderAppSecret](rw, &audit.RequestParams{ - Audit: *auditor, - Log: api.Logger, - Request: r, - Action: database.AuditActionDelete, - }) - ) - aReq.Old = secret - defer commitAudit() - err := api.Database.DeleteOAuth2ProviderAppSecretByID(ctx, secret.ID) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error deleting OAuth2 client secret.", - Detail: err.Error(), - }) - return - } - rw.WriteHeader(http.StatusNoContent) +func (api *API) deleteOAuth2ProviderAppSecret() http.HandlerFunc { + return oauth2provider.DeleteAppSecret(api.Database, api.Auditor.Load(), api.Logger) } // @Summary OAuth2 authorization request (GET - show authorization page). @@ -382,7 +116,7 @@ func (api *API) deleteOAuth2ProviderAppSecret(rw http.ResponseWriter, r *http.Re // @Success 200 "Returns HTML authorization page" // @Router /oauth2/authorize [get] func (api *API) getOAuth2ProviderAppAuthorize() http.HandlerFunc { - return identityprovider.ShowAuthorizePage(api.Database, api.AccessURL) + return oauth2provider.ShowAuthorizePage(api.Database, api.AccessURL) } // @Summary OAuth2 authorization request (POST - process authorization). @@ -397,7 +131,7 @@ func (api *API) getOAuth2ProviderAppAuthorize() http.HandlerFunc { // @Success 302 "Returns redirect with authorization code" // @Router /oauth2/authorize [post] func (api *API) postOAuth2ProviderAppAuthorize() http.HandlerFunc { - return identityprovider.ProcessAuthorize(api.Database, api.AccessURL) + return oauth2provider.ProcessAuthorize(api.Database, api.AccessURL) } // @Summary OAuth2 token exchange. @@ -412,7 +146,7 @@ func (api *API) postOAuth2ProviderAppAuthorize() http.HandlerFunc { // @Success 200 {object} oauth2.Token // @Router /oauth2/tokens [post] func (api *API) postOAuth2ProviderAppToken() http.HandlerFunc { - return identityprovider.Tokens(api.Database, api.DeploymentValues.Sessions) + return oauth2provider.Tokens(api.Database, api.DeploymentValues.Sessions) } // @Summary Delete OAuth2 application tokens. @@ -423,7 +157,7 @@ func (api *API) postOAuth2ProviderAppToken() http.HandlerFunc { // @Success 204 // @Router /oauth2/tokens [delete] func (api *API) deleteOAuth2ProviderAppTokens() http.HandlerFunc { - return identityprovider.RevokeApp(api.Database) + return oauth2provider.RevokeApp(api.Database) } // @Summary OAuth2 authorization server metadata. @@ -432,21 +166,8 @@ func (api *API) deleteOAuth2ProviderAppTokens() http.HandlerFunc { // @Tags Enterprise // @Success 200 {object} codersdk.OAuth2AuthorizationServerMetadata // @Router /.well-known/oauth-authorization-server [get] -func (api *API) oauth2AuthorizationServerMetadata(rw http.ResponseWriter, r *http.Request) { - ctx := r.Context() - metadata := codersdk.OAuth2AuthorizationServerMetadata{ - Issuer: api.AccessURL.String(), - AuthorizationEndpoint: api.AccessURL.JoinPath("/oauth2/authorize").String(), - TokenEndpoint: api.AccessURL.JoinPath("/oauth2/tokens").String(), - RegistrationEndpoint: api.AccessURL.JoinPath("/oauth2/register").String(), // RFC 7591 - ResponseTypesSupported: []string{"code"}, - GrantTypesSupported: []string{"authorization_code", "refresh_token"}, - CodeChallengeMethodsSupported: []string{"S256"}, - // TODO: Implement scope system - ScopesSupported: []string{}, - TokenEndpointAuthMethodsSupported: []string{"client_secret_post"}, - } - httpapi.Write(ctx, rw, http.StatusOK, metadata) +func (api *API) oauth2AuthorizationServerMetadata() http.HandlerFunc { + return oauth2provider.GetAuthorizationServerMetadata(api.AccessURL) } // @Summary OAuth2 protected resource metadata. @@ -455,17 +176,8 @@ func (api *API) oauth2AuthorizationServerMetadata(rw http.ResponseWriter, r *htt // @Tags Enterprise // @Success 200 {object} codersdk.OAuth2ProtectedResourceMetadata // @Router /.well-known/oauth-protected-resource [get] -func (api *API) oauth2ProtectedResourceMetadata(rw http.ResponseWriter, r *http.Request) { - ctx := r.Context() - metadata := codersdk.OAuth2ProtectedResourceMetadata{ - Resource: api.AccessURL.String(), - AuthorizationServers: []string{api.AccessURL.String()}, - // TODO: Implement scope system based on RBAC permissions - ScopesSupported: []string{}, - // RFC 6750 Bearer Token methods supported as fallback methods in api key middleware - BearerMethodsSupported: []string{"header", "query"}, - } - httpapi.Write(ctx, rw, http.StatusOK, metadata) +func (api *API) oauth2ProtectedResourceMetadata() http.HandlerFunc { + return oauth2provider.GetProtectedResourceMetadata(api.AccessURL) } // @Summary OAuth2 dynamic client registration (RFC 7591) @@ -476,225 +188,10 @@ func (api *API) oauth2ProtectedResourceMetadata(rw http.ResponseWriter, r *http. // @Param request body codersdk.OAuth2ClientRegistrationRequest true "Client registration request" // @Success 201 {object} codersdk.OAuth2ClientRegistrationResponse // @Router /oauth2/register [post] -func (api *API) postOAuth2ClientRegistration(rw http.ResponseWriter, r *http.Request) { - ctx := r.Context() - auditor := *api.Auditor.Load() - aReq, commitAudit := audit.InitRequest[database.OAuth2ProviderApp](rw, &audit.RequestParams{ - Audit: auditor, - Log: api.Logger, - Request: r, - Action: database.AuditActionCreate, - }) - defer commitAudit() - - // Parse request - var req codersdk.OAuth2ClientRegistrationRequest - if !httpapi.Read(ctx, rw, r, &req) { - return - } - - // Validate request - if err := req.Validate(); err != nil { - writeOAuth2RegistrationError(ctx, rw, http.StatusBadRequest, - "invalid_client_metadata", err.Error()) - return - } - - // Apply defaults - req = req.ApplyDefaults() - - // Generate client credentials - clientID := uuid.New() - clientSecret, hashedSecret, err := generateClientCredentials() - if err != nil { - writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError, - "server_error", "Failed to generate client credentials") - return - } - - // Generate registration access token for RFC 7592 management - registrationToken, hashedRegToken, err := generateRegistrationAccessToken() - if err != nil { - writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError, - "server_error", "Failed to generate registration token") - return - } - - // Store in database - use system context since this is a public endpoint - now := dbtime.Now() - clientName := req.GenerateClientName() - //nolint:gocritic // Dynamic client registration is a public endpoint, system access required - app, err := api.Database.InsertOAuth2ProviderApp(dbauthz.AsSystemRestricted(ctx), database.InsertOAuth2ProviderAppParams{ - ID: clientID, - CreatedAt: now, - UpdatedAt: now, - Name: clientName, - Icon: req.LogoURI, - CallbackURL: req.RedirectURIs[0], // Primary redirect URI - RedirectUris: req.RedirectURIs, - ClientType: sql.NullString{String: req.DetermineClientType(), Valid: true}, - 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}, - Scope: sql.NullString{String: req.Scope, Valid: true}, - Contacts: req.Contacts, - ClientUri: sql.NullString{String: req.ClientURI, Valid: req.ClientURI != ""}, - LogoUri: sql.NullString{String: req.LogoURI, Valid: req.LogoURI != ""}, - TosUri: sql.NullString{String: req.TOSURI, Valid: req.TOSURI != ""}, - PolicyUri: sql.NullString{String: req.PolicyURI, Valid: req.PolicyURI != ""}, - JwksUri: sql.NullString{String: req.JWKSURI, Valid: req.JWKSURI != ""}, - Jwks: pqtype.NullRawMessage{RawMessage: req.JWKS, Valid: len(req.JWKS) > 0}, - SoftwareID: sql.NullString{String: req.SoftwareID, Valid: req.SoftwareID != ""}, - SoftwareVersion: sql.NullString{String: req.SoftwareVersion, Valid: req.SoftwareVersion != ""}, - RegistrationAccessToken: sql.NullString{String: hashedRegToken, Valid: true}, - RegistrationClientUri: sql.NullString{String: fmt.Sprintf("%s/oauth2/clients/%s", api.AccessURL.String(), clientID), Valid: true}, - }) - if err != nil { - api.Logger.Error(ctx, "failed to store oauth2 client registration", - slog.Error(err), - slog.F("client_name", clientName), - slog.F("client_id", clientID.String()), - slog.F("redirect_uris", req.RedirectURIs)) - writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError, - "server_error", "Failed to store client registration") - return - } - - // Create client secret - parse the formatted secret to get components - parsedSecret, err := parseFormattedSecret(clientSecret) - if err != nil { - writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError, - "server_error", "Failed to parse generated secret") - return - } - - //nolint:gocritic // Dynamic client registration is a public endpoint, system access required - _, err = api.Database.InsertOAuth2ProviderAppSecret(dbauthz.AsSystemRestricted(ctx), database.InsertOAuth2ProviderAppSecretParams{ - ID: uuid.New(), - CreatedAt: now, - SecretPrefix: []byte(parsedSecret.prefix), - HashedSecret: []byte(hashedSecret), - DisplaySecret: createDisplaySecret(clientSecret), - AppID: clientID, - }) - if err != nil { - writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError, - "server_error", "Failed to store client secret") - return - } - - // Set audit log data - aReq.New = app - - // Return response - response := codersdk.OAuth2ClientRegistrationResponse{ - ClientID: app.ID.String(), - ClientSecret: clientSecret, - ClientIDIssuedAt: app.ClientIDIssuedAt.Time.Unix(), - ClientSecretExpiresAt: 0, // No expiration - RedirectURIs: app.RedirectUris, - ClientName: app.Name, - ClientURI: app.ClientUri.String, - LogoURI: app.LogoUri.String, - TOSURI: app.TosUri.String, - PolicyURI: app.PolicyUri.String, - JWKSURI: app.JwksUri.String, - JWKS: app.Jwks.RawMessage, - SoftwareID: app.SoftwareID.String, - SoftwareVersion: app.SoftwareVersion.String, - GrantTypes: app.GrantTypes, - ResponseTypes: app.ResponseTypes, - TokenEndpointAuthMethod: app.TokenEndpointAuthMethod.String, - Scope: app.Scope.String, - Contacts: app.Contacts, - RegistrationAccessToken: registrationToken, - RegistrationClientURI: app.RegistrationClientUri.String, - } - - httpapi.Write(ctx, rw, http.StatusCreated, response) +func (api *API) postOAuth2ClientRegistration() http.HandlerFunc { + return oauth2provider.CreateDynamicClientRegistration(api.Database, api.AccessURL, api.Auditor.Load(), api.Logger) } -// Helper functions for RFC 7591 Dynamic Client Registration - -// generateClientCredentials generates a client secret for OAuth2 apps -func generateClientCredentials() (plaintext, hashed string, err error) { - // Use the same pattern as existing OAuth2 app secrets - secret, err := identityprovider.GenerateSecret() - if err != nil { - return "", "", xerrors.Errorf("generate secret: %w", err) - } - - return secret.Formatted, secret.Hashed, nil -} - -// generateRegistrationAccessToken generates a registration access token for RFC 7592 -func generateRegistrationAccessToken() (plaintext, hashed string, err error) { - token, err := cryptorand.String(secretLength) - if err != nil { - return "", "", xerrors.Errorf("generate registration token: %w", err) - } - - // Hash the token for storage - hashedToken, err := userpassword.Hash(token) - if err != nil { - return "", "", xerrors.Errorf("hash registration token: %w", err) - } - - return token, hashedToken, nil -} - -// writeOAuth2RegistrationError writes RFC 7591 compliant error responses -func writeOAuth2RegistrationError(_ context.Context, rw http.ResponseWriter, status int, errorCode, description string) { - // RFC 7591 error response format - errorResponse := map[string]string{ - "error": errorCode, - } - if description != "" { - errorResponse["error_description"] = description - } - - rw.Header().Set("Content-Type", "application/json") - rw.WriteHeader(status) - _ = json.NewEncoder(rw).Encode(errorResponse) -} - -// parsedSecret represents the components of a formatted OAuth2 secret -type parsedSecret struct { - prefix string - secret string -} - -// parseFormattedSecret parses a formatted secret like "coder_prefix_secret" -func parseFormattedSecret(secret string) (parsedSecret, error) { - parts := strings.Split(secret, "_") - if len(parts) != 3 { - return parsedSecret{}, xerrors.Errorf("incorrect number of parts: %d", len(parts)) - } - if parts[0] != "coder" { - return parsedSecret{}, xerrors.Errorf("incorrect scheme: %s", parts[0]) - } - return parsedSecret{ - prefix: parts[1], - secret: parts[2], - }, nil -} - -// createDisplaySecret creates a display version of the secret showing only the last few characters -func createDisplaySecret(secret string) string { - if len(secret) <= displaySecretLength { - return secret - } - - visiblePart := secret[len(secret)-displaySecretLength:] - hiddenLength := len(secret) - displaySecretLength - return strings.Repeat("*", hiddenLength) + visiblePart -} - -// RFC 7592 Client Configuration Management Endpoints - // @Summary Get OAuth2 client configuration (RFC 7592) // @ID get-oauth2-client-configuration // @Accept json @@ -703,64 +200,8 @@ func createDisplaySecret(secret string) string { // @Param client_id path string true "Client ID" // @Success 200 {object} codersdk.OAuth2ClientConfiguration // @Router /oauth2/clients/{client_id} [get] -func (api *API) oauth2ClientConfiguration(rw http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - // Extract client ID from URL path - clientIDStr := chi.URLParam(r, "client_id") - clientID, err := uuid.Parse(clientIDStr) - if err != nil { - writeOAuth2RegistrationError(ctx, rw, http.StatusBadRequest, - "invalid_client_metadata", "Invalid client ID format") - return - } - - // Get app by client ID - //nolint:gocritic // RFC 7592 endpoints need system access to retrieve dynamically registered clients - app, err := api.Database.GetOAuth2ProviderAppByClientID(dbauthz.AsSystemRestricted(ctx), clientID) - if err != nil { - if xerrors.Is(err, sql.ErrNoRows) { - writeOAuth2RegistrationError(ctx, rw, http.StatusUnauthorized, - "invalid_token", "Client not found") - } else { - writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError, - "server_error", "Failed to retrieve client") - } - return - } - - // Check if client was dynamically registered - if !app.DynamicallyRegistered.Bool { - writeOAuth2RegistrationError(ctx, rw, http.StatusUnauthorized, - "invalid_token", "Client was not dynamically registered") - return - } - - // Return client configuration (without client_secret for security) - response := codersdk.OAuth2ClientConfiguration{ - ClientID: app.ID.String(), - ClientIDIssuedAt: app.ClientIDIssuedAt.Time.Unix(), - ClientSecretExpiresAt: 0, // No expiration for now - RedirectURIs: app.RedirectUris, - ClientName: app.Name, - ClientURI: app.ClientUri.String, - LogoURI: app.LogoUri.String, - TOSURI: app.TosUri.String, - PolicyURI: app.PolicyUri.String, - JWKSURI: app.JwksUri.String, - JWKS: app.Jwks.RawMessage, - SoftwareID: app.SoftwareID.String, - SoftwareVersion: app.SoftwareVersion.String, - GrantTypes: app.GrantTypes, - ResponseTypes: app.ResponseTypes, - TokenEndpointAuthMethod: app.TokenEndpointAuthMethod.String, - Scope: app.Scope.String, - Contacts: app.Contacts, - RegistrationAccessToken: "", // RFC 7592: Not returned in GET responses for security - RegistrationClientURI: app.RegistrationClientUri.String, - } - - httpapi.Write(ctx, rw, http.StatusOK, response) +func (api *API) oauth2ClientConfiguration() http.HandlerFunc { + return oauth2provider.GetClientConfiguration(api.Database) } // @Summary Update OAuth2 client configuration (RFC 7592) @@ -772,126 +213,8 @@ func (api *API) oauth2ClientConfiguration(rw http.ResponseWriter, r *http.Reques // @Param request body codersdk.OAuth2ClientRegistrationRequest true "Client update request" // @Success 200 {object} codersdk.OAuth2ClientConfiguration // @Router /oauth2/clients/{client_id} [put] -func (api *API) putOAuth2ClientConfiguration(rw http.ResponseWriter, r *http.Request) { - ctx := r.Context() - auditor := *api.Auditor.Load() - aReq, commitAudit := audit.InitRequest[database.OAuth2ProviderApp](rw, &audit.RequestParams{ - Audit: auditor, - Log: api.Logger, - Request: r, - Action: database.AuditActionWrite, - }) - defer commitAudit() - - // Extract client ID from URL path - clientIDStr := chi.URLParam(r, "client_id") - clientID, err := uuid.Parse(clientIDStr) - if err != nil { - writeOAuth2RegistrationError(ctx, rw, http.StatusBadRequest, - "invalid_client_metadata", "Invalid client ID format") - return - } - - // Parse request - var req codersdk.OAuth2ClientRegistrationRequest - if !httpapi.Read(ctx, rw, r, &req) { - return - } - - // Validate request - if err := req.Validate(); err != nil { - writeOAuth2RegistrationError(ctx, rw, http.StatusBadRequest, - "invalid_client_metadata", err.Error()) - return - } - - // Apply defaults - req = req.ApplyDefaults() - - // Get existing app to verify it exists and is dynamically registered - //nolint:gocritic // RFC 7592 endpoints need system access to retrieve dynamically registered clients - existingApp, err := api.Database.GetOAuth2ProviderAppByClientID(dbauthz.AsSystemRestricted(ctx), clientID) - if err == nil { - aReq.Old = existingApp - } - if err != nil { - if xerrors.Is(err, sql.ErrNoRows) { - writeOAuth2RegistrationError(ctx, rw, http.StatusUnauthorized, - "invalid_token", "Client not found") - } else { - writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError, - "server_error", "Failed to retrieve client") - } - return - } - - // Check if client was dynamically registered - if !existingApp.DynamicallyRegistered.Bool { - writeOAuth2RegistrationError(ctx, rw, http.StatusForbidden, - "invalid_token", "Client was not dynamically registered") - return - } - - // Update app in database - now := dbtime.Now() - //nolint:gocritic // RFC 7592 endpoints need system access to update dynamically registered clients - updatedApp, err := api.Database.UpdateOAuth2ProviderAppByClientID(dbauthz.AsSystemRestricted(ctx), database.UpdateOAuth2ProviderAppByClientIDParams{ - ID: clientID, - UpdatedAt: now, - Name: req.GenerateClientName(), - Icon: req.LogoURI, - CallbackURL: req.RedirectURIs[0], // Primary redirect URI - 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}, - Scope: sql.NullString{String: req.Scope, Valid: true}, - Contacts: req.Contacts, - ClientUri: sql.NullString{String: req.ClientURI, Valid: req.ClientURI != ""}, - LogoUri: sql.NullString{String: req.LogoURI, Valid: req.LogoURI != ""}, - TosUri: sql.NullString{String: req.TOSURI, Valid: req.TOSURI != ""}, - PolicyUri: sql.NullString{String: req.PolicyURI, Valid: req.PolicyURI != ""}, - JwksUri: sql.NullString{String: req.JWKSURI, Valid: req.JWKSURI != ""}, - Jwks: pqtype.NullRawMessage{RawMessage: req.JWKS, Valid: len(req.JWKS) > 0}, - SoftwareID: sql.NullString{String: req.SoftwareID, Valid: req.SoftwareID != ""}, - SoftwareVersion: sql.NullString{String: req.SoftwareVersion, Valid: req.SoftwareVersion != ""}, - }) - if err != nil { - writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError, - "server_error", "Failed to update client") - return - } - - // Set audit log data - aReq.New = updatedApp - - // Return updated client configuration - response := codersdk.OAuth2ClientConfiguration{ - ClientID: updatedApp.ID.String(), - ClientIDIssuedAt: updatedApp.ClientIDIssuedAt.Time.Unix(), - ClientSecretExpiresAt: 0, // No expiration for now - RedirectURIs: updatedApp.RedirectUris, - ClientName: updatedApp.Name, - ClientURI: updatedApp.ClientUri.String, - LogoURI: updatedApp.LogoUri.String, - TOSURI: updatedApp.TosUri.String, - PolicyURI: updatedApp.PolicyUri.String, - JWKSURI: updatedApp.JwksUri.String, - JWKS: updatedApp.Jwks.RawMessage, - SoftwareID: updatedApp.SoftwareID.String, - SoftwareVersion: updatedApp.SoftwareVersion.String, - GrantTypes: updatedApp.GrantTypes, - ResponseTypes: updatedApp.ResponseTypes, - TokenEndpointAuthMethod: updatedApp.TokenEndpointAuthMethod.String, - Scope: updatedApp.Scope.String, - Contacts: updatedApp.Contacts, - RegistrationAccessToken: updatedApp.RegistrationAccessToken.String, - RegistrationClientURI: updatedApp.RegistrationClientUri.String, - } - - httpapi.Write(ctx, rw, http.StatusOK, response) +func (api *API) putOAuth2ClientConfiguration() http.HandlerFunc { + return oauth2provider.UpdateClientConfiguration(api.Database, api.Auditor.Load(), api.Logger) } // @Summary Delete OAuth2 client registration (RFC 7592) @@ -900,143 +223,6 @@ func (api *API) putOAuth2ClientConfiguration(rw http.ResponseWriter, r *http.Req // @Param client_id path string true "Client ID" // @Success 204 // @Router /oauth2/clients/{client_id} [delete] -func (api *API) deleteOAuth2ClientConfiguration(rw http.ResponseWriter, r *http.Request) { - ctx := r.Context() - auditor := *api.Auditor.Load() - aReq, commitAudit := audit.InitRequest[database.OAuth2ProviderApp](rw, &audit.RequestParams{ - Audit: auditor, - Log: api.Logger, - Request: r, - Action: database.AuditActionDelete, - }) - defer commitAudit() - - // Extract client ID from URL path - clientIDStr := chi.URLParam(r, "client_id") - clientID, err := uuid.Parse(clientIDStr) - if err != nil { - writeOAuth2RegistrationError(ctx, rw, http.StatusBadRequest, - "invalid_client_metadata", "Invalid client ID format") - return - } - - // Get existing app to verify it exists and is dynamically registered - //nolint:gocritic // RFC 7592 endpoints need system access to retrieve dynamically registered clients - existingApp, err := api.Database.GetOAuth2ProviderAppByClientID(dbauthz.AsSystemRestricted(ctx), clientID) - if err == nil { - aReq.Old = existingApp - } - if err != nil { - if xerrors.Is(err, sql.ErrNoRows) { - writeOAuth2RegistrationError(ctx, rw, http.StatusUnauthorized, - "invalid_token", "Client not found") - } else { - writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError, - "server_error", "Failed to retrieve client") - } - return - } - - // Check if client was dynamically registered - if !existingApp.DynamicallyRegistered.Bool { - writeOAuth2RegistrationError(ctx, rw, http.StatusForbidden, - "invalid_token", "Client was not dynamically registered") - return - } - - // Delete the client and all associated data (tokens, secrets, etc.) - //nolint:gocritic // RFC 7592 endpoints need system access to delete dynamically registered clients - err = api.Database.DeleteOAuth2ProviderAppByClientID(dbauthz.AsSystemRestricted(ctx), clientID) - if err != nil { - writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError, - "server_error", "Failed to delete client") - return - } - - // Note: audit data already set above with aReq.Old = existingApp - - // Return 204 No Content as per RFC 7592 - rw.WriteHeader(http.StatusNoContent) -} - -// requireRegistrationAccessToken middleware validates the registration access token for RFC 7592 endpoints -func (api *API) requireRegistrationAccessToken(next http.Handler) http.Handler { - return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - // Extract client ID from URL path - clientIDStr := chi.URLParam(r, "client_id") - clientID, err := uuid.Parse(clientIDStr) - if err != nil { - writeOAuth2RegistrationError(ctx, rw, http.StatusBadRequest, - "invalid_client_id", "Invalid client ID format") - return - } - - // Extract registration access token from Authorization header - authHeader := r.Header.Get("Authorization") - if authHeader == "" { - writeOAuth2RegistrationError(ctx, rw, http.StatusUnauthorized, - "invalid_token", "Missing Authorization header") - return - } - - if !strings.HasPrefix(authHeader, "Bearer ") { - writeOAuth2RegistrationError(ctx, rw, http.StatusUnauthorized, - "invalid_token", "Authorization header must use Bearer scheme") - return - } - - token := strings.TrimPrefix(authHeader, "Bearer ") - if token == "" { - writeOAuth2RegistrationError(ctx, rw, http.StatusUnauthorized, - "invalid_token", "Missing registration access token") - return - } - - // Get the client and verify the registration access token - //nolint:gocritic // RFC 7592 endpoints need system access to validate dynamically registered clients - app, err := api.Database.GetOAuth2ProviderAppByClientID(dbauthz.AsSystemRestricted(ctx), clientID) - if err != nil { - if xerrors.Is(err, sql.ErrNoRows) { - // Return 401 for authentication-related issues, not 404 - writeOAuth2RegistrationError(ctx, rw, http.StatusUnauthorized, - "invalid_token", "Client not found") - } else { - writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError, - "server_error", "Failed to retrieve client") - } - return - } - - // Check if client was dynamically registered - if !app.DynamicallyRegistered.Bool { - writeOAuth2RegistrationError(ctx, rw, http.StatusForbidden, - "invalid_token", "Client was not dynamically registered") - return - } - - // Verify the registration access token - if !app.RegistrationAccessToken.Valid { - writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError, - "server_error", "Client has no registration access token") - return - } - - // Compare the provided token with the stored hash - valid, err := userpassword.Compare(app.RegistrationAccessToken.String, token) - if err != nil { - writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError, - "server_error", "Failed to verify registration access token") - return - } - if !valid { - writeOAuth2RegistrationError(ctx, rw, http.StatusUnauthorized, - "invalid_token", "Invalid registration access token") - return - } - - // Token is valid, continue to the next handler - next.ServeHTTP(rw, r) - }) +func (api *API) deleteOAuth2ClientConfiguration() http.HandlerFunc { + return oauth2provider.DeleteClientConfiguration(api.Database, api.Auditor.Load(), api.Logger) } diff --git a/coderd/oauth2_test.go b/coderd/oauth2_test.go index 3b3caeaa39..7e0f547f47 100644 --- a/coderd/oauth2_test.go +++ b/coderd/oauth2_test.go @@ -22,7 +22,7 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/dbtime" - "github.com/coder/coder/v2/coderd/identityprovider" + "github.com/coder/coder/v2/coderd/oauth2provider" "github.com/coder/coder/v2/coderd/userpassword" "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" @@ -865,7 +865,7 @@ func TestOAuth2ProviderTokenRefresh(t *testing.T) { newKey, err := db.InsertAPIKey(ctx, key) require.NoError(t, err) - token, err := identityprovider.GenerateSecret() + token, err := oauth2provider.GenerateSecret() require.NoError(t, err) expires := test.expires diff --git a/coderd/oauth2provider/app_secrets.go b/coderd/oauth2provider/app_secrets.go new file mode 100644 index 0000000000..5549ece426 --- /dev/null +++ b/coderd/oauth2provider/app_secrets.go @@ -0,0 +1,116 @@ +package oauth2provider + +import ( + "net/http" + + "github.com/google/uuid" + + "cdr.dev/slog" + + "github.com/coder/coder/v2/coderd/audit" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/codersdk" +) + +// GetAppSecrets returns an http.HandlerFunc that handles GET /oauth2-provider/apps/{app}/secrets +func GetAppSecrets(db database.Store) http.HandlerFunc { + return func(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + app := httpmw.OAuth2ProviderApp(r) + dbSecrets, err := db.GetOAuth2ProviderAppSecretsByAppID(ctx, app.ID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error getting OAuth2 client secrets.", + Detail: err.Error(), + }) + return + } + secrets := []codersdk.OAuth2ProviderAppSecret{} + for _, secret := range dbSecrets { + secrets = append(secrets, codersdk.OAuth2ProviderAppSecret{ + ID: secret.ID, + LastUsedAt: codersdk.NullTime{NullTime: secret.LastUsedAt}, + ClientSecretTruncated: secret.DisplaySecret, + }) + } + httpapi.Write(ctx, rw, http.StatusOK, secrets) + } +} + +// CreateAppSecret returns an http.HandlerFunc that handles POST /oauth2-provider/apps/{app}/secrets +func CreateAppSecret(db database.Store, auditor *audit.Auditor, logger slog.Logger) http.HandlerFunc { + return func(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + app = httpmw.OAuth2ProviderApp(r) + aReq, commitAudit = audit.InitRequest[database.OAuth2ProviderAppSecret](rw, &audit.RequestParams{ + Audit: *auditor, + Log: logger, + Request: r, + Action: database.AuditActionCreate, + }) + ) + defer commitAudit() + secret, err := GenerateSecret() + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to generate OAuth2 client secret.", + Detail: err.Error(), + }) + return + } + dbSecret, err := db.InsertOAuth2ProviderAppSecret(ctx, database.InsertOAuth2ProviderAppSecretParams{ + ID: uuid.New(), + CreatedAt: dbtime.Now(), + SecretPrefix: []byte(secret.Prefix), + HashedSecret: []byte(secret.Hashed), + // DisplaySecret is the last six characters of the original unhashed secret. + // This is done so they can be differentiated and it matches how GitHub + // displays their client secrets. + DisplaySecret: secret.Formatted[len(secret.Formatted)-6:], + AppID: app.ID, + }) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error creating OAuth2 client secret.", + Detail: err.Error(), + }) + return + } + aReq.New = dbSecret + httpapi.Write(ctx, rw, http.StatusCreated, codersdk.OAuth2ProviderAppSecretFull{ + ID: dbSecret.ID, + ClientSecretFull: secret.Formatted, + }) + } +} + +// DeleteAppSecret returns an http.HandlerFunc that handles DELETE /oauth2-provider/apps/{app}/secrets/{secretID} +func DeleteAppSecret(db database.Store, auditor *audit.Auditor, logger slog.Logger) http.HandlerFunc { + return func(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + secret = httpmw.OAuth2ProviderAppSecret(r) + aReq, commitAudit = audit.InitRequest[database.OAuth2ProviderAppSecret](rw, &audit.RequestParams{ + Audit: *auditor, + Log: logger, + Request: r, + Action: database.AuditActionDelete, + }) + ) + aReq.Old = secret + defer commitAudit() + err := db.DeleteOAuth2ProviderAppSecretByID(ctx, secret.ID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error deleting OAuth2 client secret.", + Detail: err.Error(), + }) + return + } + rw.WriteHeader(http.StatusNoContent) + } +} diff --git a/coderd/oauth2provider/apps.go b/coderd/oauth2provider/apps.go new file mode 100644 index 0000000000..74bafb851e --- /dev/null +++ b/coderd/oauth2provider/apps.go @@ -0,0 +1,208 @@ +package oauth2provider + +import ( + "database/sql" + "fmt" + "net/http" + "net/url" + + "github.com/google/uuid" + "github.com/sqlc-dev/pqtype" + + "cdr.dev/slog" + + "github.com/coder/coder/v2/coderd/audit" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/db2sdk" + "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/codersdk" +) + +// ListApps returns an http.HandlerFunc that handles GET /oauth2-provider/apps +func ListApps(db database.Store, accessURL *url.URL) http.HandlerFunc { + return func(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + rawUserID := r.URL.Query().Get("user_id") + if rawUserID == "" { + dbApps, err := db.GetOAuth2ProviderApps(ctx) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + httpapi.Write(ctx, rw, http.StatusOK, db2sdk.OAuth2ProviderApps(accessURL, dbApps)) + return + } + + userID, err := uuid.Parse(rawUserID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid user UUID", + Detail: fmt.Sprintf("queried user_id=%q", userID), + }) + return + } + + userApps, err := db.GetOAuth2ProviderAppsByUserID(ctx, userID) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + var sdkApps []codersdk.OAuth2ProviderApp + for _, app := range userApps { + sdkApps = append(sdkApps, db2sdk.OAuth2ProviderApp(accessURL, app.OAuth2ProviderApp)) + } + httpapi.Write(ctx, rw, http.StatusOK, sdkApps) + } +} + +// GetApp returns an http.HandlerFunc that handles GET /oauth2-provider/apps/{app} +func GetApp(accessURL *url.URL) http.HandlerFunc { + return func(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + app := httpmw.OAuth2ProviderApp(r) + httpapi.Write(ctx, rw, http.StatusOK, db2sdk.OAuth2ProviderApp(accessURL, app)) + } +} + +// CreateApp returns an http.HandlerFunc that handles POST /oauth2-provider/apps +func CreateApp(db database.Store, accessURL *url.URL, auditor *audit.Auditor, logger slog.Logger) http.HandlerFunc { + return func(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + aReq, commitAudit = audit.InitRequest[database.OAuth2ProviderApp](rw, &audit.RequestParams{ + Audit: *auditor, + Log: logger, + Request: r, + Action: database.AuditActionCreate, + }) + ) + defer commitAudit() + var req codersdk.PostOAuth2ProviderAppRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + app, err := db.InsertOAuth2ProviderApp(ctx, database.InsertOAuth2ProviderAppParams{ + ID: uuid.New(), + CreatedAt: dbtime.Now(), + UpdatedAt: dbtime.Now(), + Name: req.Name, + Icon: req.Icon, + CallbackURL: req.CallbackURL, + RedirectUris: []string{}, + ClientType: sql.NullString{String: "confidential", Valid: true}, + DynamicallyRegistered: sql.NullBool{Bool: false, Valid: true}, + ClientIDIssuedAt: sql.NullTime{}, + ClientSecretExpiresAt: sql.NullTime{}, + GrantTypes: []string{"authorization_code", "refresh_token"}, + ResponseTypes: []string{"code"}, + TokenEndpointAuthMethod: sql.NullString{String: "client_secret_post", Valid: true}, + Scope: sql.NullString{}, + Contacts: []string{}, + ClientUri: sql.NullString{}, + LogoUri: sql.NullString{}, + TosUri: sql.NullString{}, + PolicyUri: sql.NullString{}, + JwksUri: sql.NullString{}, + Jwks: pqtype.NullRawMessage{}, + SoftwareID: sql.NullString{}, + SoftwareVersion: sql.NullString{}, + RegistrationAccessToken: sql.NullString{}, + RegistrationClientUri: sql.NullString{}, + }) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error creating OAuth2 application.", + Detail: err.Error(), + }) + return + } + aReq.New = app + httpapi.Write(ctx, rw, http.StatusCreated, db2sdk.OAuth2ProviderApp(accessURL, app)) + } +} + +// UpdateApp returns an http.HandlerFunc that handles PUT /oauth2-provider/apps/{app} +func UpdateApp(db database.Store, accessURL *url.URL, auditor *audit.Auditor, logger slog.Logger) http.HandlerFunc { + return func(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + app = httpmw.OAuth2ProviderApp(r) + aReq, commitAudit = audit.InitRequest[database.OAuth2ProviderApp](rw, &audit.RequestParams{ + Audit: *auditor, + Log: logger, + Request: r, + Action: database.AuditActionWrite, + }) + ) + aReq.Old = app + defer commitAudit() + var req codersdk.PutOAuth2ProviderAppRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + app, err := db.UpdateOAuth2ProviderAppByID(ctx, database.UpdateOAuth2ProviderAppByIDParams{ + ID: app.ID, + UpdatedAt: dbtime.Now(), + Name: req.Name, + Icon: req.Icon, + CallbackURL: req.CallbackURL, + RedirectUris: app.RedirectUris, // Keep existing value + ClientType: app.ClientType, // Keep existing value + DynamicallyRegistered: app.DynamicallyRegistered, // Keep existing value + ClientSecretExpiresAt: app.ClientSecretExpiresAt, // Keep existing value + GrantTypes: app.GrantTypes, // Keep existing value + ResponseTypes: app.ResponseTypes, // Keep existing value + TokenEndpointAuthMethod: app.TokenEndpointAuthMethod, // Keep existing value + Scope: app.Scope, // Keep existing value + Contacts: app.Contacts, // Keep existing value + ClientUri: app.ClientUri, // Keep existing value + LogoUri: app.LogoUri, // Keep existing value + TosUri: app.TosUri, // Keep existing value + PolicyUri: app.PolicyUri, // Keep existing value + JwksUri: app.JwksUri, // Keep existing value + Jwks: app.Jwks, // Keep existing value + SoftwareID: app.SoftwareID, // Keep existing value + SoftwareVersion: app.SoftwareVersion, // Keep existing value + }) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error updating OAuth2 application.", + Detail: err.Error(), + }) + return + } + aReq.New = app + httpapi.Write(ctx, rw, http.StatusOK, db2sdk.OAuth2ProviderApp(accessURL, app)) + } +} + +// DeleteApp returns an http.HandlerFunc that handles DELETE /oauth2-provider/apps/{app} +func DeleteApp(db database.Store, auditor *audit.Auditor, logger slog.Logger) http.HandlerFunc { + return func(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + app = httpmw.OAuth2ProviderApp(r) + aReq, commitAudit = audit.InitRequest[database.OAuth2ProviderApp](rw, &audit.RequestParams{ + Audit: *auditor, + Log: logger, + Request: r, + Action: database.AuditActionDelete, + }) + ) + aReq.Old = app + defer commitAudit() + err := db.DeleteOAuth2ProviderAppByID(ctx, app.ID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error deleting OAuth2 application.", + Detail: err.Error(), + }) + return + } + rw.WriteHeader(http.StatusNoContent) + } +} diff --git a/coderd/identityprovider/authorize.go b/coderd/oauth2provider/authorize.go similarity index 99% rename from coderd/identityprovider/authorize.go rename to coderd/oauth2provider/authorize.go index 3dcb511223..77be5fc397 100644 --- a/coderd/identityprovider/authorize.go +++ b/coderd/oauth2provider/authorize.go @@ -1,4 +1,4 @@ -package identityprovider +package oauth2provider import ( "database/sql" diff --git a/coderd/oauth2provider/metadata.go b/coderd/oauth2provider/metadata.go new file mode 100644 index 0000000000..9ce10f8993 --- /dev/null +++ b/coderd/oauth2provider/metadata.go @@ -0,0 +1,45 @@ +package oauth2provider + +import ( + "net/http" + "net/url" + + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/codersdk" +) + +// GetAuthorizationServerMetadata returns an http.HandlerFunc that handles GET /.well-known/oauth-authorization-server +func GetAuthorizationServerMetadata(accessURL *url.URL) http.HandlerFunc { + return func(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + metadata := codersdk.OAuth2AuthorizationServerMetadata{ + Issuer: accessURL.String(), + AuthorizationEndpoint: accessURL.JoinPath("/oauth2/authorize").String(), + TokenEndpoint: accessURL.JoinPath("/oauth2/tokens").String(), + RegistrationEndpoint: accessURL.JoinPath("/oauth2/register").String(), // RFC 7591 + ResponseTypesSupported: []string{"code"}, + GrantTypesSupported: []string{"authorization_code", "refresh_token"}, + CodeChallengeMethodsSupported: []string{"S256"}, + // TODO: Implement scope system + ScopesSupported: []string{}, + TokenEndpointAuthMethodsSupported: []string{"client_secret_post"}, + } + httpapi.Write(ctx, rw, http.StatusOK, metadata) + } +} + +// GetProtectedResourceMetadata returns an http.HandlerFunc that handles GET /.well-known/oauth-protected-resource +func GetProtectedResourceMetadata(accessURL *url.URL) http.HandlerFunc { + return func(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + metadata := codersdk.OAuth2ProtectedResourceMetadata{ + Resource: accessURL.String(), + AuthorizationServers: []string{accessURL.String()}, + // TODO: Implement scope system based on RBAC permissions + ScopesSupported: []string{}, + // RFC 6750 Bearer Token methods supported as fallback methods in api key middleware + BearerMethodsSupported: []string{"header", "query"}, + } + httpapi.Write(ctx, rw, http.StatusOK, metadata) + } +} diff --git a/coderd/identityprovider/middleware.go b/coderd/oauth2provider/middleware.go similarity index 99% rename from coderd/identityprovider/middleware.go rename to coderd/oauth2provider/middleware.go index 5b49bdd29f..c989d068a8 100644 --- a/coderd/identityprovider/middleware.go +++ b/coderd/oauth2provider/middleware.go @@ -1,4 +1,4 @@ -package identityprovider +package oauth2provider import ( "net/http" diff --git a/coderd/identityprovider/identityprovidertest/fixtures.go b/coderd/oauth2provider/oauth2providertest/fixtures.go similarity index 97% rename from coderd/identityprovider/identityprovidertest/fixtures.go rename to coderd/oauth2provider/oauth2providertest/fixtures.go index c5d4bf31cf..8dbccb511a 100644 --- a/coderd/identityprovider/identityprovidertest/fixtures.go +++ b/coderd/oauth2provider/oauth2providertest/fixtures.go @@ -1,4 +1,4 @@ -package identityprovidertest +package oauth2providertest import ( "crypto/sha256" diff --git a/coderd/identityprovider/identityprovidertest/helpers.go b/coderd/oauth2provider/oauth2providertest/helpers.go similarity index 98% rename from coderd/identityprovider/identityprovidertest/helpers.go rename to coderd/oauth2provider/oauth2providertest/helpers.go index 7773a116a4..d0a90c6d34 100644 --- a/coderd/identityprovider/identityprovidertest/helpers.go +++ b/coderd/oauth2provider/oauth2providertest/helpers.go @@ -1,7 +1,7 @@ -// Package identityprovidertest provides comprehensive testing utilities for OAuth2 identity provider functionality. +// Package oauth2providertest provides comprehensive testing utilities for OAuth2 identity provider functionality. // It includes helpers for creating OAuth2 apps, performing authorization flows, token exchanges, // PKCE challenge generation and verification, and testing error scenarios. -package identityprovidertest +package oauth2providertest import ( "crypto/rand" diff --git a/coderd/identityprovider/identityprovidertest/oauth2_test.go b/coderd/oauth2provider/oauth2providertest/oauth2_test.go similarity index 60% rename from coderd/identityprovider/identityprovidertest/oauth2_test.go rename to coderd/oauth2provider/oauth2providertest/oauth2_test.go index 28e7ae38a3..cb33c8914a 100644 --- a/coderd/identityprovider/identityprovidertest/oauth2_test.go +++ b/coderd/oauth2provider/oauth2providertest/oauth2_test.go @@ -1,4 +1,4 @@ -package identityprovidertest_test +package oauth2providertest_test import ( "testing" @@ -6,7 +6,7 @@ import ( "github.com/stretchr/testify/require" "github.com/coder/coder/v2/coderd/coderdtest" - "github.com/coder/coder/v2/coderd/identityprovider/identityprovidertest" + "github.com/coder/coder/v2/coderd/oauth2provider/oauth2providertest" ) func TestOAuth2AuthorizationServerMetadata(t *testing.T) { @@ -18,7 +18,7 @@ func TestOAuth2AuthorizationServerMetadata(t *testing.T) { _ = coderdtest.CreateFirstUser(t, client) // Fetch OAuth2 metadata - metadata := identityprovidertest.FetchOAuth2Metadata(t, client.URL.String()) + metadata := oauth2providertest.FetchOAuth2Metadata(t, client.URL.String()) // Verify required metadata fields require.Contains(t, metadata, "issuer", "missing issuer in metadata") @@ -60,39 +60,39 @@ func TestOAuth2PKCEFlow(t *testing.T) { _ = coderdtest.CreateFirstUser(t, client) // Create OAuth2 app - app, clientSecret := identityprovidertest.CreateTestOAuth2App(t, client) + app, clientSecret := oauth2providertest.CreateTestOAuth2App(t, client) t.Cleanup(func() { - identityprovidertest.CleanupOAuth2App(t, client, app.ID) + oauth2providertest.CleanupOAuth2App(t, client, app.ID) }) // Generate PKCE parameters - codeVerifier, codeChallenge := identityprovidertest.GeneratePKCE(t) - state := identityprovidertest.GenerateState(t) + codeVerifier, codeChallenge := oauth2providertest.GeneratePKCE(t) + state := oauth2providertest.GenerateState(t) // Perform authorization - authParams := identityprovidertest.AuthorizeParams{ + authParams := oauth2providertest.AuthorizeParams{ ClientID: app.ID.String(), ResponseType: "code", - RedirectURI: identityprovidertest.TestRedirectURI, + RedirectURI: oauth2providertest.TestRedirectURI, State: state, CodeChallenge: codeChallenge, CodeChallengeMethod: "S256", } - code := identityprovidertest.AuthorizeOAuth2App(t, client, client.URL.String(), authParams) + code := oauth2providertest.AuthorizeOAuth2App(t, client, client.URL.String(), authParams) require.NotEmpty(t, code, "should receive authorization code") // Exchange code for token with PKCE - tokenParams := identityprovidertest.TokenExchangeParams{ + tokenParams := oauth2providertest.TokenExchangeParams{ GrantType: "authorization_code", Code: code, ClientID: app.ID.String(), ClientSecret: clientSecret, CodeVerifier: codeVerifier, - RedirectURI: identityprovidertest.TestRedirectURI, + RedirectURI: oauth2providertest.TestRedirectURI, } - token := identityprovidertest.ExchangeCodeForToken(t, client.URL.String(), tokenParams) + token := oauth2providertest.ExchangeCodeForToken(t, client.URL.String(), tokenParams) require.NotEmpty(t, token.AccessToken, "should receive access token") require.NotEmpty(t, token.RefreshToken, "should receive refresh token") require.Equal(t, "Bearer", token.TokenType, "token type should be Bearer") @@ -107,40 +107,40 @@ func TestOAuth2InvalidPKCE(t *testing.T) { _ = coderdtest.CreateFirstUser(t, client) // Create OAuth2 app - app, clientSecret := identityprovidertest.CreateTestOAuth2App(t, client) + app, clientSecret := oauth2providertest.CreateTestOAuth2App(t, client) t.Cleanup(func() { - identityprovidertest.CleanupOAuth2App(t, client, app.ID) + oauth2providertest.CleanupOAuth2App(t, client, app.ID) }) // Generate PKCE parameters - _, codeChallenge := identityprovidertest.GeneratePKCE(t) - state := identityprovidertest.GenerateState(t) + _, codeChallenge := oauth2providertest.GeneratePKCE(t) + state := oauth2providertest.GenerateState(t) // Perform authorization - authParams := identityprovidertest.AuthorizeParams{ + authParams := oauth2providertest.AuthorizeParams{ ClientID: app.ID.String(), ResponseType: "code", - RedirectURI: identityprovidertest.TestRedirectURI, + RedirectURI: oauth2providertest.TestRedirectURI, State: state, CodeChallenge: codeChallenge, CodeChallengeMethod: "S256", } - code := identityprovidertest.AuthorizeOAuth2App(t, client, client.URL.String(), authParams) + code := oauth2providertest.AuthorizeOAuth2App(t, client, client.URL.String(), authParams) require.NotEmpty(t, code, "should receive authorization code") // Attempt token exchange with wrong code verifier - tokenParams := identityprovidertest.TokenExchangeParams{ + tokenParams := oauth2providertest.TokenExchangeParams{ GrantType: "authorization_code", Code: code, ClientID: app.ID.String(), ClientSecret: clientSecret, - CodeVerifier: identityprovidertest.InvalidCodeVerifier, - RedirectURI: identityprovidertest.TestRedirectURI, + CodeVerifier: oauth2providertest.InvalidCodeVerifier, + RedirectURI: oauth2providertest.TestRedirectURI, } - identityprovidertest.PerformTokenExchangeExpectingError( - t, client.URL.String(), tokenParams, identityprovidertest.OAuth2ErrorTypes.InvalidGrant, + oauth2providertest.PerformTokenExchangeExpectingError( + t, client.URL.String(), tokenParams, oauth2providertest.OAuth2ErrorTypes.InvalidGrant, ) } @@ -153,34 +153,34 @@ func TestOAuth2WithoutPKCE(t *testing.T) { _ = coderdtest.CreateFirstUser(t, client) // Create OAuth2 app - app, clientSecret := identityprovidertest.CreateTestOAuth2App(t, client) + app, clientSecret := oauth2providertest.CreateTestOAuth2App(t, client) t.Cleanup(func() { - identityprovidertest.CleanupOAuth2App(t, client, app.ID) + oauth2providertest.CleanupOAuth2App(t, client, app.ID) }) - state := identityprovidertest.GenerateState(t) + state := oauth2providertest.GenerateState(t) // Perform authorization without PKCE - authParams := identityprovidertest.AuthorizeParams{ + authParams := oauth2providertest.AuthorizeParams{ ClientID: app.ID.String(), ResponseType: "code", - RedirectURI: identityprovidertest.TestRedirectURI, + RedirectURI: oauth2providertest.TestRedirectURI, State: state, } - code := identityprovidertest.AuthorizeOAuth2App(t, client, client.URL.String(), authParams) + code := oauth2providertest.AuthorizeOAuth2App(t, client, client.URL.String(), authParams) require.NotEmpty(t, code, "should receive authorization code") // Exchange code for token without PKCE - tokenParams := identityprovidertest.TokenExchangeParams{ + tokenParams := oauth2providertest.TokenExchangeParams{ GrantType: "authorization_code", Code: code, ClientID: app.ID.String(), ClientSecret: clientSecret, - RedirectURI: identityprovidertest.TestRedirectURI, + RedirectURI: oauth2providertest.TestRedirectURI, } - token := identityprovidertest.ExchangeCodeForToken(t, client.URL.String(), tokenParams) + token := oauth2providertest.ExchangeCodeForToken(t, client.URL.String(), tokenParams) require.NotEmpty(t, token.AccessToken, "should receive access token") require.NotEmpty(t, token.RefreshToken, "should receive refresh token") } @@ -194,36 +194,36 @@ func TestOAuth2ResourceParameter(t *testing.T) { _ = coderdtest.CreateFirstUser(t, client) // Create OAuth2 app - app, clientSecret := identityprovidertest.CreateTestOAuth2App(t, client) + app, clientSecret := oauth2providertest.CreateTestOAuth2App(t, client) t.Cleanup(func() { - identityprovidertest.CleanupOAuth2App(t, client, app.ID) + oauth2providertest.CleanupOAuth2App(t, client, app.ID) }) - state := identityprovidertest.GenerateState(t) + state := oauth2providertest.GenerateState(t) // Perform authorization with resource parameter - authParams := identityprovidertest.AuthorizeParams{ + authParams := oauth2providertest.AuthorizeParams{ ClientID: app.ID.String(), ResponseType: "code", - RedirectURI: identityprovidertest.TestRedirectURI, + RedirectURI: oauth2providertest.TestRedirectURI, State: state, - Resource: identityprovidertest.TestResourceURI, + Resource: oauth2providertest.TestResourceURI, } - code := identityprovidertest.AuthorizeOAuth2App(t, client, client.URL.String(), authParams) + code := oauth2providertest.AuthorizeOAuth2App(t, client, client.URL.String(), authParams) require.NotEmpty(t, code, "should receive authorization code") // Exchange code for token with resource parameter - tokenParams := identityprovidertest.TokenExchangeParams{ + tokenParams := oauth2providertest.TokenExchangeParams{ GrantType: "authorization_code", Code: code, ClientID: app.ID.String(), ClientSecret: clientSecret, - RedirectURI: identityprovidertest.TestRedirectURI, - Resource: identityprovidertest.TestResourceURI, + RedirectURI: oauth2providertest.TestRedirectURI, + Resource: oauth2providertest.TestResourceURI, } - token := identityprovidertest.ExchangeCodeForToken(t, client.URL.String(), tokenParams) + token := oauth2providertest.ExchangeCodeForToken(t, client.URL.String(), tokenParams) require.NotEmpty(t, token.AccessToken, "should receive access token") require.NotEmpty(t, token.RefreshToken, "should receive refresh token") } @@ -237,43 +237,43 @@ func TestOAuth2TokenRefresh(t *testing.T) { _ = coderdtest.CreateFirstUser(t, client) // Create OAuth2 app - app, clientSecret := identityprovidertest.CreateTestOAuth2App(t, client) + app, clientSecret := oauth2providertest.CreateTestOAuth2App(t, client) t.Cleanup(func() { - identityprovidertest.CleanupOAuth2App(t, client, app.ID) + oauth2providertest.CleanupOAuth2App(t, client, app.ID) }) - state := identityprovidertest.GenerateState(t) + state := oauth2providertest.GenerateState(t) // Get initial token - authParams := identityprovidertest.AuthorizeParams{ + authParams := oauth2providertest.AuthorizeParams{ ClientID: app.ID.String(), ResponseType: "code", - RedirectURI: identityprovidertest.TestRedirectURI, + RedirectURI: oauth2providertest.TestRedirectURI, State: state, } - code := identityprovidertest.AuthorizeOAuth2App(t, client, client.URL.String(), authParams) + code := oauth2providertest.AuthorizeOAuth2App(t, client, client.URL.String(), authParams) - tokenParams := identityprovidertest.TokenExchangeParams{ + tokenParams := oauth2providertest.TokenExchangeParams{ GrantType: "authorization_code", Code: code, ClientID: app.ID.String(), ClientSecret: clientSecret, - RedirectURI: identityprovidertest.TestRedirectURI, + RedirectURI: oauth2providertest.TestRedirectURI, } - initialToken := identityprovidertest.ExchangeCodeForToken(t, client.URL.String(), tokenParams) + initialToken := oauth2providertest.ExchangeCodeForToken(t, client.URL.String(), tokenParams) require.NotEmpty(t, initialToken.RefreshToken, "should receive refresh token") // Use refresh token to get new access token - refreshParams := identityprovidertest.TokenExchangeParams{ + refreshParams := oauth2providertest.TokenExchangeParams{ GrantType: "refresh_token", RefreshToken: initialToken.RefreshToken, ClientID: app.ID.String(), ClientSecret: clientSecret, } - refreshedToken := identityprovidertest.ExchangeCodeForToken(t, client.URL.String(), refreshParams) + refreshedToken := oauth2providertest.ExchangeCodeForToken(t, client.URL.String(), refreshParams) require.NotEmpty(t, refreshedToken.AccessToken, "should receive new access token") require.NotEqual(t, initialToken.AccessToken, refreshedToken.AccessToken, "new access token should be different") } @@ -289,53 +289,53 @@ func TestOAuth2ErrorResponses(t *testing.T) { t.Run("InvalidClient", func(t *testing.T) { t.Parallel() - tokenParams := identityprovidertest.TokenExchangeParams{ + tokenParams := oauth2providertest.TokenExchangeParams{ GrantType: "authorization_code", Code: "invalid-code", ClientID: "non-existent-client", ClientSecret: "invalid-secret", } - identityprovidertest.PerformTokenExchangeExpectingError( - t, client.URL.String(), tokenParams, identityprovidertest.OAuth2ErrorTypes.InvalidClient, + oauth2providertest.PerformTokenExchangeExpectingError( + t, client.URL.String(), tokenParams, oauth2providertest.OAuth2ErrorTypes.InvalidClient, ) }) t.Run("InvalidGrantType", func(t *testing.T) { t.Parallel() - app, clientSecret := identityprovidertest.CreateTestOAuth2App(t, client) + app, clientSecret := oauth2providertest.CreateTestOAuth2App(t, client) t.Cleanup(func() { - identityprovidertest.CleanupOAuth2App(t, client, app.ID) + oauth2providertest.CleanupOAuth2App(t, client, app.ID) }) - tokenParams := identityprovidertest.TokenExchangeParams{ + tokenParams := oauth2providertest.TokenExchangeParams{ GrantType: "invalid_grant_type", ClientID: app.ID.String(), ClientSecret: clientSecret, } - identityprovidertest.PerformTokenExchangeExpectingError( - t, client.URL.String(), tokenParams, identityprovidertest.OAuth2ErrorTypes.UnsupportedGrantType, + oauth2providertest.PerformTokenExchangeExpectingError( + t, client.URL.String(), tokenParams, oauth2providertest.OAuth2ErrorTypes.UnsupportedGrantType, ) }) t.Run("MissingCode", func(t *testing.T) { t.Parallel() - app, clientSecret := identityprovidertest.CreateTestOAuth2App(t, client) + app, clientSecret := oauth2providertest.CreateTestOAuth2App(t, client) t.Cleanup(func() { - identityprovidertest.CleanupOAuth2App(t, client, app.ID) + oauth2providertest.CleanupOAuth2App(t, client, app.ID) }) - tokenParams := identityprovidertest.TokenExchangeParams{ + tokenParams := oauth2providertest.TokenExchangeParams{ GrantType: "authorization_code", ClientID: app.ID.String(), ClientSecret: clientSecret, } - identityprovidertest.PerformTokenExchangeExpectingError( - t, client.URL.String(), tokenParams, identityprovidertest.OAuth2ErrorTypes.InvalidRequest, + oauth2providertest.PerformTokenExchangeExpectingError( + t, client.URL.String(), tokenParams, oauth2providertest.OAuth2ErrorTypes.InvalidRequest, ) }) } diff --git a/coderd/identityprovider/pkce.go b/coderd/oauth2provider/pkce.go similarity index 95% rename from coderd/identityprovider/pkce.go rename to coderd/oauth2provider/pkce.go index 08e4014077..fd759dff88 100644 --- a/coderd/identityprovider/pkce.go +++ b/coderd/oauth2provider/pkce.go @@ -1,4 +1,4 @@ -package identityprovider +package oauth2provider import ( "crypto/sha256" diff --git a/coderd/identityprovider/pkce_test.go b/coderd/oauth2provider/pkce_test.go similarity index 88% rename from coderd/identityprovider/pkce_test.go rename to coderd/oauth2provider/pkce_test.go index 8cd8e1c8f4..f0ed74ca1b 100644 --- a/coderd/identityprovider/pkce_test.go +++ b/coderd/oauth2provider/pkce_test.go @@ -1,4 +1,4 @@ -package identityprovider_test +package oauth2provider_test import ( "crypto/sha256" @@ -7,7 +7,7 @@ import ( "github.com/stretchr/testify/require" - "github.com/coder/coder/v2/coderd/identityprovider" + "github.com/coder/coder/v2/coderd/oauth2provider" ) func TestVerifyPKCE(t *testing.T) { @@ -55,7 +55,7 @@ func TestVerifyPKCE(t *testing.T) { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() - result := identityprovider.VerifyPKCE(tt.challenge, tt.verifier) + result := oauth2provider.VerifyPKCE(tt.challenge, tt.verifier) require.Equal(t, tt.expectValid, result) }) } @@ -73,5 +73,5 @@ func TestPKCES256Generation(t *testing.T) { challenge := base64.RawURLEncoding.EncodeToString(h[:]) require.Equal(t, expectedChallenge, challenge) - require.True(t, identityprovider.VerifyPKCE(challenge, verifier)) + require.True(t, oauth2provider.VerifyPKCE(challenge, verifier)) } diff --git a/coderd/oauth2provider/registration.go b/coderd/oauth2provider/registration.go new file mode 100644 index 0000000000..63d2de4f48 --- /dev/null +++ b/coderd/oauth2provider/registration.go @@ -0,0 +1,584 @@ +package oauth2provider + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "github.com/sqlc-dev/pqtype" + "golang.org/x/xerrors" + + "cdr.dev/slog" + + "github.com/coder/coder/v2/coderd/audit" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/userpassword" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/cryptorand" +) + +// Constants for OAuth2 secret generation (RFC 7591) +const ( + secretLength = 40 // Length of the actual secret part + displaySecretLength = 6 // Length of visible part in UI (last 6 characters) +) + +// CreateDynamicClientRegistration returns an http.HandlerFunc that handles POST /oauth2/register +func CreateDynamicClientRegistration(db database.Store, accessURL *url.URL, auditor *audit.Auditor, logger slog.Logger) http.HandlerFunc { + return func(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + aReq, commitAudit := audit.InitRequest[database.OAuth2ProviderApp](rw, &audit.RequestParams{ + Audit: *auditor, + Log: logger, + Request: r, + Action: database.AuditActionCreate, + }) + defer commitAudit() + + // Parse request + var req codersdk.OAuth2ClientRegistrationRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + // Validate request + if err := req.Validate(); err != nil { + writeOAuth2RegistrationError(ctx, rw, http.StatusBadRequest, + "invalid_client_metadata", err.Error()) + return + } + + // Apply defaults + req = req.ApplyDefaults() + + // Generate client credentials + clientID := uuid.New() + clientSecret, hashedSecret, err := generateClientCredentials() + if err != nil { + writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError, + "server_error", "Failed to generate client credentials") + return + } + + // Generate registration access token for RFC 7592 management + registrationToken, hashedRegToken, err := generateRegistrationAccessToken() + if err != nil { + writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError, + "server_error", "Failed to generate registration token") + return + } + + // Store in database - use system context since this is a public endpoint + now := dbtime.Now() + clientName := req.GenerateClientName() + //nolint:gocritic // Dynamic client registration is a public endpoint, system access required + app, err := db.InsertOAuth2ProviderApp(dbauthz.AsSystemRestricted(ctx), database.InsertOAuth2ProviderAppParams{ + ID: clientID, + CreatedAt: now, + UpdatedAt: now, + Name: clientName, + Icon: req.LogoURI, + CallbackURL: req.RedirectURIs[0], // Primary redirect URI + RedirectUris: req.RedirectURIs, + ClientType: sql.NullString{String: req.DetermineClientType(), Valid: true}, + 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}, + Scope: sql.NullString{String: req.Scope, Valid: true}, + Contacts: req.Contacts, + ClientUri: sql.NullString{String: req.ClientURI, Valid: req.ClientURI != ""}, + LogoUri: sql.NullString{String: req.LogoURI, Valid: req.LogoURI != ""}, + TosUri: sql.NullString{String: req.TOSURI, Valid: req.TOSURI != ""}, + PolicyUri: sql.NullString{String: req.PolicyURI, Valid: req.PolicyURI != ""}, + JwksUri: sql.NullString{String: req.JWKSURI, Valid: req.JWKSURI != ""}, + Jwks: pqtype.NullRawMessage{RawMessage: req.JWKS, Valid: len(req.JWKS) > 0}, + SoftwareID: sql.NullString{String: req.SoftwareID, Valid: req.SoftwareID != ""}, + SoftwareVersion: sql.NullString{String: req.SoftwareVersion, Valid: req.SoftwareVersion != ""}, + RegistrationAccessToken: sql.NullString{String: hashedRegToken, Valid: true}, + RegistrationClientUri: sql.NullString{String: fmt.Sprintf("%s/oauth2/clients/%s", accessURL.String(), clientID), Valid: true}, + }) + if err != nil { + logger.Error(ctx, "failed to store oauth2 client registration", + slog.Error(err), + slog.F("client_name", clientName), + slog.F("client_id", clientID.String()), + slog.F("redirect_uris", req.RedirectURIs)) + writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError, + "server_error", "Failed to store client registration") + return + } + + // Create client secret - parse the formatted secret to get components + parsedSecret, err := parseFormattedSecret(clientSecret) + if err != nil { + writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError, + "server_error", "Failed to parse generated secret") + return + } + + //nolint:gocritic // Dynamic client registration is a public endpoint, system access required + _, err = db.InsertOAuth2ProviderAppSecret(dbauthz.AsSystemRestricted(ctx), database.InsertOAuth2ProviderAppSecretParams{ + ID: uuid.New(), + CreatedAt: now, + SecretPrefix: []byte(parsedSecret.prefix), + HashedSecret: []byte(hashedSecret), + DisplaySecret: createDisplaySecret(clientSecret), + AppID: clientID, + }) + if err != nil { + writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError, + "server_error", "Failed to store client secret") + return + } + + // Set audit log data + aReq.New = app + + // Return response + response := codersdk.OAuth2ClientRegistrationResponse{ + ClientID: app.ID.String(), + ClientSecret: clientSecret, + ClientIDIssuedAt: app.ClientIDIssuedAt.Time.Unix(), + ClientSecretExpiresAt: 0, // No expiration + RedirectURIs: app.RedirectUris, + ClientName: app.Name, + ClientURI: app.ClientUri.String, + LogoURI: app.LogoUri.String, + TOSURI: app.TosUri.String, + PolicyURI: app.PolicyUri.String, + JWKSURI: app.JwksUri.String, + JWKS: app.Jwks.RawMessage, + SoftwareID: app.SoftwareID.String, + SoftwareVersion: app.SoftwareVersion.String, + GrantTypes: app.GrantTypes, + ResponseTypes: app.ResponseTypes, + TokenEndpointAuthMethod: app.TokenEndpointAuthMethod.String, + Scope: app.Scope.String, + Contacts: app.Contacts, + RegistrationAccessToken: registrationToken, + RegistrationClientURI: app.RegistrationClientUri.String, + } + + httpapi.Write(ctx, rw, http.StatusCreated, response) + } +} + +// GetClientConfiguration returns an http.HandlerFunc that handles GET /oauth2/clients/{client_id} +func GetClientConfiguration(db database.Store) http.HandlerFunc { + return func(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // Extract client ID from URL path + clientIDStr := chi.URLParam(r, "client_id") + clientID, err := uuid.Parse(clientIDStr) + if err != nil { + writeOAuth2RegistrationError(ctx, rw, http.StatusBadRequest, + "invalid_client_metadata", "Invalid client ID format") + return + } + + // Get app by client ID + //nolint:gocritic // RFC 7592 endpoints need system access to retrieve dynamically registered clients + app, err := db.GetOAuth2ProviderAppByClientID(dbauthz.AsSystemRestricted(ctx), clientID) + if err != nil { + if xerrors.Is(err, sql.ErrNoRows) { + writeOAuth2RegistrationError(ctx, rw, http.StatusUnauthorized, + "invalid_token", "Client not found") + } else { + writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError, + "server_error", "Failed to retrieve client") + } + return + } + + // Check if client was dynamically registered + if !app.DynamicallyRegistered.Bool { + writeOAuth2RegistrationError(ctx, rw, http.StatusUnauthorized, + "invalid_token", "Client was not dynamically registered") + return + } + + // Return client configuration (without client_secret for security) + response := codersdk.OAuth2ClientConfiguration{ + ClientID: app.ID.String(), + ClientIDIssuedAt: app.ClientIDIssuedAt.Time.Unix(), + ClientSecretExpiresAt: 0, // No expiration for now + RedirectURIs: app.RedirectUris, + ClientName: app.Name, + ClientURI: app.ClientUri.String, + LogoURI: app.LogoUri.String, + TOSURI: app.TosUri.String, + PolicyURI: app.PolicyUri.String, + JWKSURI: app.JwksUri.String, + JWKS: app.Jwks.RawMessage, + SoftwareID: app.SoftwareID.String, + SoftwareVersion: app.SoftwareVersion.String, + GrantTypes: app.GrantTypes, + ResponseTypes: app.ResponseTypes, + TokenEndpointAuthMethod: app.TokenEndpointAuthMethod.String, + Scope: app.Scope.String, + Contacts: app.Contacts, + RegistrationAccessToken: "", // RFC 7592: Not returned in GET responses for security + RegistrationClientURI: app.RegistrationClientUri.String, + } + + httpapi.Write(ctx, rw, http.StatusOK, response) + } +} + +// UpdateClientConfiguration returns an http.HandlerFunc that handles PUT /oauth2/clients/{client_id} +func UpdateClientConfiguration(db database.Store, auditor *audit.Auditor, logger slog.Logger) http.HandlerFunc { + return func(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + aReq, commitAudit := audit.InitRequest[database.OAuth2ProviderApp](rw, &audit.RequestParams{ + Audit: *auditor, + Log: logger, + Request: r, + Action: database.AuditActionWrite, + }) + defer commitAudit() + + // Extract client ID from URL path + clientIDStr := chi.URLParam(r, "client_id") + clientID, err := uuid.Parse(clientIDStr) + if err != nil { + writeOAuth2RegistrationError(ctx, rw, http.StatusBadRequest, + "invalid_client_metadata", "Invalid client ID format") + return + } + + // Parse request + var req codersdk.OAuth2ClientRegistrationRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + // Validate request + if err := req.Validate(); err != nil { + writeOAuth2RegistrationError(ctx, rw, http.StatusBadRequest, + "invalid_client_metadata", err.Error()) + return + } + + // Apply defaults + req = req.ApplyDefaults() + + // Get existing app to verify it exists and is dynamically registered + //nolint:gocritic // RFC 7592 endpoints need system access to retrieve dynamically registered clients + existingApp, err := db.GetOAuth2ProviderAppByClientID(dbauthz.AsSystemRestricted(ctx), clientID) + if err == nil { + aReq.Old = existingApp + } + if err != nil { + if xerrors.Is(err, sql.ErrNoRows) { + writeOAuth2RegistrationError(ctx, rw, http.StatusUnauthorized, + "invalid_token", "Client not found") + } else { + writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError, + "server_error", "Failed to retrieve client") + } + return + } + + // Check if client was dynamically registered + if !existingApp.DynamicallyRegistered.Bool { + writeOAuth2RegistrationError(ctx, rw, http.StatusForbidden, + "invalid_token", "Client was not dynamically registered") + return + } + + // Update app in database + now := dbtime.Now() + //nolint:gocritic // RFC 7592 endpoints need system access to update dynamically registered clients + updatedApp, err := db.UpdateOAuth2ProviderAppByClientID(dbauthz.AsSystemRestricted(ctx), database.UpdateOAuth2ProviderAppByClientIDParams{ + ID: clientID, + UpdatedAt: now, + Name: req.GenerateClientName(), + Icon: req.LogoURI, + CallbackURL: req.RedirectURIs[0], // Primary redirect URI + 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}, + Scope: sql.NullString{String: req.Scope, Valid: true}, + Contacts: req.Contacts, + ClientUri: sql.NullString{String: req.ClientURI, Valid: req.ClientURI != ""}, + LogoUri: sql.NullString{String: req.LogoURI, Valid: req.LogoURI != ""}, + TosUri: sql.NullString{String: req.TOSURI, Valid: req.TOSURI != ""}, + PolicyUri: sql.NullString{String: req.PolicyURI, Valid: req.PolicyURI != ""}, + JwksUri: sql.NullString{String: req.JWKSURI, Valid: req.JWKSURI != ""}, + Jwks: pqtype.NullRawMessage{RawMessage: req.JWKS, Valid: len(req.JWKS) > 0}, + SoftwareID: sql.NullString{String: req.SoftwareID, Valid: req.SoftwareID != ""}, + SoftwareVersion: sql.NullString{String: req.SoftwareVersion, Valid: req.SoftwareVersion != ""}, + }) + if err != nil { + writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError, + "server_error", "Failed to update client") + return + } + + // Set audit log data + aReq.New = updatedApp + + // Return updated client configuration + response := codersdk.OAuth2ClientConfiguration{ + ClientID: updatedApp.ID.String(), + ClientIDIssuedAt: updatedApp.ClientIDIssuedAt.Time.Unix(), + ClientSecretExpiresAt: 0, // No expiration for now + RedirectURIs: updatedApp.RedirectUris, + ClientName: updatedApp.Name, + ClientURI: updatedApp.ClientUri.String, + LogoURI: updatedApp.LogoUri.String, + TOSURI: updatedApp.TosUri.String, + PolicyURI: updatedApp.PolicyUri.String, + JWKSURI: updatedApp.JwksUri.String, + JWKS: updatedApp.Jwks.RawMessage, + SoftwareID: updatedApp.SoftwareID.String, + SoftwareVersion: updatedApp.SoftwareVersion.String, + GrantTypes: updatedApp.GrantTypes, + ResponseTypes: updatedApp.ResponseTypes, + TokenEndpointAuthMethod: updatedApp.TokenEndpointAuthMethod.String, + Scope: updatedApp.Scope.String, + Contacts: updatedApp.Contacts, + RegistrationAccessToken: updatedApp.RegistrationAccessToken.String, + RegistrationClientURI: updatedApp.RegistrationClientUri.String, + } + + httpapi.Write(ctx, rw, http.StatusOK, response) + } +} + +// DeleteClientConfiguration returns an http.HandlerFunc that handles DELETE /oauth2/clients/{client_id} +func DeleteClientConfiguration(db database.Store, auditor *audit.Auditor, logger slog.Logger) http.HandlerFunc { + return func(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + aReq, commitAudit := audit.InitRequest[database.OAuth2ProviderApp](rw, &audit.RequestParams{ + Audit: *auditor, + Log: logger, + Request: r, + Action: database.AuditActionDelete, + }) + defer commitAudit() + + // Extract client ID from URL path + clientIDStr := chi.URLParam(r, "client_id") + clientID, err := uuid.Parse(clientIDStr) + if err != nil { + writeOAuth2RegistrationError(ctx, rw, http.StatusBadRequest, + "invalid_client_metadata", "Invalid client ID format") + return + } + + // Get existing app to verify it exists and is dynamically registered + //nolint:gocritic // RFC 7592 endpoints need system access to retrieve dynamically registered clients + existingApp, err := db.GetOAuth2ProviderAppByClientID(dbauthz.AsSystemRestricted(ctx), clientID) + if err == nil { + aReq.Old = existingApp + } + if err != nil { + if xerrors.Is(err, sql.ErrNoRows) { + writeOAuth2RegistrationError(ctx, rw, http.StatusUnauthorized, + "invalid_token", "Client not found") + } else { + writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError, + "server_error", "Failed to retrieve client") + } + return + } + + // Check if client was dynamically registered + if !existingApp.DynamicallyRegistered.Bool { + writeOAuth2RegistrationError(ctx, rw, http.StatusForbidden, + "invalid_token", "Client was not dynamically registered") + return + } + + // Delete the client and all associated data (tokens, secrets, etc.) + //nolint:gocritic // RFC 7592 endpoints need system access to delete dynamically registered clients + err = db.DeleteOAuth2ProviderAppByClientID(dbauthz.AsSystemRestricted(ctx), clientID) + if err != nil { + writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError, + "server_error", "Failed to delete client") + return + } + + // Note: audit data already set above with aReq.Old = existingApp + + // Return 204 No Content as per RFC 7592 + rw.WriteHeader(http.StatusNoContent) + } +} + +// RequireRegistrationAccessToken returns middleware that validates the registration access token for RFC 7592 endpoints +func RequireRegistrationAccessToken(db database.Store) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // Extract client ID from URL path + clientIDStr := chi.URLParam(r, "client_id") + clientID, err := uuid.Parse(clientIDStr) + if err != nil { + writeOAuth2RegistrationError(ctx, rw, http.StatusBadRequest, + "invalid_client_id", "Invalid client ID format") + return + } + + // Extract registration access token from Authorization header + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + writeOAuth2RegistrationError(ctx, rw, http.StatusUnauthorized, + "invalid_token", "Missing Authorization header") + return + } + + if !strings.HasPrefix(authHeader, "Bearer ") { + writeOAuth2RegistrationError(ctx, rw, http.StatusUnauthorized, + "invalid_token", "Authorization header must use Bearer scheme") + return + } + + token := strings.TrimPrefix(authHeader, "Bearer ") + if token == "" { + writeOAuth2RegistrationError(ctx, rw, http.StatusUnauthorized, + "invalid_token", "Missing registration access token") + return + } + + // Get the client and verify the registration access token + //nolint:gocritic // RFC 7592 endpoints need system access to validate dynamically registered clients + app, err := db.GetOAuth2ProviderAppByClientID(dbauthz.AsSystemRestricted(ctx), clientID) + if err != nil { + if xerrors.Is(err, sql.ErrNoRows) { + // Return 401 for authentication-related issues, not 404 + writeOAuth2RegistrationError(ctx, rw, http.StatusUnauthorized, + "invalid_token", "Client not found") + } else { + writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError, + "server_error", "Failed to retrieve client") + } + return + } + + // Check if client was dynamically registered + if !app.DynamicallyRegistered.Bool { + writeOAuth2RegistrationError(ctx, rw, http.StatusForbidden, + "invalid_token", "Client was not dynamically registered") + return + } + + // Verify the registration access token + if !app.RegistrationAccessToken.Valid { + writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError, + "server_error", "Client has no registration access token") + return + } + + // Compare the provided token with the stored hash + valid, err := userpassword.Compare(app.RegistrationAccessToken.String, token) + if err != nil { + writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError, + "server_error", "Failed to verify registration access token") + return + } + if !valid { + writeOAuth2RegistrationError(ctx, rw, http.StatusUnauthorized, + "invalid_token", "Invalid registration access token") + return + } + + // Token is valid, continue to the next handler + next.ServeHTTP(rw, r) + }) + } +} + +// Helper functions for RFC 7591 Dynamic Client Registration + +// generateClientCredentials generates a client secret for OAuth2 apps +func generateClientCredentials() (plaintext, hashed string, err error) { + // Use the same pattern as existing OAuth2 app secrets + secret, err := GenerateSecret() + if err != nil { + return "", "", xerrors.Errorf("generate secret: %w", err) + } + + return secret.Formatted, secret.Hashed, nil +} + +// generateRegistrationAccessToken generates a registration access token for RFC 7592 +func generateRegistrationAccessToken() (plaintext, hashed string, err error) { + token, err := cryptorand.String(secretLength) + if err != nil { + return "", "", xerrors.Errorf("generate registration token: %w", err) + } + + // Hash the token for storage + hashedToken, err := userpassword.Hash(token) + if err != nil { + return "", "", xerrors.Errorf("hash registration token: %w", err) + } + + return token, hashedToken, nil +} + +// writeOAuth2RegistrationError writes RFC 7591 compliant error responses +func writeOAuth2RegistrationError(_ context.Context, rw http.ResponseWriter, status int, errorCode, description string) { + // RFC 7591 error response format + errorResponse := map[string]string{ + "error": errorCode, + } + if description != "" { + errorResponse["error_description"] = description + } + + rw.Header().Set("Content-Type", "application/json") + rw.WriteHeader(status) + _ = json.NewEncoder(rw).Encode(errorResponse) +} + +// parsedSecret represents the components of a formatted OAuth2 secret +type parsedSecret struct { + prefix string + secret string +} + +// parseFormattedSecret parses a formatted secret like "coder_prefix_secret" +func parseFormattedSecret(secret string) (parsedSecret, error) { + parts := strings.Split(secret, "_") + if len(parts) != 3 { + return parsedSecret{}, xerrors.Errorf("incorrect number of parts: %d", len(parts)) + } + if parts[0] != "coder" { + return parsedSecret{}, xerrors.Errorf("incorrect scheme: %s", parts[0]) + } + return parsedSecret{ + prefix: parts[1], + secret: parts[2], + }, nil +} + +// createDisplaySecret creates a display version of the secret showing only the last few characters +func createDisplaySecret(secret string) string { + if len(secret) <= displaySecretLength { + return secret + } + + visiblePart := secret[len(secret)-displaySecretLength:] + hiddenLength := len(secret) - displaySecretLength + return strings.Repeat("*", hiddenLength) + visiblePart +} diff --git a/coderd/identityprovider/revoke.go b/coderd/oauth2provider/revoke.go similarity index 97% rename from coderd/identityprovider/revoke.go rename to coderd/oauth2provider/revoke.go index 78acb9ea0d..243ce75028 100644 --- a/coderd/identityprovider/revoke.go +++ b/coderd/oauth2provider/revoke.go @@ -1,4 +1,4 @@ -package identityprovider +package oauth2provider import ( "database/sql" diff --git a/coderd/identityprovider/secrets.go b/coderd/oauth2provider/secrets.go similarity index 57% rename from coderd/identityprovider/secrets.go rename to coderd/oauth2provider/secrets.go index 72524b3d2a..a360c0b325 100644 --- a/coderd/identityprovider/secrets.go +++ b/coderd/oauth2provider/secrets.go @@ -1,16 +1,13 @@ -package identityprovider +package oauth2provider import ( "fmt" - "strings" - - "golang.org/x/xerrors" "github.com/coder/coder/v2/coderd/userpassword" "github.com/coder/coder/v2/cryptorand" ) -type OAuth2ProviderAppSecret struct { +type AppSecret struct { // Formatted contains the secret. This value is owned by the client, not the // server. It is formatted to include the prefix. Formatted string @@ -26,11 +23,11 @@ type OAuth2ProviderAppSecret struct { // GenerateSecret generates a secret to be used as a client secret, refresh // token, or authorization code. -func GenerateSecret() (OAuth2ProviderAppSecret, error) { +func GenerateSecret() (AppSecret, error) { // 40 characters matches the length of GitHub's client secrets. secret, err := cryptorand.String(40) if err != nil { - return OAuth2ProviderAppSecret{}, err + return AppSecret{}, err } // This ID is prefixed to the secret so it can be used to look up the secret @@ -38,40 +35,17 @@ func GenerateSecret() (OAuth2ProviderAppSecret, error) { // will not have the salt. prefix, err := cryptorand.String(10) if err != nil { - return OAuth2ProviderAppSecret{}, err + return AppSecret{}, err } hashed, err := userpassword.Hash(secret) if err != nil { - return OAuth2ProviderAppSecret{}, err + return AppSecret{}, err } - return OAuth2ProviderAppSecret{ + return AppSecret{ Formatted: fmt.Sprintf("coder_%s_%s", prefix, secret), Prefix: prefix, Hashed: hashed, }, nil } - -type parsedSecret struct { - prefix string - secret string -} - -// parseSecret extracts the ID and original secret from a secret. -func parseSecret(secret string) (parsedSecret, error) { - parts := strings.Split(secret, "_") - if len(parts) != 3 { - return parsedSecret{}, xerrors.Errorf("incorrect number of parts: %d", len(parts)) - } - if parts[0] != "coder" { - return parsedSecret{}, xerrors.Errorf("incorrect scheme: %s", parts[0]) - } - if len(parts[1]) == 0 { - return parsedSecret{}, xerrors.Errorf("prefix is invalid") - } - if len(parts[2]) == 0 { - return parsedSecret{}, xerrors.Errorf("invalid") - } - return parsedSecret{parts[1], parts[2]}, nil -} diff --git a/coderd/identityprovider/tokens.go b/coderd/oauth2provider/tokens.go similarity index 98% rename from coderd/identityprovider/tokens.go rename to coderd/oauth2provider/tokens.go index 4cacf8f06a..afbc27dd8b 100644 --- a/coderd/identityprovider/tokens.go +++ b/coderd/oauth2provider/tokens.go @@ -1,4 +1,4 @@ -package identityprovider +package oauth2provider import ( "context" @@ -183,7 +183,7 @@ 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) { // Validate the client secret. - secret, err := parseSecret(params.clientSecret) + secret, err := parseFormattedSecret(params.clientSecret) if err != nil { return oauth2.Token{}, errBadSecret } @@ -204,7 +204,7 @@ func authorizationCodeGrant(ctx context.Context, db database.Store, app database } // Validate the authorization code. - code, err := parseSecret(params.code) + code, err := parseFormattedSecret(params.code) if err != nil { return oauth2.Token{}, errBadCode } @@ -335,7 +335,7 @@ func authorizationCodeGrant(ctx context.Context, db database.Store, app database func refreshTokenGrant(ctx context.Context, db database.Store, app database.OAuth2ProviderApp, lifetimes codersdk.SessionLifetime, params tokenParams) (oauth2.Token, error) { // Validate the token. - token, err := parseSecret(params.refreshToken) + token, err := parseFormattedSecret(params.refreshToken) if err != nil { return oauth2.Token{}, errBadToken }