Files
coder/coderd/oauth2provider/registration.go
T
Bobby Ho 663f41ffa9 feat: derive OAuth2 client type from token_endpoint_auth_method (#28043)
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
2026-08-18 21:12:44 -07:00

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
}