diff --git a/coderd/coderd.go b/coderd/coderd.go index 08915bc29d8fb..72316d1ea18e5 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 88f108c5fc13b..9195876b9eebe 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) -} - -// 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 +func (api *API) postOAuth2ClientRegistration() http.HandlerFunc { + return oauth2provider.CreateDynamicClientRegistration(api.Database, api.AccessURL, api.Auditor.Load(), api.Logger) } -// 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 3b3caeaa395e6..7e0f547f47824 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 0000000000000..5549ece4266f2 --- /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 0000000000000..74bafb851ef1a --- /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 3dcb511223e3b..77be5fc397a8a 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 0000000000000..9ce10f89933b7 --- /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 5b49bdd29fbcf..c989d068a821c 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 c5d4bf31cf7ff..8dbccb511a36c 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 7773a116a40f5..d0a90c6d34768 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 28e7ae38a3866..cb33c8914a676 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 08e4014077bc0..fd759dff88935 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 8cd8e1c8f47f2..f0ed74ca1b6b9 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 0000000000000..63d2de4f48394 --- /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 78acb9ea0de22..243ce750288bb 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 72524b3d2a077..a360c0b325c89 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 4cacf8f06a637..afbc27dd8b5a8 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 }
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies: