mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
Adds an OAuth2 client type (public vs confidential, RFC 7591 §2) derived from the requested auth method instead of hardcoded confidential. The type is stored and guarded here, but no endpoint enforces on it yet; public behavior at the token endpoint follows in the next PR in the stack. - Client type is derived once and reused by both registration and redirect URI validation, so they can't disagree - IsPublic() fails closed: an unrecognized or missing value reads as confidential - RFC 7592 update (PUT) now rejects moving a client between public and confidential (400) instead of silently flipping it when the auth method is omitted - Discovery still doesn't advertise "none"; follows once the token endpoint honors it ### Behavior by client shape `client_type` is derived from `token_endpoint_auth_method` at POST and pinned at PUT. RFC 7592 GET/PUT authenticate with the registration access token, not the client secret, so neither endpoint reads a secret. | Registered with | Stored `client_type` / method | GET reports | PUT that flips the method | |------------------------------------|---------------------------------------|-----------------------|-----------------------------------------| | omitted, or `client_secret_basic` | `confidential` / `client_secret_basic` | `client_secret_basic` | `none` → 400 `invalid_client_metadata` | | `none` (new) | `public` / `none` | `none` | `client_secret_*` → 400 `invalid_client_metadata` | | `none` (before this PR) | `confidential` / `none` | `none` | either → 200, type stays `confidential` | - PUT still replaces every other RFC 7591 field. `client_type` is the only pinned one; the method may move within a type (`client_secret_basic` ↔ `client_secret_post`). - Row 3 is the only shape where the two columns disagree. The guard fires only on a method change that crosses the type line, so those clients keep managing themselves instead of being locked out of their own configuration endpoint. - The token endpoint does not consult `client_type` yet, so every client still authenticates with a secret and registration still issues one. Split out of #27873, second in the stack (on top of #28041). Refs https://linear.app/codercom/issue/ENG-3029/oauth2-support-public-client
591 lines
24 KiB
Go
591 lines
24 KiB
Go
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/v3"
|
|
"github.com/coder/coder/v2/coderd/apikey"
|
|
"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/util/slice"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
)
|
|
|
|
// 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()
|
|
|
|
// This is queried on every request rather than cached, since
|
|
// registration is expected to happen rarely, not on a hot path. A
|
|
// misconfigured or misbehaving client flooding this endpoint is a
|
|
// rate-limiting or firewalling problem to solve at the deployment
|
|
// level, not a reason to cache this flag.
|
|
//nolint:gocritic // Public registration endpoint, no authenticated actor to authorize against.
|
|
dcrEnabled, err := db.GetOAuth2DCREnabled(dbauthz.AsSystemOAuth2(ctx))
|
|
if err != nil {
|
|
writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError,
|
|
"server_error", "Failed to check registration availability")
|
|
return
|
|
}
|
|
if !dcrEnabled {
|
|
writeOAuth2RegistrationError(ctx, rw, http.StatusForbidden,
|
|
"invalid_request", "Dynamic client registration is disabled on this deployment")
|
|
return
|
|
}
|
|
|
|
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 // OAuth2 system context — dynamic registration is a public endpoint
|
|
app, err := db.InsertOAuth2ProviderApp(dbauthz.AsSystemOAuth2(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: string(req.DetermineClientType()),
|
|
DynamicallyRegistered: sql.NullBool{Bool: true, Valid: true},
|
|
ClientIDIssuedAt: sql.NullTime{Time: now, Valid: true},
|
|
ClientSecretExpiresAt: sql.NullTime{}, // No expiration for now
|
|
GrantTypes: slice.ToStrings(req.GrantTypes),
|
|
ResponseTypes: slice.ToStrings(req.ResponseTypes),
|
|
TokenEndpointAuthMethod: sql.NullString{String: string(req.TokenEndpointAuthMethod), Valid: true},
|
|
Scope: sql.NullString{String: req.Scope, Valid: true},
|
|
Contacts: req.Contacts,
|
|
ClientUri: sql.NullString{String: req.ClientURI, Valid: req.ClientURI != ""},
|
|
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: hashedRegToken,
|
|
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 // OAuth2 system context — dynamic registration is a public endpoint
|
|
_, err = db.InsertOAuth2ProviderAppSecret(dbauthz.AsSystemOAuth2(ctx), database.InsertOAuth2ProviderAppSecretParams{
|
|
ID: uuid.New(),
|
|
CreatedAt: now,
|
|
SecretPrefix: []byte(parsedSecret.Prefix),
|
|
HashedSecret: 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: slice.StringEnums[codersdk.OAuth2ProviderGrantType](app.GrantTypes),
|
|
ResponseTypes: slice.StringEnums[codersdk.OAuth2ProviderResponseType](app.ResponseTypes),
|
|
TokenEndpointAuthMethod: codersdk.OAuth2TokenEndpointAuthMethod(app.TokenEndpointAuthMethod.String),
|
|
Scope: app.Scope.String,
|
|
Contacts: app.Contacts,
|
|
RegistrationAccessToken: registrationToken,
|
|
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 // OAuth2 system context — RFC 7592 client configuration endpoint
|
|
app, err := db.GetOAuth2ProviderAppByClientID(dbauthz.AsSystemOAuth2(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: slice.StringEnums[codersdk.OAuth2ProviderGrantType](app.GrantTypes),
|
|
ResponseTypes: slice.StringEnums[codersdk.OAuth2ProviderResponseType](app.ResponseTypes),
|
|
TokenEndpointAuthMethod: codersdk.OAuth2TokenEndpointAuthMethod(app.TokenEndpointAuthMethod.String),
|
|
Scope: app.Scope.String,
|
|
Contacts: app.Contacts,
|
|
RegistrationAccessToken: "", // 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 // OAuth2 system context — RFC 7592 client configuration endpoint
|
|
existingApp, err := db.GetOAuth2ProviderAppByClientID(dbauthz.AsSystemOAuth2(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
|
|
}
|
|
|
|
// A client's type is fixed at registration (RFC 7592 §2.2 permits
|
|
// rejecting metadata the server will not accept). Flipping it would
|
|
// either drop the secret requirement for a client that has one, or mark
|
|
// a client confidential when it has no secret and no way to be issued
|
|
// one.
|
|
//
|
|
// Requiring authMethodChanged means an update that leaves the auth
|
|
// method alone is never rejected, so a legacy row whose two columns
|
|
// disagree can still manage itself. IsPublic is the reader for the
|
|
// stored column so an unrecognized value is treated as confidential
|
|
// here exactly as it is at the token endpoint.
|
|
storedMethod := codersdk.OAuth2TokenEndpointAuthMethod(existingApp.TokenEndpointAuthMethod.String)
|
|
authMethodChanged := req.TokenEndpointAuthMethod != storedMethod
|
|
clientTypeChanged := (req.DetermineClientType() == codersdk.OAuth2ClientTypePublic) != existingApp.IsPublic()
|
|
if authMethodChanged && clientTypeChanged {
|
|
logger.Warn(ctx, "rejected oauth2 client type change",
|
|
slog.F("client_id", clientID.String()),
|
|
slog.F("stored_token_endpoint_auth_method", existingApp.TokenEndpointAuthMethod.String),
|
|
slog.F("requested_token_endpoint_auth_method", string(req.TokenEndpointAuthMethod)),
|
|
slog.F("stored_client_type", existingApp.ClientType))
|
|
writeOAuth2RegistrationError(ctx, rw, http.StatusBadRequest,
|
|
"invalid_client_metadata",
|
|
fmt.Sprintf("token_endpoint_auth_method cannot move an existing client between public and confidential (stored %q, requested %q); the client type is fixed at registration, so register a new client instead",
|
|
existingApp.TokenEndpointAuthMethod.String, string(req.TokenEndpointAuthMethod)))
|
|
return
|
|
}
|
|
|
|
// Update app in database
|
|
now := dbtime.Now()
|
|
//nolint:gocritic // OAuth2 system context — RFC 7592 client configuration endpoint
|
|
updatedApp, err := db.UpdateOAuth2ProviderAppByClientID(dbauthz.AsSystemOAuth2(ctx), database.UpdateOAuth2ProviderAppByClientIDParams{
|
|
ID: clientID,
|
|
UpdatedAt: now,
|
|
Name: req.GenerateClientName(),
|
|
Icon: req.LogoURI,
|
|
CallbackURL: req.RedirectURIs[0], // Primary redirect URI
|
|
RedirectUris: req.RedirectURIs,
|
|
// Carried through unchanged. The guard above rejects a request that
|
|
// would change the type, so re-deriving it here could only ever
|
|
// differ for a legacy row whose stored type and auth method
|
|
// disagree, silently converting it to public while it still holds a
|
|
// secret.
|
|
ClientType: existingApp.ClientType,
|
|
ClientSecretExpiresAt: sql.NullTime{}, // No expiration for now
|
|
GrantTypes: slice.ToStrings(req.GrantTypes),
|
|
ResponseTypes: slice.ToStrings(req.ResponseTypes),
|
|
TokenEndpointAuthMethod: sql.NullString{String: string(req.TokenEndpointAuthMethod), Valid: true},
|
|
Scope: sql.NullString{String: req.Scope, Valid: true},
|
|
Contacts: req.Contacts,
|
|
ClientUri: sql.NullString{String: req.ClientURI, Valid: req.ClientURI != ""},
|
|
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: slice.StringEnums[codersdk.OAuth2ProviderGrantType](updatedApp.GrantTypes),
|
|
ResponseTypes: slice.StringEnums[codersdk.OAuth2ProviderResponseType](updatedApp.ResponseTypes),
|
|
TokenEndpointAuthMethod: codersdk.OAuth2TokenEndpointAuthMethod(updatedApp.TokenEndpointAuthMethod.String),
|
|
Scope: updatedApp.Scope.String,
|
|
Contacts: updatedApp.Contacts,
|
|
RegistrationAccessToken: "", // RFC 7592: Not returned for security
|
|
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 // OAuth2 system context — RFC 7592 client configuration endpoint
|
|
existingApp, err := db.GetOAuth2ProviderAppByClientID(dbauthz.AsSystemOAuth2(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 // OAuth2 system context — RFC 7592 client configuration endpoint
|
|
err = db.DeleteOAuth2ProviderAppByClientID(dbauthz.AsSystemOAuth2(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 // OAuth2 system context — RFC 7592 registration access token validation
|
|
app, err := db.GetOAuth2ProviderAppByClientID(dbauthz.AsSystemOAuth2(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 len(app.RegistrationAccessToken) == 0 {
|
|
writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError,
|
|
"server_error", "Client has no registration access token")
|
|
return
|
|
}
|
|
|
|
// Compare the provided token with the stored hash
|
|
if !apikey.ValidateHash(app.RegistrationAccessToken, token) {
|
|
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 string, hashed []byte, err error) {
|
|
// Use the same pattern as existing OAuth2 app secrets
|
|
secret, err := GenerateSecret()
|
|
if err != nil {
|
|
return "", nil, xerrors.Errorf("generate secret: %w", err)
|
|
}
|
|
|
|
return secret.Formatted, secret.Hashed, nil
|
|
}
|
|
|
|
// generateRegistrationAccessToken generates a registration access token for RFC 7592
|
|
func generateRegistrationAccessToken() (plaintext string, hashed []byte, err error) {
|
|
return apikey.GenerateSecret(secretLength)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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
|
|
}
|