Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions internal/model/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,7 @@ type OAuthServiceConfig struct {
WhitelistFile string `description:"Path to the OAuth whitelist file for this provider." yaml:"whitelistFile,omitempty"`
Scopes []string `description:"OAuth scopes." yaml:"scopes,omitempty"`
RedirectURL string `description:"OAuth redirect URL." yaml:"redirectUrl,omitempty"`
Issuer string `description:"OIDC issuer URL. When set, any OAuth endpoint left empty is filled from its /.well-known/openid-configuration document at startup. The issuer must be HTTPS and must match the issuer in the document; a non-HTTPS issuer and certificate verification both require this provider's 'insecure' option." yaml:"issuer,omitempty"`
AuthURL string `description:"OAuth authorization URL." yaml:"authUrl,omitempty"`
TokenURL string `description:"OAuth token URL." yaml:"tokenUrl,omitempty"`
UserinfoURL string `description:"OAuth userinfo URL." yaml:"userinfoUrl,omitempty"`
Expand Down
8 changes: 7 additions & 1 deletion internal/service/oauth_broker_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,13 @@ func NewOAuthBrokerService(i OAuthBrokerServiceInput) *OAuthBrokerService {
service.services[name] = presetFunc(cfg, i.Ctx)
service.log.App.Debug().Str("service", name).Msg("Loaded OAuth service from preset")
} else {
service.services[name] = NewOAuthService(cfg, name, i.Ctx)
resolved, err := resolveOIDCDiscovery(cfg, i.Ctx)

if err != nil {
service.log.App.Warn().Err(err).Str("service", name).Msg("Failed to resolve OIDC discovery document, using the configured endpoints")
}

service.services[name] = NewOAuthService(resolved, name, i.Ctx)
service.log.App.Debug().Str("service", name).Msg("Loaded OAuth service from custom config")
}
}
Expand Down
190 changes: 190 additions & 0 deletions internal/service/oauth_discovery.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
package service

import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"

"github.com/tinyauthapp/tinyauth/internal/model"
)

// maxDiscoveryBodyBytes caps how much of a discovery document is read, so a slow or hostile issuer
// cannot exhaust memory with an unbounded response body.
const maxDiscoveryBodyBytes = 1 << 20 // 1 MiB

// oidcDiscoveryDocument holds the endpoints Tinyauth can fill from an OIDC provider's well-known
// configuration (https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata).
type oidcDiscoveryDocument struct {
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
UserinfoEndpoint string `json:"userinfo_endpoint"`
}

// resolveOIDCDiscovery fills any OAuth endpoint (authorization, token, userinfo) left empty from the
// provider's OIDC discovery document when an issuer is configured. Explicitly configured endpoints are
// never overwritten, so a provider with all endpoints set (or no issuer) is returned unchanged and the
// behaviour stays backwards compatible. It fails soft: on any error the original config is returned with
// the error, so startup continues and the existing "missing endpoint" handling surfaces later.
func resolveOIDCDiscovery(cfg model.OAuthServiceConfig, ctx context.Context) (model.OAuthServiceConfig, error) {
if cfg.Issuer == "" {
return cfg, nil
}

if cfg.AuthURL != "" && cfg.TokenURL != "" && cfg.UserinfoURL != "" {
return cfg, nil
}

// OIDC discovery requires secure transport (OpenID Connect Discovery 1.0), otherwise an intermediary
// could swap the discovered endpoints (the token endpoint receives the client secret). A non-HTTPS
// issuer is only allowed when the operator explicitly sets this provider's insecure flag.
issuerURL, err := url.Parse(cfg.Issuer)

if err != nil {
return cfg, fmt.Errorf("invalid OIDC issuer URL %q: %w", cfg.Issuer, err)
}

if issuerURL.Scheme != "https" && !cfg.Insecure {
return cfg, fmt.Errorf("refusing to fetch OIDC discovery from non-HTTPS issuer %q, set this provider's insecure option to allow it", cfg.Issuer)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

discoveryURL := strings.TrimRight(cfg.Issuer, "/") + "/.well-known/openid-configuration"

client := &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
TLSClientConfig: &tls.Config{
InsecureSkipVerify: cfg.Insecure,
MinVersion: tls.VersionTLS12,
},
},
// Do not let a redirect downgrade the transport to plaintext (or any non-HTTPS scheme) unless
// insecure is set; that would reopen the interception window the HTTPS requirement closes.
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return fmt.Errorf("stopped after 10 redirects")
}
if req.URL.Scheme != "https" && !cfg.Insecure {
return fmt.Errorf("refusing to follow OIDC discovery redirect to non-HTTPS URL %q", req.URL.Redacted())
}
return nil
},
}

req, err := http.NewRequestWithContext(ctx, http.MethodGet, discoveryURL, nil)

if err != nil {
return cfg, fmt.Errorf("failed to build OIDC discovery request: %w", err)
}

resp, err := client.Do(req)

if err != nil {
return cfg, fmt.Errorf("failed to fetch OIDC discovery document: %w", err)
}

defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return cfg, fmt.Errorf("OIDC discovery document returned status %d", resp.StatusCode)
}

var doc oidcDiscoveryDocument

if err := json.NewDecoder(io.LimitReader(resp.Body, maxDiscoveryBodyBytes)).Decode(&doc); err != nil {
return cfg, fmt.Errorf("failed to decode OIDC discovery document: %w", err)
}

return applyDiscoveryDocument(cfg, doc)
}

// applyDiscoveryDocument validates a fetched discovery document against the configured provider and
// returns the config with any missing endpoint filled in. It enforces the issuer match and the HTTPS
// requirement on discovered endpoints; on any failure it returns the original config unchanged so the
// caller can fail soft.
func applyDiscoveryDocument(cfg model.OAuthServiceConfig, doc oidcDiscoveryDocument) (model.OAuthServiceConfig, error) {
// The issuer in the document MUST match the configured issuer (OIDC Discovery 1.0 section 4.3,
// RFC 8414 section 3.3). Rejecting a mismatch prevents a substitution/mix-up attack from pointing
// the endpoints (the token endpoint receives the client secret) at an unexpected provider. A
// trailing slash is not significant, so it is ignored.
if strings.TrimRight(doc.Issuer, "/") != strings.TrimRight(cfg.Issuer, "/") {
return cfg, fmt.Errorf("OIDC discovery issuer mismatch: document reports %q, expected %q", doc.Issuer, cfg.Issuer)
}

// Determine the effective endpoints: an explicitly configured value always wins, otherwise the
// discovered one is used. A discovered endpoint must be HTTPS unless insecure is set, otherwise a
// (possibly tampered) document could send the user to a cleartext authorization page or make the
// client POST its secret to a cleartext token endpoint. Explicitly configured values are the
// operator's own choice and are left as-is, matching the non-discovery config path. The config is
// only mutated once every required endpoint is present, so a document that is valid JSON but omits
// an endpoint is rejected (and surfaces the fail-soft warning) instead of silently building a
// provider with an empty endpoint.
secure := func(name, raw string) error {
if cfg.Insecure || raw == "" {
return nil
}
parsed, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("invalid %s %q in OIDC discovery document: %w", name, raw, err)
}
if parsed.Scheme != "https" {
return fmt.Errorf("OIDC discovery %s %q is not HTTPS, set this provider's insecure option to allow it", name, raw)
}
return nil
}

authURL := cfg.AuthURL
if authURL == "" {
if err := secure("authorization_endpoint", doc.AuthorizationEndpoint); err != nil {
return cfg, err
}
authURL = doc.AuthorizationEndpoint
}

tokenURL := cfg.TokenURL
if tokenURL == "" {
if err := secure("token_endpoint", doc.TokenEndpoint); err != nil {
return cfg, err
}
tokenURL = doc.TokenEndpoint
}

userinfoURL := cfg.UserinfoURL
if userinfoURL == "" {
if err := secure("userinfo_endpoint", doc.UserinfoEndpoint); err != nil {
return cfg, err
}
userinfoURL = doc.UserinfoEndpoint
}

var missing []string

if authURL == "" {
missing = append(missing, "authorization_endpoint")
}

if tokenURL == "" {
missing = append(missing, "token_endpoint")
}

if userinfoURL == "" {
missing = append(missing, "userinfo_endpoint")
}

if len(missing) > 0 {
return cfg, fmt.Errorf("OIDC discovery document from %q is missing required endpoint(s): %s", cfg.Issuer, strings.Join(missing, ", "))
}

cfg.AuthURL = authURL
cfg.TokenURL = tokenURL
Comment thread
coderabbitai[bot] marked this conversation as resolved.
cfg.UserinfoURL = userinfoURL

return cfg, nil
}
Loading