registration

package
v0.44.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 18, 2026 License: Apache-2.0 Imports: 14 Imported by: 0

Documentation

Overview

Package registration provides OAuth client types and utilities, including RFC 8252 compliant loopback redirect URI support for native OAuth clients.

Package registration provides OAuth 2.0 Dynamic Client Registration (DCR) functionality per RFC 7591, including request validation and secure redirect URI handling for public native clients.

Index

Constants

View Source
const (
	// DCRErrorInvalidRedirectURI indicates that the value of one or more
	// redirect_uris is invalid.
	DCRErrorInvalidRedirectURI = "invalid_redirect_uri"

	// DCRErrorInvalidClientMetadata indicates that the value of one of the
	// client metadata fields is invalid and the server has rejected this request.
	DCRErrorInvalidClientMetadata = "invalid_client_metadata"
)

DCR error codes per RFC 7591 Section 3.2.2

View Source
const (
	// MaxRedirectURICount is the maximum number of redirect URIs allowed per client.
	MaxRedirectURICount = 10

	// MaxClientNameLength is the maximum allowed length for a client name.
	MaxClientNameLength = 256

	// MaxSoftwareIDLength is the maximum allowed length for a software_id
	// value. RFC 7591 does not mandate an upper bound, so we reuse the
	// client_name cap for consistency — a software_id is a similar-purpose
	// human-oriented identifier and the same ballpark DoS concerns apply.
	MaxSoftwareIDLength = 256
)

Validation limits to prevent DoS attacks via excessively large requests.

Variables

View Source
var DefaultScopes = []string{"openid", "profile", "email", "offline_access"}

DefaultScopes are the default OAuth 2.0 scopes for registered clients. Includes offline_access to enable refresh token issuance.

View Source
var SHA256Hasher fosite.Hasher = sha256Hasher{}

SHA256Hasher is a fosite.Hasher for client secrets based on plain SHA-256.

Client secrets issued by this server are 32 bytes of crypto/rand output, base64url-encoded, and are never client-chosen (the DCR request struct has no client_secret field). At 256 bits of CSPRNG entropy there is nothing for a password-stretching KDF to protect: brute force is infeasible regardless of hash cost. What a slow KDF *would* do is give an unauthenticated caller a CPU-amplification lever — one bcrypt verify per wrong-secret attempt against /oauth/token, in the same process as the proxy data path — so the work factor protects the attacker, not the secret.

Functions

func DCRIssued added in v0.43.0

func DCRIssued(client fosite.Client) bool

DCRIssued reports whether client was issued by this package.

func GenerateClientSecret added in v0.43.0

func GenerateClientSecret() (string, error)

GenerateClientSecret mints a new client secret: 32 bytes of crypto/rand output, base64url-encoded (43 characters, no padding). RawURLEncoding is load-bearing, not cosmetic: fosite url.QueryUnescape's both Basic-auth components, so a secret containing '+', '/', or '%' would be corrupted or rejected at the token endpoint.

func MarkDCRIssued added in v0.43.0

func MarkDCRIssued(client fosite.Client) fosite.Client

MarkDCRIssued wraps a client rebuilt from persisted DCR-issued form so the DCRIssued marker — and with it the anti-bloat TTL behaviour in storage — survives the storage round-trip. Callers must only mark clients they know were DCR-issued; pre-provisioned clients must never carry the marker.

client must be one of the two concrete shapes clientFromStored produces: *fosite.DefaultOpenIDConnectClient (a row with a recorded token_endpoint_auth_method) or *fosite.DefaultClient (a row with none). The type switch below embeds whichever concrete type it was given rather than the fosite.Client interface, so every method the concrete type implements — now and in the future, including optional fosite interfaces like ClientWithSecretRotation — promotes automatically instead of being silently dropped, which is exactly what embedding the interface would do (fosite type-asserts for ClientWithSecretRotation.GetRotatedHashes during secret validation).

Any other concrete type is a caller bug, but this sits on the GetClient path reachable from the unauthenticated /oauth/authorize endpoint, so it must not crash the process. Falling back to the client unwrapped, with an error log naming the type, is a bounded degradation: the row loses its DCRIssued marker and so stops renewing its TTL, which is recoverable — unlike a panic on an unauthenticated request path.

func New

func New(cfg Config) (fosite.Client, error)

New creates a fosite.Client from the given configuration. Public clients ("none") are wrapped in LoopbackClient to support RFC 8252 Section 7.3 compliant loopback redirect URI matching for native OAuth clients. Confidential clients (client_secret_basic / client_secret_post) require a Secret, have it SHA-256 hashed (see SHA256Hasher), and are not loopback-wrapped.

func NewConfidentialPlain added in v0.43.0

func NewConfidentialPlain(cfg Config) (fosite.Client, error)

NewConfidentialPlain creates a DCR-issued confidential client as a plain *fosite.DefaultClient (Public: false, hashed secret), NOT the *fosite.DefaultOpenIDConnectClient shape New produces for an ordinary confidential registration.

The difference matters: fosite only enforces token_endpoint_auth_method for clients implementing fosite.OpenIDConnectClient (see client_authentication.go in fosite v0.49.0 — AuthenticateClient type- switches on that interface before checking the method at all). A plain *fosite.DefaultClient with Public=false accepts credentials via either HTTP Basic or the form body and verifies whichever is presented. Use this constructor when the caller does not know which presentation the client will use — pinning the wrong one yields an invalid_client the operator cannot debug remotely.

This is the shape ValidateDCRRequest's override path uses when a request's redirect_uris matches an operator-configured Config.ForceConfidentialRedirectURIs entry: such a client declared itself public but requires a secret, and the operator cannot know in advance whether its OAuth library presents credentials via Basic or form body.

TokenEndpointAuthMethod on cfg is ignored; the returned client has no pinned method by construction. Ignores cfg.GrantTypes/ResponseTypes defaulting the same way New does. The returned client always carries the DCRIssued marker (see MarkDCRIssued) so storage retention applies.

func NewStaticDelegateClient added in v0.43.0

func NewStaticDelegateClient(cfg Config) (*fosite.DefaultClient, error)

NewStaticDelegateClient creates an unmarked, pre-provisioned confidential client for token exchange. A bare DefaultClient intentionally leaves the token endpoint authentication method unpinned, allowing both HTTP Basic and form-body client-secret authentication.

func UnionScopes added in v0.29.0

func UnionScopes(requested, baseline []string) []string

UnionScopes returns the union of requested and baseline scopes, preserving the order of requested first, then appending any baseline scopes not already present. Duplicates are removed. Returns nil when the result is empty.

Both inputs must already be validated by the caller. UnionScopes does not filter empty strings or validate scope syntax — it only deduplicates and merges in stable order.

func ValidateScopeSubset added in v0.27.2

func ValidateScopeSubset(subset, superset []string, fieldName string) error

ValidateScopeSubset checks that every scope in subset is also present in superset, returning an error that names fieldName and the offending scope.

Shared across the layers that validate baseline-scope configuration so the error message format is identical wherever the violation is caught (a caller using YAML-loaded config and a caller constructing config programmatically both see the same wording).

fieldName should be the wire-format or display name of the field being validated (e.g. "baseline_client_scopes"). It is embedded verbatim in the returned error.

Types

type Config

type Config struct {
	// ID is the unique client identifier.
	ID string

	// Secret is the client secret for confidential clients.
	// Required for client_secret_basic / client_secret_post; ignored for "none".
	Secret string //nolint:gosec // G117: field legitimately holds sensitive data

	// RedirectURIs is the list of allowed redirect URIs.
	RedirectURIs []string

	// TokenEndpointAuthMethod is the client's registered auth method:
	// "none" (public client) or one of the client_secret_* methods
	// (confidential client). Required — there is no default, so callers must
	// choose explicitly rather than drifting into one.
	TokenEndpointAuthMethod string

	// GrantTypes overrides the default grant types.
	// If nil or empty, defaultGrantTypes is used.
	GrantTypes []string

	// ResponseTypes overrides the default response types.
	// If nil or empty, defaultResponseTypes is used.
	ResponseTypes []string

	// Scopes overrides the default scopes.
	// If nil or empty, DefaultScopes is used.
	Scopes []string

	// Audience is the list of allowed audience values for this client.
	// Per RFC 8707, the "resource" parameter in token requests is validated
	// against this list. If nil, audience validation will reject all values.
	Audience []string
}

Config holds configuration for creating a new OAuth client.

type DCRError

type DCRError struct {
	// Error is a single ASCII error code from the defined set.
	Error string `json:"error"`

	// ErrorDescription is a human-readable text providing additional information.
	ErrorDescription string `json:"error_description,omitempty"`
}

DCRError represents an OAuth 2.0 Dynamic Client Registration error response per RFC 7591 Section 3.2.2.

func FilterPublicGrantTypes added in v0.43.0

func FilterPublicGrantTypes(grantTypes []string) ([]string, *DCRError)

FilterPublicGrantTypes returns the subset of grantTypes this server supports for public clients, dropping unsupported entries instead of rejecting the whole set the way ValidatePublicGrantTypes does.

This is the right semantics for CIMD: a Client ID Metadata Document describes the client's capabilities across every authorization server it talks to, and the client cannot tailor it per server — VS Code, for example, declares the device_code grant alongside authorization_code, and rejecting the document over a grant type this flow never uses breaks the client entirely. A DCR request, by contrast, is addressed to this server specifically, so ValidatePublicGrantTypes' rejection remains the correct feedback on that path.

The surviving set must still include "authorization_code": a client whose supported grant types do not intersect with the only redemption flow this server offers cannot function against it, and a clear error beats a client that registers and then fails every token request. nil/empty input gets the same defaults as DCR.

func FilterPublicResponseTypes added in v0.43.0

func FilterPublicResponseTypes(responseTypes []string) ([]string, *DCRError)

FilterPublicResponseTypes returns the subset of responseTypes this server supports for public clients, dropping unsupported entries instead of rejecting the whole set. Same reasoning as FilterPublicGrantTypes: a CIMD document declares capabilities across all servers, so an entry this server does not support must not be fatal — but "code" must survive the filter, since it is the only response type this server can serve. nil/empty input gets the same defaults as DCR.

func ValidateConfidentialRedirectURIs added in v0.43.0

func ValidateConfidentialRedirectURIs(redirectURIs []string, authMethod string) *DCRError

ValidateConfidentialRedirectURIs checks that every entry in redirectURIs meets the confidential-client policy: https non-loopback, RFC 8252 strict scheme rules. authMethod is embedded in the loopback-rejection message and should be the auth method the client is being registered with.

Shared by the ordinary confidential registration path (validateAuthMethod) and the force-confidential override (resolveForceConfidentialOverride in pkg/authserver/server/handlers/dcr.go), so a registration cannot become confidential by either path while carrying a loopback redirect_uri.

func ValidateDCRRequest

func ValidateDCRRequest(
	req *oauthproto.DynamicClientRegistrationRequest,
	allowConfidential bool,
) (*oauthproto.DynamicClientRegistrationRequest, *DCRError)

ValidateDCRRequest validates a DCR request according to RFC 7591 and the server's security policy. When allowConfidential is false the policy is unchanged from the historical default: public clients only. When true, token_endpoint_auth_method may also be client_secret_basic or client_secret_post; such confidential registrations are additionally restricted to https non-loopback redirect URIs, because a client on a loopback or private-scheme URI is by construction a public client (OAuth 2.1 §2.1) and minting it a secret ships that secret inside a distributed binary. Returns the validated request with defaults applied, or an error.

The validated request does NOT carry the requested scopes — scope validation against the server's supported set is a separate step, handled by ValidateScopes using the caller's policy inputs.

func ValidatePublicGrantTypes added in v0.29.0

func ValidatePublicGrantTypes(grantTypes []string) ([]string, *DCRError)

ValidatePublicGrantTypes validates the grant_types for a public OAuth client, applying the same rules as DCR: authorization_code must be present, and all declared values must be in the allowed set. Returns the validated slice (with defaults applied when nil/empty) or a *DCRError on violation.

func ValidatePublicResponseTypes added in v0.29.0

func ValidatePublicResponseTypes(responseTypes []string) ([]string, *DCRError)

ValidatePublicResponseTypes validates the response_types for a public OAuth client, applying the same rules as DCR: code must be present and all declared values must be in the allowed set. Returns the validated slice (with defaults applied when nil/empty) or a *DCRError on violation.

func ValidateRedirectURI

func ValidateRedirectURI(uri string) *DCRError

ValidateRedirectURI validates a redirect URI per RFC 8252: - HTTPS is allowed for any address (web-based redirects) - HTTP is only allowed for loopback addresses (127.0.0.1, [::1], localhost) - Private-use URI schemes (e.g., cursor://, vscode://) are allowed for native apps

func ValidateScopes added in v0.10.0

func ValidateScopes(requestedScopes, allowedScopes []string) ([]string, *DCRError)

ValidateScopes validates a slice of already-parsed scope tokens against the server's allowed set per RFC 7591 §2.

  • Empty/nil input falls back to DefaultScopes (which must itself be a subset of allowedScopes; otherwise the call returns an error).
  • Each requested scope must appear in allowedScopes; otherwise returns invalid_client_metadata.
  • Duplicates in the input are tolerated and deduplicated per RFC 6749 §3.3 (scope is a set of case-sensitive strings).

type LoopbackClient

type LoopbackClient struct {
	*fosite.DefaultOpenIDConnectClient
}

LoopbackClient wraps a fosite.DefaultOpenIDConnectClient with RFC 8252 Section 7.3 loopback redirect URI matching helpers (MatchRedirectURI, GetMatchingRedirectURI, defined below).

RFC 8252 Section 7.3 specifies that:

  • Loopback redirect URIs use "http" (not "https")
  • The host must be "127.0.0.1", "[::1]", or "localhost"
  • The authorization server MUST allow any port
  • The path and query components must match exactly

What this type does NOT do: fosite's own authorize-path redirect matching (MatchRedirectURIWithClientRedirectURIs → isMatchingAsLoopback) reads only GetRedirectURIs() and never calls this type's methods, so they take no effect on that path. Fosite's own loopback matching (isLoopbackAddress, net.ParseIP().IsLoopback()) covers IP literals (127.0.0.1, [::1]) but not the "localhost" hostname — net.ParseIP("localhost") returns nil — so a client registered with "http://localhost/callback" gets exact-match only against fosite's matcher; a dynamic-port authorize request like "http://localhost:57403/callback" (the pattern VS Code, Claude Code, and other native apps use) fails today. MatchRedirectURI/GetMatchingRedirectURI exist for callers that do their own matching outside fosite's authorize path; they are not a fosite hook. This type's live value in the codebase is carrying the OIDC client shape (so GetTokenEndpointAuthMethod survives) through storage's DCR round-trip.

func NewLoopbackClient

func NewLoopbackClient(client *fosite.DefaultOpenIDConnectClient) *LoopbackClient

NewLoopbackClient creates a new LoopbackClient wrapping the provided client. The wrapper preserves all OIDC fields (including TokenEndpointAuthMethod).

Note: fosite's redirect-matching path does not call MatchRedirectURI — MatchRedirectURIWithClientRedirectURIs reads only GetRedirectURIs() and applies fosite's own loopback handling (isMatchingAsLoopback), which covers loopback IP literals but not the "localhost" hostname. This wrapper's value is carrying the OIDC client shape (so GetTokenEndpointAuthMethod survives) for callers that do their own matching via MatchRedirectURI/ GetMatchingRedirectURI; it is not a fosite hook.

func (*LoopbackClient) GetMatchingRedirectURI

func (c *LoopbackClient) GetMatchingRedirectURI(requestedURI string) string

GetMatchingRedirectURI returns the matching redirect URI if found, or an empty string. For loopback URIs, returns the requested URI (with its port) if it matches a registered loopback pattern.

func (*LoopbackClient) MatchRedirectURI

func (c *LoopbackClient) MatchRedirectURI(requestedURI string) bool

MatchRedirectURI checks if the given redirect URI matches one of the client's registered redirect URIs, with RFC 8252 Section 7.3 loopback support.

For loopback URIs (127.0.0.1, [::1], or localhost), the port is allowed to vary while the scheme, host, path, and query must match exactly.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL