Files
coder/coderd/oauth2provider/registration.go
T
Bobby Ho 6348c83142 feat: register public clients without a secret (#28046)
Third in the stack, split out of #27873. This is where dynamic client
registration first produces a public client.

- A registration requesting token_endpoint_auth_method `none` issues no
secret and persists the client_type derived in #28043. The response
omits client_secret entirely, per RFC 7591 §3.2.1.
- Discovery still advertises only the two secret-based methods. `none`
is withheld until the token endpoint honors it, so a conforming client
is not told to attempt an exchange that would be rejected.
- The app and its secret are written in one transaction. They were two
independent inserts, so a failed second one left a committed app that
could never authenticate while still holding a registration access
token. Pre-existing, but a public client's missing secret row makes the
orphaned case unspottable by inspection.
- registration_client_uri uses url.JoinPath, fixing a trailing-slash
access URL producing //oauth2/clients/{id}.
- Docs state both limits above, so the page does not describe a flow
that returns 400 until #28047 lands.

A client registered here cannot obtain a token yet; #28047 adds that.
Dynamic client registration is off by default, so none of this is
user-visible until then.

Refs
https://linear.app/codercom/issue/ENG-3029/oauth2-support-public-client
2026-08-20 12:49:47 -07:00

614 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()
clientType := req.DetermineClientType()
isPublic := clientType == codersdk.OAuth2ClientTypePublic
// Public clients authenticate with PKCE alone and never receive a
// secret (RFC 7591 §2, OAuth 2.1 §2.1).
clientID := uuid.New()
var clientSecret string
var hashedSecret []byte
if !isPublic {
var err error
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()
// The app and its secret are written in one transaction. A partial
// write would commit an app that can never authenticate, and which
// still holds a registration access token.
var app database.OAuth2ProviderApp
err = db.InTx(func(tx database.Store) error {
var err error
//nolint:gocritic // OAuth2 system context, dynamic registration is a public endpoint
app, err = tx.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(clientType),
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,
// JoinPath, not Sprintf: an access URL configured with a
// trailing slash would otherwise mint "//oauth2/clients/{id}"
// and hand it to the client as its management endpoint.
RegistrationClientUri: sql.NullString{String: accessURL.JoinPath("/oauth2/clients", clientID.String()).String(), Valid: true},
})
if err != nil {
return xerrors.Errorf("insert oauth2 provider app: %w", err)
}
if isPublic {
return nil
}
// Extract the prefix for the secret row below.
parsedSecret, err := ParseFormattedSecret(clientSecret)
if err != nil {
return xerrors.Errorf("parse generated secret: %w", err)
}
//nolint:gocritic // OAuth2 system context, dynamic registration is a public endpoint
_, err = tx.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 {
return xerrors.Errorf("insert oauth2 provider app secret: %w", err)
}
return nil
}, nil)
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
}
// 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
}