refactor: move OAuth2 provider code to dedicated package (#18746)

# Refactor OAuth2 Provider Code into Dedicated Package

This PR refactors the OAuth2 provider functionality by moving it from the main `coderd` package into a dedicated `oauth2provider` package. The change improves code organization and maintainability without changing functionality.

Key changes:

- Created a new `oauth2provider` package to house all OAuth2 provider-related code
- Moved existing OAuth2 provider functionality from `coderd/identityprovider` to the new package
- Refactored handler functions to follow a consistent pattern of returning `http.HandlerFunc` instead of being handlers directly
- Split large files into smaller, more focused files organized by functionality:
  - `app_secrets.go` - Manages OAuth2 application secrets
  - `apps.go` - Handles OAuth2 application CRUD operations
  - `authorize.go` - Implements the authorization flow
  - `metadata.go` - Provides OAuth2 metadata endpoints
  - `registration.go` - Handles dynamic client registration
  - `revoke.go` - Implements token revocation
  - `secrets.go` - Manages secret generation and validation
  - `tokens.go` - Handles token issuance and validation

This refactoring improves code organization and makes the OAuth2 provider functionality more maintainable while preserving all existing behavior.
This commit is contained in:
Thomas Kosiewski
2025-07-03 20:24:45 +02:00
committed by GitHub
parent 7fbb3ced5b
commit c65013384a
17 changed files with 1095 additions and 981 deletions

View File

@ -19,6 +19,7 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"github.com/coder/coder/v2/coderd/oauth2provider"
"github.com/coder/coder/v2/coderd/prebuilds" "github.com/coder/coder/v2/coderd/prebuilds"
"github.com/andybalholm/brotli" "github.com/andybalholm/brotli"
@ -913,9 +914,9 @@ func New(options *Options) *API {
} }
// OAuth2 metadata endpoint for RFC 8414 discovery // 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 // 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 // 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 // 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 // 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 // RFC 7592 Client Configuration Management - Protected by registration access token
r.Route("/clients/{client_id}", func(r chi.Router) { r.Route("/clients/{client_id}", func(r chi.Router) {
r.Use( r.Use(
// Middleware to validate registration access token // Middleware to validate registration access token
api.requireRegistrationAccessToken, oauth2provider.RequireRegistrationAccessToken(api.Database),
) )
r.Get("/", api.oauth2ClientConfiguration) // Read client configuration r.Get("/", api.oauth2ClientConfiguration()) // Read client configuration
r.Put("/", api.putOAuth2ClientConfiguration) // Update client configuration r.Put("/", api.putOAuth2ClientConfiguration()) // Update client configuration
r.Delete("/", api.deleteOAuth2ClientConfiguration) // Delete client r.Delete("/", api.deleteOAuth2ClientConfiguration()) // Delete client
}) })
}) })
@ -1479,22 +1480,22 @@ func New(options *Options) *API {
httpmw.RequireExperimentWithDevBypass(api.Experiments, codersdk.ExperimentOAuth2), httpmw.RequireExperimentWithDevBypass(api.Experiments, codersdk.ExperimentOAuth2),
) )
r.Route("/apps", func(r chi.Router) { r.Route("/apps", func(r chi.Router) {
r.Get("/", api.oAuth2ProviderApps) r.Get("/", api.oAuth2ProviderApps())
r.Post("/", api.postOAuth2ProviderApp) r.Post("/", api.postOAuth2ProviderApp())
r.Route("/{app}", func(r chi.Router) { r.Route("/{app}", func(r chi.Router) {
r.Use(httpmw.ExtractOAuth2ProviderApp(options.Database)) r.Use(httpmw.ExtractOAuth2ProviderApp(options.Database))
r.Get("/", api.oAuth2ProviderApp) r.Get("/", api.oAuth2ProviderApp())
r.Put("/", api.putOAuth2ProviderApp) r.Put("/", api.putOAuth2ProviderApp())
r.Delete("/", api.deleteOAuth2ProviderApp) r.Delete("/", api.deleteOAuth2ProviderApp())
r.Route("/secrets", func(r chi.Router) { r.Route("/secrets", func(r chi.Router) {
r.Get("/", api.oAuth2ProviderAppSecrets) r.Get("/", api.oAuth2ProviderAppSecrets())
r.Post("/", api.postOAuth2ProviderAppSecret) r.Post("/", api.postOAuth2ProviderAppSecret())
r.Route("/{secretID}", func(r chi.Router) { r.Route("/{secretID}", func(r chi.Router) {
r.Use(httpmw.ExtractOAuth2ProviderAppSecret(options.Database)) r.Use(httpmw.ExtractOAuth2ProviderAppSecret(options.Database))
r.Delete("/", api.deleteOAuth2ProviderAppSecret) r.Delete("/", api.deleteOAuth2ProviderAppSecret())
}) })
}) })
}) })

View File

@ -1,39 +1,9 @@
package coderd package coderd
import ( import (
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http" "net/http"
"strings"
"github.com/go-chi/chi/v5" "github.com/coder/coder/v2/coderd/oauth2provider"
"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)
) )
// @Summary Get OAuth2 applications. // @Summary Get OAuth2 applications.
@ -44,40 +14,8 @@ const (
// @Param user_id query string false "Filter by applications authorized for a user" // @Param user_id query string false "Filter by applications authorized for a user"
// @Success 200 {array} codersdk.OAuth2ProviderApp // @Success 200 {array} codersdk.OAuth2ProviderApp
// @Router /oauth2-provider/apps [get] // @Router /oauth2-provider/apps [get]
func (api *API) oAuth2ProviderApps(rw http.ResponseWriter, r *http.Request) { func (api *API) oAuth2ProviderApps() http.HandlerFunc {
ctx := r.Context() return oauth2provider.ListApps(api.Database, api.AccessURL)
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)
} }
// @Summary Get OAuth2 application. // @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" // @Param app path string true "App ID"
// @Success 200 {object} codersdk.OAuth2ProviderApp // @Success 200 {object} codersdk.OAuth2ProviderApp
// @Router /oauth2-provider/apps/{app} [get] // @Router /oauth2-provider/apps/{app} [get]
func (api *API) oAuth2ProviderApp(rw http.ResponseWriter, r *http.Request) { func (api *API) oAuth2ProviderApp() http.HandlerFunc {
ctx := r.Context() return oauth2provider.GetApp(api.AccessURL)
app := httpmw.OAuth2ProviderApp(r)
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.OAuth2ProviderApp(api.AccessURL, app))
} }
// @Summary Create OAuth2 application. // @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." // @Param request body codersdk.PostOAuth2ProviderAppRequest true "The OAuth2 application to create."
// @Success 200 {object} codersdk.OAuth2ProviderApp // @Success 200 {object} codersdk.OAuth2ProviderApp
// @Router /oauth2-provider/apps [post] // @Router /oauth2-provider/apps [post]
func (api *API) postOAuth2ProviderApp(rw http.ResponseWriter, r *http.Request) { func (api *API) postOAuth2ProviderApp() http.HandlerFunc {
var ( return oauth2provider.CreateApp(api.Database, api.AccessURL, api.Auditor.Load(), api.Logger)
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))
} }
// @Summary Update OAuth2 application. // @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." // @Param request body codersdk.PutOAuth2ProviderAppRequest true "Update an OAuth2 application."
// @Success 200 {object} codersdk.OAuth2ProviderApp // @Success 200 {object} codersdk.OAuth2ProviderApp
// @Router /oauth2-provider/apps/{app} [put] // @Router /oauth2-provider/apps/{app} [put]
func (api *API) putOAuth2ProviderApp(rw http.ResponseWriter, r *http.Request) { func (api *API) putOAuth2ProviderApp() http.HandlerFunc {
var ( return oauth2provider.UpdateApp(api.Database, api.AccessURL, api.Auditor.Load(), api.Logger)
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))
} }
// @Summary Delete OAuth2 application. // @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" // @Param app path string true "App ID"
// @Success 204 // @Success 204
// @Router /oauth2-provider/apps/{app} [delete] // @Router /oauth2-provider/apps/{app} [delete]
func (api *API) deleteOAuth2ProviderApp(rw http.ResponseWriter, r *http.Request) { func (api *API) deleteOAuth2ProviderApp() http.HandlerFunc {
var ( return oauth2provider.DeleteApp(api.Database, api.Auditor.Load(), api.Logger)
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)
} }
// @Summary Get OAuth2 application secrets. // @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" // @Param app path string true "App ID"
// @Success 200 {array} codersdk.OAuth2ProviderAppSecret // @Success 200 {array} codersdk.OAuth2ProviderAppSecret
// @Router /oauth2-provider/apps/{app}/secrets [get] // @Router /oauth2-provider/apps/{app}/secrets [get]
func (api *API) oAuth2ProviderAppSecrets(rw http.ResponseWriter, r *http.Request) { func (api *API) oAuth2ProviderAppSecrets() http.HandlerFunc {
ctx := r.Context() return oauth2provider.GetAppSecrets(api.Database)
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)
} }
// @Summary Create OAuth2 application secret. // @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" // @Param app path string true "App ID"
// @Success 200 {array} codersdk.OAuth2ProviderAppSecretFull // @Success 200 {array} codersdk.OAuth2ProviderAppSecretFull
// @Router /oauth2-provider/apps/{app}/secrets [post] // @Router /oauth2-provider/apps/{app}/secrets [post]
func (api *API) postOAuth2ProviderAppSecret(rw http.ResponseWriter, r *http.Request) { func (api *API) postOAuth2ProviderAppSecret() http.HandlerFunc {
var ( return oauth2provider.CreateAppSecret(api.Database, api.Auditor.Load(), api.Logger)
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,
})
} }
// @Summary Delete OAuth2 application secret. // @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" // @Param secretID path string true "Secret ID"
// @Success 204 // @Success 204
// @Router /oauth2-provider/apps/{app}/secrets/{secretID} [delete] // @Router /oauth2-provider/apps/{app}/secrets/{secretID} [delete]
func (api *API) deleteOAuth2ProviderAppSecret(rw http.ResponseWriter, r *http.Request) { func (api *API) deleteOAuth2ProviderAppSecret() http.HandlerFunc {
var ( return oauth2provider.DeleteAppSecret(api.Database, api.Auditor.Load(), api.Logger)
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)
} }
// @Summary OAuth2 authorization request (GET - show authorization page). // @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" // @Success 200 "Returns HTML authorization page"
// @Router /oauth2/authorize [get] // @Router /oauth2/authorize [get]
func (api *API) getOAuth2ProviderAppAuthorize() http.HandlerFunc { 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). // @Summary OAuth2 authorization request (POST - process authorization).
@ -397,7 +131,7 @@ func (api *API) getOAuth2ProviderAppAuthorize() http.HandlerFunc {
// @Success 302 "Returns redirect with authorization code" // @Success 302 "Returns redirect with authorization code"
// @Router /oauth2/authorize [post] // @Router /oauth2/authorize [post]
func (api *API) postOAuth2ProviderAppAuthorize() http.HandlerFunc { func (api *API) postOAuth2ProviderAppAuthorize() http.HandlerFunc {
return identityprovider.ProcessAuthorize(api.Database, api.AccessURL) return oauth2provider.ProcessAuthorize(api.Database, api.AccessURL)
} }
// @Summary OAuth2 token exchange. // @Summary OAuth2 token exchange.
@ -412,7 +146,7 @@ func (api *API) postOAuth2ProviderAppAuthorize() http.HandlerFunc {
// @Success 200 {object} oauth2.Token // @Success 200 {object} oauth2.Token
// @Router /oauth2/tokens [post] // @Router /oauth2/tokens [post]
func (api *API) postOAuth2ProviderAppToken() http.HandlerFunc { 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. // @Summary Delete OAuth2 application tokens.
@ -423,7 +157,7 @@ func (api *API) postOAuth2ProviderAppToken() http.HandlerFunc {
// @Success 204 // @Success 204
// @Router /oauth2/tokens [delete] // @Router /oauth2/tokens [delete]
func (api *API) deleteOAuth2ProviderAppTokens() http.HandlerFunc { func (api *API) deleteOAuth2ProviderAppTokens() http.HandlerFunc {
return identityprovider.RevokeApp(api.Database) return oauth2provider.RevokeApp(api.Database)
} }
// @Summary OAuth2 authorization server metadata. // @Summary OAuth2 authorization server metadata.
@ -432,21 +166,8 @@ func (api *API) deleteOAuth2ProviderAppTokens() http.HandlerFunc {
// @Tags Enterprise // @Tags Enterprise
// @Success 200 {object} codersdk.OAuth2AuthorizationServerMetadata // @Success 200 {object} codersdk.OAuth2AuthorizationServerMetadata
// @Router /.well-known/oauth-authorization-server [get] // @Router /.well-known/oauth-authorization-server [get]
func (api *API) oauth2AuthorizationServerMetadata(rw http.ResponseWriter, r *http.Request) { func (api *API) oauth2AuthorizationServerMetadata() http.HandlerFunc {
ctx := r.Context() return oauth2provider.GetAuthorizationServerMetadata(api.AccessURL)
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)
} }
// @Summary OAuth2 protected resource metadata. // @Summary OAuth2 protected resource metadata.
@ -455,17 +176,8 @@ func (api *API) oauth2AuthorizationServerMetadata(rw http.ResponseWriter, r *htt
// @Tags Enterprise // @Tags Enterprise
// @Success 200 {object} codersdk.OAuth2ProtectedResourceMetadata // @Success 200 {object} codersdk.OAuth2ProtectedResourceMetadata
// @Router /.well-known/oauth-protected-resource [get] // @Router /.well-known/oauth-protected-resource [get]
func (api *API) oauth2ProtectedResourceMetadata(rw http.ResponseWriter, r *http.Request) { func (api *API) oauth2ProtectedResourceMetadata() http.HandlerFunc {
ctx := r.Context() return oauth2provider.GetProtectedResourceMetadata(api.AccessURL)
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)
} }
// @Summary OAuth2 dynamic client registration (RFC 7591) // @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" // @Param request body codersdk.OAuth2ClientRegistrationRequest true "Client registration request"
// @Success 201 {object} codersdk.OAuth2ClientRegistrationResponse // @Success 201 {object} codersdk.OAuth2ClientRegistrationResponse
// @Router /oauth2/register [post] // @Router /oauth2/register [post]
func (api *API) postOAuth2ClientRegistration(rw http.ResponseWriter, r *http.Request) { func (api *API) postOAuth2ClientRegistration() http.HandlerFunc {
ctx := r.Context() return oauth2provider.CreateDynamicClientRegistration(api.Database, api.AccessURL, api.Auditor.Load(), api.Logger)
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
}
// 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) // @Summary Get OAuth2 client configuration (RFC 7592)
// @ID get-oauth2-client-configuration // @ID get-oauth2-client-configuration
// @Accept json // @Accept json
@ -703,64 +200,8 @@ func createDisplaySecret(secret string) string {
// @Param client_id path string true "Client ID" // @Param client_id path string true "Client ID"
// @Success 200 {object} codersdk.OAuth2ClientConfiguration // @Success 200 {object} codersdk.OAuth2ClientConfiguration
// @Router /oauth2/clients/{client_id} [get] // @Router /oauth2/clients/{client_id} [get]
func (api *API) oauth2ClientConfiguration(rw http.ResponseWriter, r *http.Request) { func (api *API) oauth2ClientConfiguration() http.HandlerFunc {
ctx := r.Context() return oauth2provider.GetClientConfiguration(api.Database)
// 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)
} }
// @Summary Update OAuth2 client configuration (RFC 7592) // @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" // @Param request body codersdk.OAuth2ClientRegistrationRequest true "Client update request"
// @Success 200 {object} codersdk.OAuth2ClientConfiguration // @Success 200 {object} codersdk.OAuth2ClientConfiguration
// @Router /oauth2/clients/{client_id} [put] // @Router /oauth2/clients/{client_id} [put]
func (api *API) putOAuth2ClientConfiguration(rw http.ResponseWriter, r *http.Request) { func (api *API) putOAuth2ClientConfiguration() http.HandlerFunc {
ctx := r.Context() return oauth2provider.UpdateClientConfiguration(api.Database, api.Auditor.Load(), api.Logger)
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)
} }
// @Summary Delete OAuth2 client registration (RFC 7592) // @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" // @Param client_id path string true "Client ID"
// @Success 204 // @Success 204
// @Router /oauth2/clients/{client_id} [delete] // @Router /oauth2/clients/{client_id} [delete]
func (api *API) deleteOAuth2ClientConfiguration(rw http.ResponseWriter, r *http.Request) { func (api *API) deleteOAuth2ClientConfiguration() http.HandlerFunc {
ctx := r.Context() return oauth2provider.DeleteClientConfiguration(api.Database, api.Auditor.Load(), api.Logger)
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)
})
} }

View File

@ -22,7 +22,7 @@ import (
"github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/database/dbtime" "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/userpassword"
"github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk"
@ -865,7 +865,7 @@ func TestOAuth2ProviderTokenRefresh(t *testing.T) {
newKey, err := db.InsertAPIKey(ctx, key) newKey, err := db.InsertAPIKey(ctx, key)
require.NoError(t, err) require.NoError(t, err)
token, err := identityprovider.GenerateSecret() token, err := oauth2provider.GenerateSecret()
require.NoError(t, err) require.NoError(t, err)
expires := test.expires expires := test.expires

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -1,4 +1,4 @@
package identityprovider package oauth2provider
import ( import (
"database/sql" "database/sql"

View File

@ -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)
}
}

View File

@ -1,4 +1,4 @@
package identityprovider package oauth2provider
import ( import (
"net/http" "net/http"

View File

@ -1,4 +1,4 @@
package identityprovidertest package oauth2providertest
import ( import (
"crypto/sha256" "crypto/sha256"

View File

@ -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, // It includes helpers for creating OAuth2 apps, performing authorization flows, token exchanges,
// PKCE challenge generation and verification, and testing error scenarios. // PKCE challenge generation and verification, and testing error scenarios.
package identityprovidertest package oauth2providertest
import ( import (
"crypto/rand" "crypto/rand"

View File

@ -1,4 +1,4 @@
package identityprovidertest_test package oauth2providertest_test
import ( import (
"testing" "testing"
@ -6,7 +6,7 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/coderdtest" "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) { func TestOAuth2AuthorizationServerMetadata(t *testing.T) {
@ -18,7 +18,7 @@ func TestOAuth2AuthorizationServerMetadata(t *testing.T) {
_ = coderdtest.CreateFirstUser(t, client) _ = coderdtest.CreateFirstUser(t, client)
// Fetch OAuth2 metadata // Fetch OAuth2 metadata
metadata := identityprovidertest.FetchOAuth2Metadata(t, client.URL.String()) metadata := oauth2providertest.FetchOAuth2Metadata(t, client.URL.String())
// Verify required metadata fields // Verify required metadata fields
require.Contains(t, metadata, "issuer", "missing issuer in metadata") require.Contains(t, metadata, "issuer", "missing issuer in metadata")
@ -60,39 +60,39 @@ func TestOAuth2PKCEFlow(t *testing.T) {
_ = coderdtest.CreateFirstUser(t, client) _ = coderdtest.CreateFirstUser(t, client)
// Create OAuth2 app // Create OAuth2 app
app, clientSecret := identityprovidertest.CreateTestOAuth2App(t, client) app, clientSecret := oauth2providertest.CreateTestOAuth2App(t, client)
t.Cleanup(func() { t.Cleanup(func() {
identityprovidertest.CleanupOAuth2App(t, client, app.ID) oauth2providertest.CleanupOAuth2App(t, client, app.ID)
}) })
// Generate PKCE parameters // Generate PKCE parameters
codeVerifier, codeChallenge := identityprovidertest.GeneratePKCE(t) codeVerifier, codeChallenge := oauth2providertest.GeneratePKCE(t)
state := identityprovidertest.GenerateState(t) state := oauth2providertest.GenerateState(t)
// Perform authorization // Perform authorization
authParams := identityprovidertest.AuthorizeParams{ authParams := oauth2providertest.AuthorizeParams{
ClientID: app.ID.String(), ClientID: app.ID.String(),
ResponseType: "code", ResponseType: "code",
RedirectURI: identityprovidertest.TestRedirectURI, RedirectURI: oauth2providertest.TestRedirectURI,
State: state, State: state,
CodeChallenge: codeChallenge, CodeChallenge: codeChallenge,
CodeChallengeMethod: "S256", 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") require.NotEmpty(t, code, "should receive authorization code")
// Exchange code for token with PKCE // Exchange code for token with PKCE
tokenParams := identityprovidertest.TokenExchangeParams{ tokenParams := oauth2providertest.TokenExchangeParams{
GrantType: "authorization_code", GrantType: "authorization_code",
Code: code, Code: code,
ClientID: app.ID.String(), ClientID: app.ID.String(),
ClientSecret: clientSecret, ClientSecret: clientSecret,
CodeVerifier: codeVerifier, 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.AccessToken, "should receive access token")
require.NotEmpty(t, token.RefreshToken, "should receive refresh token") require.NotEmpty(t, token.RefreshToken, "should receive refresh token")
require.Equal(t, "Bearer", token.TokenType, "token type should be Bearer") require.Equal(t, "Bearer", token.TokenType, "token type should be Bearer")
@ -107,40 +107,40 @@ func TestOAuth2InvalidPKCE(t *testing.T) {
_ = coderdtest.CreateFirstUser(t, client) _ = coderdtest.CreateFirstUser(t, client)
// Create OAuth2 app // Create OAuth2 app
app, clientSecret := identityprovidertest.CreateTestOAuth2App(t, client) app, clientSecret := oauth2providertest.CreateTestOAuth2App(t, client)
t.Cleanup(func() { t.Cleanup(func() {
identityprovidertest.CleanupOAuth2App(t, client, app.ID) oauth2providertest.CleanupOAuth2App(t, client, app.ID)
}) })
// Generate PKCE parameters // Generate PKCE parameters
_, codeChallenge := identityprovidertest.GeneratePKCE(t) _, codeChallenge := oauth2providertest.GeneratePKCE(t)
state := identityprovidertest.GenerateState(t) state := oauth2providertest.GenerateState(t)
// Perform authorization // Perform authorization
authParams := identityprovidertest.AuthorizeParams{ authParams := oauth2providertest.AuthorizeParams{
ClientID: app.ID.String(), ClientID: app.ID.String(),
ResponseType: "code", ResponseType: "code",
RedirectURI: identityprovidertest.TestRedirectURI, RedirectURI: oauth2providertest.TestRedirectURI,
State: state, State: state,
CodeChallenge: codeChallenge, CodeChallenge: codeChallenge,
CodeChallengeMethod: "S256", 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") require.NotEmpty(t, code, "should receive authorization code")
// Attempt token exchange with wrong code verifier // Attempt token exchange with wrong code verifier
tokenParams := identityprovidertest.TokenExchangeParams{ tokenParams := oauth2providertest.TokenExchangeParams{
GrantType: "authorization_code", GrantType: "authorization_code",
Code: code, Code: code,
ClientID: app.ID.String(), ClientID: app.ID.String(),
ClientSecret: clientSecret, ClientSecret: clientSecret,
CodeVerifier: identityprovidertest.InvalidCodeVerifier, CodeVerifier: oauth2providertest.InvalidCodeVerifier,
RedirectURI: identityprovidertest.TestRedirectURI, RedirectURI: oauth2providertest.TestRedirectURI,
} }
identityprovidertest.PerformTokenExchangeExpectingError( oauth2providertest.PerformTokenExchangeExpectingError(
t, client.URL.String(), tokenParams, identityprovidertest.OAuth2ErrorTypes.InvalidGrant, t, client.URL.String(), tokenParams, oauth2providertest.OAuth2ErrorTypes.InvalidGrant,
) )
} }
@ -153,34 +153,34 @@ func TestOAuth2WithoutPKCE(t *testing.T) {
_ = coderdtest.CreateFirstUser(t, client) _ = coderdtest.CreateFirstUser(t, client)
// Create OAuth2 app // Create OAuth2 app
app, clientSecret := identityprovidertest.CreateTestOAuth2App(t, client) app, clientSecret := oauth2providertest.CreateTestOAuth2App(t, client)
t.Cleanup(func() { 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 // Perform authorization without PKCE
authParams := identityprovidertest.AuthorizeParams{ authParams := oauth2providertest.AuthorizeParams{
ClientID: app.ID.String(), ClientID: app.ID.String(),
ResponseType: "code", ResponseType: "code",
RedirectURI: identityprovidertest.TestRedirectURI, RedirectURI: oauth2providertest.TestRedirectURI,
State: state, 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") require.NotEmpty(t, code, "should receive authorization code")
// Exchange code for token without PKCE // Exchange code for token without PKCE
tokenParams := identityprovidertest.TokenExchangeParams{ tokenParams := oauth2providertest.TokenExchangeParams{
GrantType: "authorization_code", GrantType: "authorization_code",
Code: code, Code: code,
ClientID: app.ID.String(), ClientID: app.ID.String(),
ClientSecret: clientSecret, 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.AccessToken, "should receive access token")
require.NotEmpty(t, token.RefreshToken, "should receive refresh token") require.NotEmpty(t, token.RefreshToken, "should receive refresh token")
} }
@ -194,36 +194,36 @@ func TestOAuth2ResourceParameter(t *testing.T) {
_ = coderdtest.CreateFirstUser(t, client) _ = coderdtest.CreateFirstUser(t, client)
// Create OAuth2 app // Create OAuth2 app
app, clientSecret := identityprovidertest.CreateTestOAuth2App(t, client) app, clientSecret := oauth2providertest.CreateTestOAuth2App(t, client)
t.Cleanup(func() { 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 // Perform authorization with resource parameter
authParams := identityprovidertest.AuthorizeParams{ authParams := oauth2providertest.AuthorizeParams{
ClientID: app.ID.String(), ClientID: app.ID.String(),
ResponseType: "code", ResponseType: "code",
RedirectURI: identityprovidertest.TestRedirectURI, RedirectURI: oauth2providertest.TestRedirectURI,
State: state, 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") require.NotEmpty(t, code, "should receive authorization code")
// Exchange code for token with resource parameter // Exchange code for token with resource parameter
tokenParams := identityprovidertest.TokenExchangeParams{ tokenParams := oauth2providertest.TokenExchangeParams{
GrantType: "authorization_code", GrantType: "authorization_code",
Code: code, Code: code,
ClientID: app.ID.String(), ClientID: app.ID.String(),
ClientSecret: clientSecret, ClientSecret: clientSecret,
RedirectURI: identityprovidertest.TestRedirectURI, RedirectURI: oauth2providertest.TestRedirectURI,
Resource: identityprovidertest.TestResourceURI, 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.AccessToken, "should receive access token")
require.NotEmpty(t, token.RefreshToken, "should receive refresh token") require.NotEmpty(t, token.RefreshToken, "should receive refresh token")
} }
@ -237,43 +237,43 @@ func TestOAuth2TokenRefresh(t *testing.T) {
_ = coderdtest.CreateFirstUser(t, client) _ = coderdtest.CreateFirstUser(t, client)
// Create OAuth2 app // Create OAuth2 app
app, clientSecret := identityprovidertest.CreateTestOAuth2App(t, client) app, clientSecret := oauth2providertest.CreateTestOAuth2App(t, client)
t.Cleanup(func() { 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 // Get initial token
authParams := identityprovidertest.AuthorizeParams{ authParams := oauth2providertest.AuthorizeParams{
ClientID: app.ID.String(), ClientID: app.ID.String(),
ResponseType: "code", ResponseType: "code",
RedirectURI: identityprovidertest.TestRedirectURI, RedirectURI: oauth2providertest.TestRedirectURI,
State: state, 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", GrantType: "authorization_code",
Code: code, Code: code,
ClientID: app.ID.String(), ClientID: app.ID.String(),
ClientSecret: clientSecret, 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") require.NotEmpty(t, initialToken.RefreshToken, "should receive refresh token")
// Use refresh token to get new access token // Use refresh token to get new access token
refreshParams := identityprovidertest.TokenExchangeParams{ refreshParams := oauth2providertest.TokenExchangeParams{
GrantType: "refresh_token", GrantType: "refresh_token",
RefreshToken: initialToken.RefreshToken, RefreshToken: initialToken.RefreshToken,
ClientID: app.ID.String(), ClientID: app.ID.String(),
ClientSecret: clientSecret, 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.NotEmpty(t, refreshedToken.AccessToken, "should receive new access token")
require.NotEqual(t, initialToken.AccessToken, refreshedToken.AccessToken, "new access token should be different") 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.Run("InvalidClient", func(t *testing.T) {
t.Parallel() t.Parallel()
tokenParams := identityprovidertest.TokenExchangeParams{ tokenParams := oauth2providertest.TokenExchangeParams{
GrantType: "authorization_code", GrantType: "authorization_code",
Code: "invalid-code", Code: "invalid-code",
ClientID: "non-existent-client", ClientID: "non-existent-client",
ClientSecret: "invalid-secret", ClientSecret: "invalid-secret",
} }
identityprovidertest.PerformTokenExchangeExpectingError( oauth2providertest.PerformTokenExchangeExpectingError(
t, client.URL.String(), tokenParams, identityprovidertest.OAuth2ErrorTypes.InvalidClient, t, client.URL.String(), tokenParams, oauth2providertest.OAuth2ErrorTypes.InvalidClient,
) )
}) })
t.Run("InvalidGrantType", func(t *testing.T) { t.Run("InvalidGrantType", func(t *testing.T) {
t.Parallel() t.Parallel()
app, clientSecret := identityprovidertest.CreateTestOAuth2App(t, client) app, clientSecret := oauth2providertest.CreateTestOAuth2App(t, client)
t.Cleanup(func() { 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", GrantType: "invalid_grant_type",
ClientID: app.ID.String(), ClientID: app.ID.String(),
ClientSecret: clientSecret, ClientSecret: clientSecret,
} }
identityprovidertest.PerformTokenExchangeExpectingError( oauth2providertest.PerformTokenExchangeExpectingError(
t, client.URL.String(), tokenParams, identityprovidertest.OAuth2ErrorTypes.UnsupportedGrantType, t, client.URL.String(), tokenParams, oauth2providertest.OAuth2ErrorTypes.UnsupportedGrantType,
) )
}) })
t.Run("MissingCode", func(t *testing.T) { t.Run("MissingCode", func(t *testing.T) {
t.Parallel() t.Parallel()
app, clientSecret := identityprovidertest.CreateTestOAuth2App(t, client) app, clientSecret := oauth2providertest.CreateTestOAuth2App(t, client)
t.Cleanup(func() { t.Cleanup(func() {
identityprovidertest.CleanupOAuth2App(t, client, app.ID) oauth2providertest.CleanupOAuth2App(t, client, app.ID)
}) })
tokenParams := identityprovidertest.TokenExchangeParams{ tokenParams := oauth2providertest.TokenExchangeParams{
GrantType: "authorization_code", GrantType: "authorization_code",
ClientID: app.ID.String(), ClientID: app.ID.String(),
ClientSecret: clientSecret, ClientSecret: clientSecret,
} }
identityprovidertest.PerformTokenExchangeExpectingError( oauth2providertest.PerformTokenExchangeExpectingError(
t, client.URL.String(), tokenParams, identityprovidertest.OAuth2ErrorTypes.InvalidRequest, t, client.URL.String(), tokenParams, oauth2providertest.OAuth2ErrorTypes.InvalidRequest,
) )
}) })
} }

View File

@ -1,4 +1,4 @@
package identityprovider package oauth2provider
import ( import (
"crypto/sha256" "crypto/sha256"

View File

@ -1,4 +1,4 @@
package identityprovider_test package oauth2provider_test
import ( import (
"crypto/sha256" "crypto/sha256"
@ -7,7 +7,7 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/identityprovider" "github.com/coder/coder/v2/coderd/oauth2provider"
) )
func TestVerifyPKCE(t *testing.T) { func TestVerifyPKCE(t *testing.T) {
@ -55,7 +55,7 @@ func TestVerifyPKCE(t *testing.T) {
tt := tt tt := tt
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
t.Parallel() t.Parallel()
result := identityprovider.VerifyPKCE(tt.challenge, tt.verifier) result := oauth2provider.VerifyPKCE(tt.challenge, tt.verifier)
require.Equal(t, tt.expectValid, result) require.Equal(t, tt.expectValid, result)
}) })
} }
@ -73,5 +73,5 @@ func TestPKCES256Generation(t *testing.T) {
challenge := base64.RawURLEncoding.EncodeToString(h[:]) challenge := base64.RawURLEncoding.EncodeToString(h[:])
require.Equal(t, expectedChallenge, challenge) require.Equal(t, expectedChallenge, challenge)
require.True(t, identityprovider.VerifyPKCE(challenge, verifier)) require.True(t, oauth2provider.VerifyPKCE(challenge, verifier))
} }

View File

@ -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
}

View File

@ -1,4 +1,4 @@
package identityprovider package oauth2provider
import ( import (
"database/sql" "database/sql"

View File

@ -1,16 +1,13 @@
package identityprovider package oauth2provider
import ( import (
"fmt" "fmt"
"strings"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/userpassword" "github.com/coder/coder/v2/coderd/userpassword"
"github.com/coder/coder/v2/cryptorand" "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 // Formatted contains the secret. This value is owned by the client, not the
// server. It is formatted to include the prefix. // server. It is formatted to include the prefix.
Formatted string Formatted string
@ -26,11 +23,11 @@ type OAuth2ProviderAppSecret struct {
// GenerateSecret generates a secret to be used as a client secret, refresh // GenerateSecret generates a secret to be used as a client secret, refresh
// token, or authorization code. // token, or authorization code.
func GenerateSecret() (OAuth2ProviderAppSecret, error) { func GenerateSecret() (AppSecret, error) {
// 40 characters matches the length of GitHub's client secrets. // 40 characters matches the length of GitHub's client secrets.
secret, err := cryptorand.String(40) secret, err := cryptorand.String(40)
if err != nil { 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 // 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. // will not have the salt.
prefix, err := cryptorand.String(10) prefix, err := cryptorand.String(10)
if err != nil { if err != nil {
return OAuth2ProviderAppSecret{}, err return AppSecret{}, err
} }
hashed, err := userpassword.Hash(secret) hashed, err := userpassword.Hash(secret)
if err != nil { if err != nil {
return OAuth2ProviderAppSecret{}, err return AppSecret{}, err
} }
return OAuth2ProviderAppSecret{ return AppSecret{
Formatted: fmt.Sprintf("coder_%s_%s", prefix, secret), Formatted: fmt.Sprintf("coder_%s_%s", prefix, secret),
Prefix: prefix, Prefix: prefix,
Hashed: hashed, Hashed: hashed,
}, nil }, 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
}

View File

@ -1,4 +1,4 @@
package identityprovider package oauth2provider
import ( import (
"context" "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) { func authorizationCodeGrant(ctx context.Context, db database.Store, app database.OAuth2ProviderApp, lifetimes codersdk.SessionLifetime, params tokenParams) (oauth2.Token, error) {
// Validate the client secret. // Validate the client secret.
secret, err := parseSecret(params.clientSecret) secret, err := parseFormattedSecret(params.clientSecret)
if err != nil { if err != nil {
return oauth2.Token{}, errBadSecret return oauth2.Token{}, errBadSecret
} }
@ -204,7 +204,7 @@ func authorizationCodeGrant(ctx context.Context, db database.Store, app database
} }
// Validate the authorization code. // Validate the authorization code.
code, err := parseSecret(params.code) code, err := parseFormattedSecret(params.code)
if err != nil { if err != nil {
return oauth2.Token{}, errBadCode 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) { func refreshTokenGrant(ctx context.Context, db database.Store, app database.OAuth2ProviderApp, lifetimes codersdk.SessionLifetime, params tokenParams) (oauth2.Token, error) {
// Validate the token. // Validate the token.
token, err := parseSecret(params.refreshToken) token, err := parseFormattedSecret(params.refreshToken)
if err != nil { if err != nil {
return oauth2.Token{}, errBadToken return oauth2.Token{}, errBadToken
} }