codersdk/oauth2_validation.go
Rama main · réplica de lectura del repositorio original.
package codersdk
import (
"net/url"
"slices"
"strings"
"golang.org/x/xerrors"
)
// RFC 7591 validation functions for Dynamic Client Registration
const (
// OAuth2RedirectURIsMaxCount is the most redirect URIs an app may register.
OAuth2RedirectURIsMaxCount = 32
// OAuth2RedirectURIMaxBytes is the longest a single redirect URI may be.
OAuth2RedirectURIMaxBytes = 2048
// OAuth2ScopeListMaxBytes bounds the length of an app's stored scope list.
// The full public catalog fits in well under this.
OAuth2ScopeListMaxBytes = 4096
// OAuth2ScopeListMaxNames bounds how many space-separated names an app's
// scope list may hold. The public catalog is about half this size.
OAuth2ScopeListMaxNames = 100
)
// ValidateOAuth2ScopeList caps the size of a scope list before it is stored as
// an app's allowlist. Registration is unauthenticated, so the cap keeps an
// anonymous caller from storing a list as large as the request body. Names are
// checked against the catalog elsewhere. The length check runs first so an
// oversized list is never split.
func ValidateOAuth2ScopeList(raw string) error {
if len(raw) > OAuth2ScopeListMaxBytes {
return xerrors.Errorf("must be at most %d bytes", OAuth2ScopeListMaxBytes)
}
if names := len(strings.Fields(raw)); names > OAuth2ScopeListMaxNames {
return xerrors.Errorf("must list at most %d names", OAuth2ScopeListMaxNames)
}
return nil
}
func (req *OAuth2ClientRegistrationRequest) Validate() error {
// Validate redirect URIs - required for authorization code flow
if len(req.RedirectURIs) == 0 {
return xerrors.New("redirect_uris is required for authorization code flow")
}
// The client type is derived once, by DetermineClientType, so which RFC 8252
// rules apply here cannot drift from what gets stored in client_type.
if err := ValidateRedirectURIs(req.RedirectURIs, req.DetermineClientType()); err != nil {
return xerrors.Errorf("invalid redirect_uris: %w", err)
}
// Validate grant types if specified
if len(req.GrantTypes) > 0 {
if err := validateGrantTypes(req.GrantTypes); err != nil {
return xerrors.Errorf("invalid grant_types: %w", err)
}
}
// Validate response types if specified
if len(req.ResponseTypes) > 0 {
if err := validateResponseTypes(req.ResponseTypes); err != nil {
return xerrors.Errorf("invalid response_types: %w", err)
}
}
// Validate token endpoint auth method if specified
if req.TokenEndpointAuthMethod != "" {
if err := validateTokenEndpointAuthMethod(req.TokenEndpointAuthMethod); err != nil {
return xerrors.Errorf("invalid token_endpoint_auth_method: %w", err)
}
}
// Validate URI fields
if req.ClientURI != "" {
if err := validateURIField(req.ClientURI, "client_uri"); err != nil {
return err
}
}
if req.LogoURI != "" {
if err := validateURIField(req.LogoURI, "logo_uri"); err != nil {
return err
}
}
if req.TOSURI != "" {
if err := validateURIField(req.TOSURI, "tos_uri"); err != nil {
return err
}
}
if req.PolicyURI != "" {
if err := validateURIField(req.PolicyURI, "policy_uri"); err != nil {
return err
}
}
if req.JWKSURI != "" {
if err := validateURIField(req.JWKSURI, "jwks_uri"); err != nil {
return err
}
}
return nil
}
// ValidateRedirectURIScheme reports whether the callback URL's scheme is
// safe to use as a redirect target. It returns an error when the scheme
// is empty, an unsupported URN, or one of the schemes that are dangerous
// in browser/HTML contexts (javascript, data, file, ftp).
//
// Legitimate custom schemes for native apps (e.g. vscode://, jetbrains://)
// are allowed.
func ValidateRedirectURIScheme(u *url.URL) error {
return validateScheme(u)
}
// ValidateRedirectURIShape checks that a redirect URI parses, uses an
// allowed scheme, names a host or a path, and has no fragment. Both the admin
// and dynamic registration paths run this check, so every stored redirect URI
// passes it.
func ValidateRedirectURIShape(raw string) error {
u, err := url.Parse(raw)
if err != nil {
return xerrors.Errorf("is not a valid URL: %w", err)
}
if err := validateScheme(u); err != nil {
return err
}
return validateParsedRedirectURIShape(raw, u)
}
// validateParsedRedirectURIShape checks the rules that apply to every
// client type: no fragment, and a host or a path is present. The caller
// has already checked the scheme.
func validateParsedRedirectURIShape(raw string, u *url.URL) error {
// Prevent URI fragments (RFC 6749 section 3.1.2).
if u.Fragment != "" || strings.Contains(raw, "#") {
return xerrors.New("must not contain a fragment component")
}
if u.Scheme == "urn" {
return nil
}
if (u.Scheme == "http" || u.Scheme == "https") && u.Host == "" {
return xerrors.New("http and https URLs must include a host")
}
if u.Opaque != "" || (u.Host == "" && u.Path == "") {
return xerrors.New("must name a host or a path")
}
return nil
}
// RedirectURIMatches reports whether a redirect_uri a client presented may be
// used in place of one the app registered. The rule is exact string equality
// (OAuth 2.1 §2.3.1). The one exception is a registered http URI to a loopback
// host, where the port is ignored (RFC 8252 §7.3). The exception depends on the
// registered URI alone, not on the client type.
func RedirectURIMatches(presented, registered *url.URL) bool {
if presented.String() == registered.String() {
return true
}
if registered.Scheme != "http" || !isLoopbackAddress(registered.Hostname()) {
return false
}
// Drop the port from both sides. Every other component must still match.
// Hostname() also strips IPv6 brackets, so both strings are built the same
// way and stay comparable.
p, r := *presented, *registered
p.Host, r.Host = p.Hostname(), r.Hostname()
return p.String() == r.String()
}
func validateScheme(u *url.URL) error {
if u.Scheme == "" {
return xerrors.New("must have a scheme")
}
// Handle special URNs (RFC 6749 section 3.1.2.1).
if u.Scheme == "urn" {
if u.String() == "urn:ietf:wg:oauth:2.0:oob" {
return nil
}
return xerrors.New("uses an unsupported URN scheme")
}
// Block dangerous schemes for security (not allowed by RFCs
// for OAuth2).
dangerousSchemes := []string{"javascript", "data", "file", "ftp"}
for _, dangerous := range dangerousSchemes {
if strings.EqualFold(u.Scheme, dangerous) {
return xerrors.Errorf("uses the dangerous scheme %s", dangerous)
}
}
return nil
}
// ValidateRedirectURIs validates redirect URIs according to RFC 7591, 8252.
// clientType selects which rules apply and is derived by DetermineClientType,
// the single owner of that mapping, so this cannot disagree with the type the
// app is stored as.
//
// Stored URIs are checked again on every update. An app that predates the
// caps and no longer passes must be deleted and registered again.
func ValidateRedirectURIs(uris []string, clientType OAuth2ClientType) error {
if len(uris) == 0 {
return xerrors.New("at least one redirect URI is required")
}
if len(uris) > OAuth2RedirectURIsMaxCount {
return xerrors.Errorf("at most %d redirect URIs are allowed", OAuth2RedirectURIsMaxCount)
}
for i, uriStr := range uris {
if err := ValidateRedirectURI(uriStr, clientType); err != nil {
return xerrors.Errorf("redirect URI at index %d: %w", i, err)
}
}
return nil
}
// ValidateRedirectURI checks one redirect URI against the dynamic client
// registration rules.
func ValidateRedirectURI(uriStr string, clientType OAuth2ClientType) error {
if uriStr == "" {
return xerrors.New("cannot be empty")
}
// Checked before parsing so an oversized value is never parsed.
if len(uriStr) > OAuth2RedirectURIMaxBytes {
return xerrors.Errorf("must be at most %d bytes", OAuth2RedirectURIMaxBytes)
}
uri, err := url.Parse(uriStr)
if err != nil {
return xerrors.Errorf("is not a valid URL: %w", err)
}
if err := validateScheme(uri); err != nil {
return err
}
// The urn:ietf:wg:oauth:2.0:oob scheme passed validation
// above but needs no further checks.
if uri.Scheme == "urn" {
return nil
}
isPublicClient := clientType == OAuth2ClientTypePublic
// Handle different validation for public vs confidential clients
if uri.Scheme == "http" || uri.Scheme == "https" {
// HTTP/HTTPS validation (RFC 8252 section 7.3)
if uri.Scheme == "http" {
if isPublicClient {
// For public clients, only allow loopback (RFC 8252)
if !isLoopbackAddress(uri.Hostname()) {
return xerrors.New("public clients may only use http with loopback addresses (127.0.0.1, ::1, localhost)")
}
} else {
// For confidential clients, allow localhost for development
if !isLocalhost(uri.Hostname()) {
return xerrors.New("must use https scheme for non-localhost URLs")
}
}
}
} else if isPublicClient {
// mailto, tel, and sms hand off to a mail client, dialer, or SMS
// app rather than returning control to the application that
// started the flow. A public client has no other way to obtain
// its authorization code, so registering one of these would
// produce a client that can never complete authorization.
//
// This check runs only for public clients because that is how
// custom-scheme validation was scoped before this change, not
// because these three schemes are known to be safe for a
// confidential client's redirect.
switch uri.Scheme {
case "mailto", "tel", "sms":
return xerrors.Errorf("public clients may not use the %s scheme", uri.Scheme)
}
}
// Beyond that, custom schemes need no further check: validateScheme
// already blocked the ones that are dangerous in a redirect context,
// and RFC 8252 §7.1 only recommends reverse-domain notation rather
// than requiring it. Rejecting bare schemes such as vscode:// or
// jetbrains:// would penalize the native and CLI apps this client
// type exists for; PKCE, not the scheme's spelling, is what secures
// the redirect.
// The client type checks above report the more specific error, so the
// shared shape rules run last.
return validateParsedRedirectURIShape(uriStr, uri)
}
// validateGrantTypes validates OAuth2 grant types
func validateGrantTypes(grantTypes []OAuth2ProviderGrantType) error {
for _, grant := range grantTypes {
if !isSupportedGrantType(grant) {
return xerrors.Errorf("unsupported grant type: %s", grant)
}
}
// Ensure authorization_code is present if redirect_uris are specified
hasAuthCode := slices.Contains(grantTypes, OAuth2ProviderGrantTypeAuthorizationCode)
if !hasAuthCode {
return xerrors.New("authorization_code grant type is required when redirect_uris are specified")
}
return nil
}
func isSupportedGrantType(grant OAuth2ProviderGrantType) bool {
switch grant {
case OAuth2ProviderGrantTypeAuthorizationCode, OAuth2ProviderGrantTypeRefreshToken:
return true
}
return false
}
// validateResponseTypes validates OAuth2 response types
func validateResponseTypes(responseTypes []OAuth2ProviderResponseType) error {
for _, responseType := range responseTypes {
if !isSupportedResponseType(responseType) {
return xerrors.Errorf("unsupported response type: %s", responseType)
}
}
return nil
}
func isSupportedResponseType(responseType OAuth2ProviderResponseType) bool {
return responseType == OAuth2ProviderResponseTypeCode
}
// validateTokenEndpointAuthMethod validates token endpoint authentication method
func validateTokenEndpointAuthMethod(method OAuth2TokenEndpointAuthMethod) error {
if !method.Valid() {
return xerrors.Errorf("unsupported token endpoint auth method: %s", method)
}
return nil
}
// ValidatePKCECodeChallengeMethod validates PKCE code_challenge_method parameter.
// Per OAuth 2.1, only S256 is supported; plain is rejected for security reasons.
func ValidatePKCECodeChallengeMethod(method string) error {
if method == "" {
return nil // Optional, defaults to S256 if code_challenge is provided
}
m := OAuth2PKCECodeChallengeMethod(method)
if m == OAuth2PKCECodeChallengeMethodPlain {
return xerrors.New("code_challenge_method 'plain' is not supported; use 'S256'")
}
if m != OAuth2PKCECodeChallengeMethodS256 {
return xerrors.Errorf("unsupported code_challenge_method: %s", method)
}
return nil
}
// validateURIField validates a URI field
func validateURIField(uriStr, fieldName string) error {
if uriStr == "" {
return nil // Empty URIs are allowed for optional fields
}
uri, err := url.Parse(uriStr)
if err != nil {
return xerrors.Errorf("invalid %s: %w", fieldName, err)
}
// Require absolute URLs with scheme
if !uri.IsAbs() {
return xerrors.Errorf("%s must be an absolute URL", fieldName)
}
// Only allow http/https schemes
if uri.Scheme != "http" && uri.Scheme != "https" {
return xerrors.Errorf("%s must use http or https scheme", fieldName)
}
// For production, prefer HTTPS
// Note: we allow HTTP for localhost but prefer HTTPS for production
// This could be made configurable in the future
return nil
}
// isLocalhost checks if hostname is localhost (allows broader development usage)
func isLocalhost(hostname string) bool {
return hostname == "localhost" ||
hostname == "127.0.0.1" ||
hostname == "::1" ||
strings.HasSuffix(hostname, ".localhost")
}
// isLoopbackAddress reports whether hostname is a loopback host. RFC 8252 §7.3
// names 127.0.0.1 and ::1. Coder also accepts localhost as its own policy.
func isLoopbackAddress(hostname string) bool {
return hostname == "localhost" ||
hostname == "127.0.0.1" ||
hostname == "::1"
}