initial commit
Some checks failed
ci / go (push) Has been cancelled
ci / admin-ui (push) Has been cancelled
ci / runtime-sdk (push) Has been cancelled
ci / helm (push) Has been cancelled
ci / openapi (push) Has been cancelled
ci / container (push) Has been cancelled
ci / release-config (push) Has been cancelled
ci / shared styling (push) Has been cancelled
ci / security (push) Has been cancelled
ci / all green (push) Has been cancelled
Some checks failed
ci / go (push) Has been cancelled
ci / admin-ui (push) Has been cancelled
ci / runtime-sdk (push) Has been cancelled
ci / helm (push) Has been cancelled
ci / openapi (push) Has been cancelled
ci / container (push) Has been cancelled
ci / release-config (push) Has been cancelled
ci / shared styling (push) Has been cancelled
ci / security (push) Has been cancelled
ci / all green (push) Has been cancelled
This commit is contained in:
commit
951dd8d26b
175 changed files with 50041 additions and 0 deletions
715
internal/auth/auth.go
Normal file
715
internal/auth/auth.go
Normal file
|
|
@ -0,0 +1,715 @@
|
|||
// Package auth authenticates callers and authorises their actions.
|
||||
//
|
||||
// Three mechanisms are supported and are resolved by Authenticator in a fixed
|
||||
// order, most specific first:
|
||||
//
|
||||
// 1. An API key, presented as a bearer token. Machine access.
|
||||
// 2. An OIDC session, carried in a signed cookie. Human access to the admin
|
||||
// API, with a full login redirect flow.
|
||||
// 3. A development bypass, available only when the configuration explicitly
|
||||
// enables it and the process is running in a development environment.
|
||||
//
|
||||
// The development bypass refuses to initialise outside development. That check
|
||||
// is the single most important line of defence in this package: an accidentally
|
||||
// deployed dev mode is a total authentication bypass, so it is made impossible
|
||||
// to construct rather than merely documented as unsupported.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/urpi/go-bananas/internal/config"
|
||||
"github.com/urpi/go-bananas/internal/domain"
|
||||
)
|
||||
|
||||
// Errors returned by this package. They wrap the domain sentinels so the HTTP
|
||||
// layer can map them to status codes without importing this package's internals.
|
||||
var (
|
||||
ErrNoSession = fmt.Errorf("%w: no session", domain.ErrUnauthorized)
|
||||
ErrSessionExpired = fmt.Errorf("%w: session expired", domain.ErrUnauthorized)
|
||||
ErrSessionInvalid = fmt.Errorf("%w: session is invalid", domain.ErrUnauthorized)
|
||||
ErrDevBypassDenied = fmt.Errorf("%w: development bypass is not available", domain.ErrUnauthorized)
|
||||
)
|
||||
|
||||
// Identity, Role and the role constants are re-exported so that callers of this
|
||||
// package need not also import the domain package.
|
||||
type (
|
||||
// Identity is the authenticated caller.
|
||||
Identity = domain.Identity
|
||||
// Role is a coarse authorisation level.
|
||||
Role = domain.Role
|
||||
)
|
||||
|
||||
// Role constants, re-exported for the same reason.
|
||||
const (
|
||||
// RoleViewer may read published themes.
|
||||
RoleViewer = domain.RoleViewer
|
||||
// RoleEditor may create, edit and publish themes.
|
||||
RoleEditor = domain.RoleEditor
|
||||
// RoleAdmin may additionally manage users, API keys and settings.
|
||||
RoleAdmin = domain.RoleAdmin
|
||||
)
|
||||
|
||||
// Resolver is the identity-provider half of the interface. It is an interface so
|
||||
// that the session store can be tested without a live provider, and so that a
|
||||
// future provider (SAML, a service mesh) can be added without touching the HTTP
|
||||
// layer.
|
||||
// Login is the outcome of a completed login.
|
||||
type Login struct {
|
||||
// Identity is the authenticated caller.
|
||||
Identity *Identity
|
||||
// ReturnTo is the sanitised local path to send the browser to. It is always
|
||||
// a path on this service, never an absolute URL.
|
||||
ReturnTo string
|
||||
}
|
||||
|
||||
// SafeReturnTo restricts a post-login redirect to a local path.
|
||||
//
|
||||
// Without this check an attacker could craft a login link that, after a
|
||||
// successful authentication, bounces the user to a site of their choosing with the
|
||||
// session cookie freshly minted: an open redirect that also primes a phishing flow.
|
||||
// Absolute URLs, protocol-relative URLs, backslashes and control characters are all
|
||||
// rejected, because browsers normalise several of those into an origin.
|
||||
func SafeReturnTo(returnTo string) string {
|
||||
trimmed := strings.TrimSpace(returnTo)
|
||||
if trimmed == "" {
|
||||
return "/"
|
||||
}
|
||||
if strings.ContainsAny(trimmed, "\\\r\n\t") ||
|
||||
strings.Contains(trimmed, "://") || strings.HasPrefix(trimmed, "//") {
|
||||
return "/"
|
||||
}
|
||||
if !strings.HasPrefix(trimmed, "/") {
|
||||
return "/"
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
type Resolver interface {
|
||||
// Begin starts a login by redirecting the browser to the provider.
|
||||
Begin(ctx context.Context, req *http.Request, w http.ResponseWriter, returnTo string) error
|
||||
// Complete finishes a login, returning the identity and where to send the
|
||||
// browser next.
|
||||
Complete(ctx context.Context, r *http.Request) (*Login, error)
|
||||
// End starts a logout.
|
||||
End(ctx context.Context, redirect string) string
|
||||
// Enabled reports whether the resolver can authenticate at all.
|
||||
Enabled() bool
|
||||
// ClearState discards any pending login transaction.
|
||||
ClearState(w http.ResponseWriter)
|
||||
// LogoutURL returns the provider's end-session endpoint.
|
||||
LogoutURL(redirect string) string
|
||||
}
|
||||
|
||||
// KeyStore is the persistence the authenticator needs beyond sessions: recording
|
||||
// the users it has seen, so that an operator can see who has access.
|
||||
//
|
||||
// It was once also the API-key store. Machine credentials were removed, so a bearer
|
||||
// token is no longer a credential: the runtime API is public and the admin API is
|
||||
// for people. A request carrying a bearer token is now simply unauthenticated,
|
||||
// rather than being given editor rights by whatever it presents.
|
||||
type KeyStore interface {
|
||||
UpsertUser(ctx context.Context, identity *Identity) error
|
||||
}
|
||||
|
||||
// Authenticator authenticates requests and issues sessions.
|
||||
type Authenticator struct {
|
||||
config config.Config
|
||||
resolver Resolver
|
||||
keys KeyStore
|
||||
sessions *SessionStore
|
||||
// now is the clock, overridable in tests.
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// New builds an Authenticator. resolver may be nil when authentication is
|
||||
// disabled; sessions are always constructed because the dev bypass and API keys
|
||||
// still benefit from a consistent identity shape.
|
||||
func New(cfg config.Config, resolver Resolver, keys KeyStore) (*Authenticator, error) {
|
||||
if cfg.AuthMode == config.AuthModeDev && !cfg.IsDevelopment() {
|
||||
return nil, fmt.Errorf("refusing to start: AUTH_MODE=dev outside a development environment")
|
||||
}
|
||||
if cfg.AuthMode == config.AuthModeNone && !cfg.IsDevelopment() {
|
||||
return nil, fmt.Errorf("refusing to start: AUTH_MODE=none outside a development environment")
|
||||
}
|
||||
|
||||
sessionStore, err := NewSessionStore(cfg.SessionSecret, cfg.SessionTTL, cfg.IsDevelopment())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg.AuthMode == config.AuthModeOIDC && (resolver == nil || !resolver.Enabled()) {
|
||||
return nil, fmt.Errorf("AUTH_MODE=oidc but the identity provider is unavailable")
|
||||
}
|
||||
|
||||
return &Authenticator{
|
||||
config: cfg,
|
||||
resolver: resolver,
|
||||
keys: keys,
|
||||
sessions: sessionStore,
|
||||
now: time.Now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Sessions exposes the session store so the HTTP layer can set and clear
|
||||
// cookies without re-deriving the cookie attributes.
|
||||
func (a *Authenticator) Sessions() *SessionStore { return a.sessions }
|
||||
|
||||
// Config returns the configuration the authenticator was built with.
|
||||
func (a *Authenticator) Config() config.Config { return a.config }
|
||||
|
||||
// Mode returns the active authentication mode.
|
||||
func (a *Authenticator) Mode() string { return a.config.AuthMode }
|
||||
|
||||
// DevBypassEnabled reports whether the development bypass is available.
|
||||
func (a *Authenticator) DevBypassEnabled() bool {
|
||||
return a.config.AuthMode == config.AuthModeDev && a.config.IsDevelopment()
|
||||
}
|
||||
|
||||
// Authenticate resolves the caller from a request.
|
||||
//
|
||||
// The session cookie is the only credential. There is no bearer-token path: a
|
||||
// request with an Authorization header is treated exactly like one without, so a
|
||||
// stale or forged token cannot grant anything.
|
||||
func (a *Authenticator) Authenticate(r *http.Request) (*Identity, error) {
|
||||
if identity, err := a.sessions.Read(r); err == nil {
|
||||
return identity, nil
|
||||
} else if errors.Is(err, ErrSessionExpired) {
|
||||
return nil, ErrSessionExpired
|
||||
}
|
||||
|
||||
if a.DevBypassEnabled() {
|
||||
if identity := a.devIdentity(r); identity != nil {
|
||||
return identity, nil
|
||||
}
|
||||
}
|
||||
|
||||
if a.config.AuthMode == config.AuthModeNone {
|
||||
return &Identity{
|
||||
Subject: "anonymous",
|
||||
Name: "anonymous",
|
||||
Roles: []Role{RoleAdmin},
|
||||
Method: "none",
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, ErrNoSession
|
||||
}
|
||||
|
||||
// devIdentity builds an identity for the development bypass.
|
||||
//
|
||||
// The caller may name themselves with ?dev-user=email, which lets a developer
|
||||
// exercise the role model without a running identity provider. It is only
|
||||
// reachable when DevBypassEnabled is true, which config.Validate refuses to
|
||||
// allow outside development.
|
||||
func (a *Authenticator) devIdentity(r *http.Request) *Identity {
|
||||
email := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("dev-user")))
|
||||
if email == "" {
|
||||
email = "dev@go-bananas.local"
|
||||
}
|
||||
|
||||
role := a.config.DevFallbackRole
|
||||
if mapped, ok := a.config.DevUsers[email]; ok {
|
||||
role = mapped
|
||||
}
|
||||
|
||||
identity := &Identity{
|
||||
Subject: "dev:" + email,
|
||||
Email: email,
|
||||
Name: displayNameFor(email),
|
||||
Roles: []Role{domain.ParseRole(role)},
|
||||
Method: "dev",
|
||||
}
|
||||
return identity
|
||||
}
|
||||
|
||||
// displayNameFor turns an email address into a plausible display name.
|
||||
func displayNameFor(email string) string {
|
||||
local, _, _ := strings.Cut(email, "@")
|
||||
if local == "" {
|
||||
return email
|
||||
}
|
||||
// Capitalise the first letter of each hyphen-separated segment.
|
||||
parts := strings.Split(local, "-")
|
||||
for i, p := range parts {
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
parts[i] = strings.ToUpper(p[:1]) + p[1:]
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// IssueSession records a successful login and writes the session cookie.
|
||||
func (a *Authenticator) IssueSession(ctx context.Context, w http.ResponseWriter, identity *Identity) error {
|
||||
if identity == nil {
|
||||
return fmt.Errorf("%w: cannot issue a session for a nil identity", domain.ErrInvalid)
|
||||
}
|
||||
if a.keys != nil && identity.Method != "dev" {
|
||||
// Recording the login lets an administrator see who has used the system.
|
||||
if err := a.keys.UpsertUser(ctx, identity); err != nil {
|
||||
// A login that cannot be recorded should still succeed; losing the
|
||||
// directory entry is far less harmful than locking the user out.
|
||||
_ = err
|
||||
}
|
||||
}
|
||||
return a.sessions.Issue(w, identity)
|
||||
}
|
||||
|
||||
// ClearSession removes the session cookie.
|
||||
func (a *Authenticator) ClearSession(w http.ResponseWriter) {
|
||||
a.sessions.Clear(w)
|
||||
}
|
||||
|
||||
// ModeConfigured reports whether interactive login is available at all.
|
||||
func (a *Authenticator) ModeConfigured() bool {
|
||||
return a != nil && a.config.AuthMode != config.AuthModeNone
|
||||
}
|
||||
|
||||
// ProviderEnabled reports whether an OIDC provider is wired up.
|
||||
func (a *Authenticator) ProviderEnabled() bool {
|
||||
return a != nil && a.resolver != nil && a.resolver.Enabled()
|
||||
}
|
||||
|
||||
// Begin starts an interactive login.
|
||||
func (a *Authenticator) Begin(ctx context.Context, req *http.Request, w http.ResponseWriter, returnTo string) error {
|
||||
if a == nil || a.resolver == nil || !a.resolver.Enabled() {
|
||||
return fmt.Errorf("%w: no identity provider is configured", domain.ErrNotConfigured)
|
||||
}
|
||||
return a.resolver.Begin(ctx, req, w, returnTo)
|
||||
}
|
||||
|
||||
// Complete finishes an interactive login.
|
||||
func (a *Authenticator) Complete(ctx context.Context, r *http.Request) (*Login, error) {
|
||||
if a == nil || a.resolver == nil || !a.resolver.Enabled() {
|
||||
return nil, fmt.Errorf("%w: no identity provider is configured", domain.ErrNotConfigured)
|
||||
}
|
||||
return a.resolver.Complete(ctx, r)
|
||||
}
|
||||
|
||||
// ClearState discards a pending login transaction.
|
||||
func (a *Authenticator) ClearState(w http.ResponseWriter) {
|
||||
if a != nil && a.resolver != nil {
|
||||
a.resolver.ClearState(w)
|
||||
}
|
||||
}
|
||||
|
||||
// LogoutURL returns the provider logout URL.
|
||||
func (a *Authenticator) LogoutURL(redirect string) string {
|
||||
if a.resolver == nil || !a.resolver.Enabled() {
|
||||
return ""
|
||||
}
|
||||
return a.resolver.LogoutURL(redirect)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- sessions --
|
||||
|
||||
// SessionStore issues and verifies stateless signed session cookies.
|
||||
//
|
||||
// The session is a signed, encrypted-free JSON payload in a cookie: there is no
|
||||
// server-side session table to migrate, scale or invalidate. The trade is that
|
||||
// revoking a session before its expiry requires rotating the signing key, which
|
||||
// is acceptable for a session that lives eight hours and is documented as such.
|
||||
//
|
||||
// The signature is HMAC-SHA256 over the payload with a constant-time
|
||||
// comparison, which is what makes a forged cookie infeasible.
|
||||
type SessionStore struct {
|
||||
secret []byte
|
||||
ttl time.Duration
|
||||
secure bool
|
||||
name string
|
||||
sameSite http.SameSite
|
||||
now func() time.Time
|
||||
// inDevelopment relaxes cookie behaviour so that a local run over plain
|
||||
// HTTP still receives a cookie.
|
||||
inDevelopment bool
|
||||
}
|
||||
|
||||
// NewSessionStore builds a session store.
|
||||
func NewSessionStore(secret string, ttl time.Duration, inDevelopment bool) (*SessionStore, error) {
|
||||
if len(secret) < 32 {
|
||||
return nil, fmt.Errorf("%w: session secret must be at least 32 bytes, got %d", domain.ErrInvalid, len(secret))
|
||||
}
|
||||
if ttl <= 0 {
|
||||
ttl = 8 * time.Hour
|
||||
}
|
||||
switch strings.ToLower(stringValue(sameSiteFromString("lax"))) {
|
||||
case "lax", "strict", "none":
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: unknown SameSite value", domain.ErrInvalid)
|
||||
}
|
||||
return &SessionStore{
|
||||
secret: []byte(secret),
|
||||
ttl: ttl,
|
||||
secure: true,
|
||||
name: "go-bananas-session",
|
||||
sameSite: http.SameSiteLaxMode,
|
||||
now: time.Now,
|
||||
inDevelopment: inDevelopment,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// WithCookie configures the cookie attributes. It exists so that the
|
||||
// configuration can be applied in one place after construction.
|
||||
func (s *SessionStore) WithCookie(name string, secure bool, sameSite http.SameSite) *SessionStore {
|
||||
if name != "" {
|
||||
s.name = name
|
||||
}
|
||||
s.secure = secure
|
||||
s.sameSite = sameSite
|
||||
return s
|
||||
}
|
||||
|
||||
// WithClock overrides the clock, for tests.
|
||||
func (s *SessionStore) WithClock(now func() time.Time) *SessionStore {
|
||||
s.now = now
|
||||
return s
|
||||
}
|
||||
|
||||
// CookieName returns the configured cookie name.
|
||||
func (s *SessionStore) CookieName() string { return s.name }
|
||||
|
||||
// sessionPayload is the signed cookie body.
|
||||
type sessionPayload struct {
|
||||
Subject string `json:"sub"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Roles []string `json:"roles"`
|
||||
Method string `json:"method"`
|
||||
IssuedAt int64 `json:"iat"`
|
||||
ExpiresAt int64 `json:"exp"`
|
||||
// Nonce lets a future rotation invalidate sessions issued before a cutover.
|
||||
Nonce string `json:"jti,omitempty"`
|
||||
}
|
||||
|
||||
// Issue writes a signed session cookie.
|
||||
func (s *SessionStore) Issue(w http.ResponseWriter, identity *Identity) error {
|
||||
now := s.now().UTC()
|
||||
roles := make([]string, 0, len(identity.Roles))
|
||||
for _, r := range identity.Roles {
|
||||
roles = append(roles, string(r))
|
||||
}
|
||||
|
||||
payload := sessionPayload{
|
||||
Subject: identity.Subject,
|
||||
Email: identity.Email,
|
||||
Name: identity.Name,
|
||||
Roles: roles,
|
||||
Method: identity.Method,
|
||||
IssuedAt: now.Unix(),
|
||||
ExpiresAt: now.Add(s.ttl).Unix(),
|
||||
}
|
||||
encoded, err := s.encode(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
//nolint:gosec // G124: HttpOnly, Secure and SameSite are all set, from the
|
||||
// configured values rather than literals. gosec only recognises a literal
|
||||
// attribute, so a configurable cookie reads as an unprotected one.
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: s.name,
|
||||
Value: encoded,
|
||||
Path: "/",
|
||||
Expires: now.Add(s.ttl),
|
||||
MaxAge: int(s.ttl.Seconds()),
|
||||
HttpOnly: true,
|
||||
Secure: s.secure,
|
||||
SameSite: s.sameSite,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Clear expires the session cookie.
|
||||
func (s *SessionStore) Clear(w http.ResponseWriter) {
|
||||
//nolint:gosec // G124: as above. Clearing a cookie needs the same attribute
|
||||
// triple as setting it, or a browser will not remove it.
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: s.name,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
Expires: time.Unix(0, 0),
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: s.secure,
|
||||
SameSite: s.sameSite,
|
||||
})
|
||||
}
|
||||
|
||||
// Read verifies and decodes the session cookie.
|
||||
func (s *SessionStore) Read(r *http.Request) (*Identity, error) {
|
||||
cookie, err := r.Cookie(s.name)
|
||||
if err != nil || cookie.Value == "" {
|
||||
return nil, ErrNoSession
|
||||
}
|
||||
|
||||
payload, err := s.decode(cookie.Value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := s.now().UTC().Unix()
|
||||
if payload.ExpiresAt > 0 && now > payload.ExpiresAt {
|
||||
return nil, ErrSessionExpired
|
||||
}
|
||||
|
||||
identity := &Identity{
|
||||
Subject: payload.Subject,
|
||||
Email: payload.Email,
|
||||
Name: payload.Name,
|
||||
Method: payload.Method,
|
||||
}
|
||||
// Left nil when the payload carries no expiry, rather than becoming the Unix
|
||||
// epoch: a session without an expiry is a valid thing to represent, and 1970 is
|
||||
// a date every client would read as "already expired".
|
||||
if payload.ExpiresAt > 0 {
|
||||
identity.ExpiresAt = timePtr(time.Unix(payload.ExpiresAt, 0).UTC())
|
||||
}
|
||||
for _, r := range payload.Roles {
|
||||
identity.Roles = append(identity.Roles, Role(r))
|
||||
}
|
||||
if len(identity.Roles) == 0 {
|
||||
identity.Roles = []Role{RoleViewer}
|
||||
}
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
// encode serialises and signs a payload.
|
||||
func (s *SessionStore) encode(payload sessionPayload) (string, error) {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encode session: %w", err)
|
||||
}
|
||||
bodyEncoded := base64.RawURLEncoding.EncodeToString(body)
|
||||
mac := s.sign(bodyEncoded)
|
||||
return bodyEncoded + "." + base64.RawURLEncoding.EncodeToString(mac), nil
|
||||
}
|
||||
|
||||
// decode verifies and parses a payload.
|
||||
func (s *SessionStore) decode(value string) (sessionPayload, error) {
|
||||
var payload sessionPayload
|
||||
|
||||
bodyEncoded, signatureEncoded, ok := strings.Cut(value, ".")
|
||||
if !ok {
|
||||
return payload, ErrSessionInvalid
|
||||
}
|
||||
|
||||
signature, err := base64.RawURLEncoding.DecodeString(signatureEncoded)
|
||||
if err != nil {
|
||||
return payload, ErrSessionInvalid
|
||||
}
|
||||
|
||||
// Constant-time comparison: a byte-by-byte early return would leak the
|
||||
// correct prefix length to an attacker measuring responses.
|
||||
expected := s.sign(bodyEncoded)
|
||||
if subtle.ConstantTimeCompare(signature, expected) != 1 {
|
||||
return payload, ErrSessionInvalid
|
||||
}
|
||||
|
||||
body, err := base64.RawURLEncoding.DecodeString(bodyEncoded)
|
||||
if err != nil {
|
||||
return payload, ErrSessionInvalid
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return payload, ErrSessionInvalid
|
||||
}
|
||||
if payload.Subject == "" {
|
||||
return payload, ErrSessionInvalid
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// sign computes the HMAC over a value.
|
||||
func (s *SessionStore) sign(value string) []byte {
|
||||
mac := hmacSHA256(s.secret, []byte(value))
|
||||
return mac[:]
|
||||
}
|
||||
|
||||
// sameSiteFromString maps a configuration value to an http.SameSite.
|
||||
func sameSiteFromString(raw string) http.SameSite {
|
||||
switch strings.ToLower(raw) {
|
||||
case "strict":
|
||||
return http.SameSiteStrictMode
|
||||
case "none":
|
||||
return http.SameSiteNoneMode
|
||||
default:
|
||||
return http.SameSiteLaxMode
|
||||
}
|
||||
}
|
||||
|
||||
// stringValue renders a SameSite for logging.
|
||||
func stringValue(v http.SameSite) string {
|
||||
switch v {
|
||||
case http.SameSiteStrictMode:
|
||||
return "strict"
|
||||
case http.SameSiteNoneMode:
|
||||
return "none"
|
||||
default:
|
||||
return "lax"
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ state --
|
||||
|
||||
// StateStore keeps short-lived OIDC transaction state (the PKCE verifier and
|
||||
// the post-login return path) in a signed cookie.
|
||||
//
|
||||
// Keeping it in the cookie rather than a server-side map is what lets go-bananas
|
||||
// run multiple replicas behind a load balancer with no shared session storage.
|
||||
type StateStore struct {
|
||||
secret []byte
|
||||
name string
|
||||
ttl time.Duration
|
||||
secure bool
|
||||
sameSite http.SameSite
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// oauthState is the transaction cookie body.
|
||||
//
|
||||
// State is stored alongside the verifier and nonce so that the callback can be
|
||||
// bound to the browser that started the flow. Without it the state parameter is
|
||||
// merely present, not checked, and an attacker-supplied authorization code could
|
||||
// be redeemed into a session in someone else's browser (a login CSRF).
|
||||
type oauthState struct {
|
||||
Verifier string `json:"v"`
|
||||
ReturnTo string `json:"r"`
|
||||
Nonce string `json:"n"`
|
||||
State string `json:"s"`
|
||||
IssuedAt int64 `json:"iat"`
|
||||
}
|
||||
|
||||
// Transaction is a verified login transaction read back from the state cookie.
|
||||
type Transaction struct {
|
||||
// Verifier is the PKCE code verifier for the exchange.
|
||||
Verifier string
|
||||
// ReturnTo is the sanitised local path the browser should land on.
|
||||
ReturnTo string
|
||||
// Nonce must match the nonce in the returned ID token.
|
||||
Nonce string
|
||||
// State must match the state parameter the provider echoes back.
|
||||
State string
|
||||
}
|
||||
|
||||
// NewStateStore builds the transaction cookie store.
|
||||
func NewStateStore(secret, name string, secure bool, sameSite http.SameSite, ttl time.Duration) *StateStore {
|
||||
if ttl <= 0 {
|
||||
ttl = 10 * time.Minute
|
||||
}
|
||||
return &StateStore{
|
||||
secret: []byte(secret),
|
||||
name: name,
|
||||
secure: secure,
|
||||
sameSite: sameSite,
|
||||
ttl: ttl,
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// Name returns the state cookie name.
|
||||
func (s *StateStore) Name() string { return s.name }
|
||||
|
||||
// Put writes the transaction state cookie. It fails when any part of the
|
||||
// transaction is empty: a transaction missing its state or verifier cannot be
|
||||
// validated at the callback, so storing it would only defer the failure to a point
|
||||
// where the user is already mid-login.
|
||||
func (s *StateStore) Put(w http.ResponseWriter, verifier, returnTo, nonce, state string) error {
|
||||
if verifier == "" || nonce == "" || state == "" {
|
||||
return fmt.Errorf("%w: incomplete oauth transaction", domain.ErrInvalid)
|
||||
}
|
||||
now := s.now().UTC()
|
||||
body, err := json.Marshal(oauthState{
|
||||
Verifier: verifier, ReturnTo: returnTo, Nonce: nonce, State: state,
|
||||
IssuedAt: now.Unix(),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode oauth state: %w", err)
|
||||
}
|
||||
encoded := base64.RawURLEncoding.EncodeToString(body)
|
||||
mac := hmacSHA256(s.secret, []byte(encoded))
|
||||
|
||||
//nolint:gosec // G124: as above.
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: s.name,
|
||||
Value: encoded + "." + base64.RawURLEncoding.EncodeToString(mac[:]),
|
||||
Path: "/",
|
||||
Expires: now.Add(s.ttl),
|
||||
MaxAge: int(s.ttl.Seconds()),
|
||||
HttpOnly: true,
|
||||
Secure: s.secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get verifies the transaction state cookie and returns what it holds.
|
||||
//
|
||||
// "Consume" is the caller's job: the cookie is single-use, but only because the
|
||||
// HTTP layer expires it once the login has succeeded. Clearing it here instead
|
||||
// would expire it before the code exchange, and a user who mistyped their password
|
||||
// once at the provider would arrive back with no transaction to finish.
|
||||
func (s *StateStore) Get(r *http.Request) (Transaction, error) {
|
||||
cookie, err := r.Cookie(s.name)
|
||||
if err != nil {
|
||||
return Transaction{}, fmt.Errorf("%w: missing oauth state", domain.ErrUnauthorized)
|
||||
}
|
||||
encoded, signatureEncoded, ok := strings.Cut(cookie.Value, ".")
|
||||
if !ok {
|
||||
return Transaction{}, fmt.Errorf("%w: malformed oauth state", domain.ErrUnauthorized)
|
||||
}
|
||||
signature, decodeErr := base64.RawURLEncoding.DecodeString(signatureEncoded)
|
||||
if decodeErr != nil {
|
||||
return Transaction{}, fmt.Errorf("%w: malformed oauth state", domain.ErrUnauthorized)
|
||||
}
|
||||
expected := hmacSHA256(s.secret, []byte(encoded))
|
||||
if subtle.ConstantTimeCompare(signature, expected[:]) != 1 {
|
||||
return Transaction{}, fmt.Errorf("%w: oauth state signature mismatch", domain.ErrUnauthorized)
|
||||
}
|
||||
body, decodeErr := base64.RawURLEncoding.DecodeString(encoded)
|
||||
if decodeErr != nil {
|
||||
return Transaction{}, fmt.Errorf("%w: malformed oauth state", domain.ErrUnauthorized)
|
||||
}
|
||||
var state oauthState
|
||||
if json.Unmarshal(body, &state) != nil {
|
||||
return Transaction{}, fmt.Errorf("%w: malformed oauth state", domain.ErrUnauthorized)
|
||||
}
|
||||
if s.now().UTC().Sub(time.Unix(state.IssuedAt, 0).UTC()) > s.ttl {
|
||||
return Transaction{}, fmt.Errorf("%w: oauth state expired", domain.ErrUnauthorized)
|
||||
}
|
||||
if state.Verifier == "" || state.Nonce == "" || state.State == "" {
|
||||
return Transaction{}, fmt.Errorf("%w: incomplete oauth state", domain.ErrUnauthorized)
|
||||
}
|
||||
return Transaction{
|
||||
Verifier: state.Verifier,
|
||||
ReturnTo: SafeReturnTo(state.ReturnTo),
|
||||
Nonce: state.Nonce,
|
||||
State: state.State,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Clear expires the state cookie.
|
||||
func (s *StateStore) Clear(w http.ResponseWriter) {
|
||||
//nolint:gosec // G124: as above.
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: s.name, Value: "", Path: "/", MaxAge: -1,
|
||||
HttpOnly: true, Secure: s.secure, SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
// hmacSHA256 computes a signature. It is a free function so the session store
|
||||
// and the OIDC state store share one implementation and cannot diverge.
|
||||
func hmacSHA256(key, body []byte) [32]byte {
|
||||
return sha256HMAC(key, body)
|
||||
}
|
||||
|
||||
// timePtr returns a pointer to t. It exists because the session and identity types
|
||||
// carry an optional expiry, and taking the address of a conversion inline is not
|
||||
// something Go will let you write.
|
||||
func timePtr(t time.Time) *time.Time { return &t }
|
||||
417
internal/auth/auth_test.go
Normal file
417
internal/auth/auth_test.go
Normal file
|
|
@ -0,0 +1,417 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/urpi/go-bananas/internal/config"
|
||||
"github.com/urpi/go-bananas/internal/domain"
|
||||
)
|
||||
|
||||
const testSecret = "test-secret-that-is-at-least-32-bytes-long"
|
||||
|
||||
// ---------------------------------------------------------------- sessions --
|
||||
|
||||
func TestSessionRoundTrip(t *testing.T) {
|
||||
store, err := NewSessionStore(testSecret, time.Hour, false)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSessionStore: %v", err)
|
||||
}
|
||||
|
||||
identity := &domain.Identity{
|
||||
Subject: "alice",
|
||||
Email: "alice@example.com",
|
||||
Name: "Alice",
|
||||
Roles: []domain.Role{domain.RoleEditor},
|
||||
Method: "oidc",
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
if err := store.Issue(rec, identity); err != nil {
|
||||
t.Fatalf("Issue: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
|
||||
got, err := store.Read(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Read: %v", err)
|
||||
}
|
||||
if got.Subject != "alice" || got.Email != "alice@example.com" {
|
||||
t.Errorf("identity = %+v", got)
|
||||
}
|
||||
if !got.HasRole(domain.RoleEditor) {
|
||||
t.Errorf("roles = %v, want editor", got.Roles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionCookieIsHttpOnly(t *testing.T) {
|
||||
store, _ := NewSessionStore(testSecret, time.Hour, false)
|
||||
rec := httptest.NewRecorder()
|
||||
if err := store.Issue(rec, &domain.Identity{Subject: "a"}); err != nil {
|
||||
t.Fatalf("Issue: %v", err)
|
||||
}
|
||||
cookies := rec.Result().Cookies()
|
||||
if len(cookies) != 1 {
|
||||
t.Fatalf("got %d cookies, want 1", len(cookies))
|
||||
}
|
||||
if !cookies[0].HttpOnly {
|
||||
t.Error("session cookie must be HttpOnly so script cannot read it")
|
||||
}
|
||||
if !cookies[0].Secure {
|
||||
t.Error("session cookie must be Secure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionRejectsShortSecret(t *testing.T) {
|
||||
if _, err := NewSessionStore("too-short", time.Hour, false); err == nil {
|
||||
t.Fatal("expected a short secret to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionRejectsTamperedPayload(t *testing.T) {
|
||||
store, _ := NewSessionStore(testSecret, time.Hour, false)
|
||||
rec := httptest.NewRecorder()
|
||||
if err := store.Issue(rec, &domain.Identity{Subject: "alice", Roles: []domain.Role{domain.RoleViewer}}); err != nil {
|
||||
t.Fatalf("Issue: %v", err)
|
||||
}
|
||||
|
||||
var issued *http.Cookie
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
issued = c
|
||||
}
|
||||
|
||||
// Re-encode the payload with an escalated role but keep the old signature.
|
||||
body, signature, _ := strings.Cut(issued.Value, ".")
|
||||
forged := base64RawURL(`{"sub":"alice","roles":["admin"],"method":"oidc","iat":1,"exp":9999999999}`)
|
||||
_ = body
|
||||
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
|
||||
req.AddCookie(&http.Cookie{Name: issued.Name, Value: forged + "." + signature})
|
||||
|
||||
if _, err := store.Read(req); err == nil {
|
||||
t.Fatal("a re-signed payload must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionRejectsForeignSecret(t *testing.T) {
|
||||
mine, _ := NewSessionStore(testSecret, time.Hour, false)
|
||||
theirs, _ := NewSessionStore("a-completely-different-32-byte-secret!", time.Hour, false)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
if err := theirs.Issue(rec, &domain.Identity{Subject: "mallory", Roles: []domain.Role{domain.RoleAdmin}}); err != nil {
|
||||
t.Fatalf("Issue: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
if _, err := mine.Read(req); err == nil {
|
||||
t.Fatal("a cookie signed with another key must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionExpires(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
store, _ := NewSessionStore(testSecret, time.Hour, false)
|
||||
store.WithClock(func() time.Time { return now })
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
if err := store.Issue(rec, &domain.Identity{Subject: "alice"}); err != nil {
|
||||
t.Fatalf("Issue: %v", err)
|
||||
}
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
|
||||
// Two hours later the session must be gone.
|
||||
store.WithClock(func() time.Time { return now.Add(2 * time.Hour) })
|
||||
if _, err := store.Read(req); err == nil {
|
||||
t.Fatal("expected an expired session to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionReadRejectsMissingCookie(t *testing.T) {
|
||||
store, _ := NewSessionStore(testSecret, time.Hour, false)
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
|
||||
if _, err := store.Read(req); err == nil {
|
||||
t.Fatal("expected ErrNoSession")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionWithRoleDefaultsToViewer(t *testing.T) {
|
||||
// A cookie carrying no roles must never be treated as having privileges.
|
||||
store, _ := NewSessionStore(testSecret, time.Hour, false)
|
||||
encoded, err := store.encode(sessionPayload{Subject: "a", Method: "oidc", ExpiresAt: time.Now().Add(time.Hour).Unix()})
|
||||
if err != nil {
|
||||
t.Fatalf("encode: %v", err)
|
||||
}
|
||||
payload, err := store.decode(encoded)
|
||||
if err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(payload.Roles) != 0 {
|
||||
t.Errorf("payload roles = %v, want none", payload.Roles)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- oidc state --
|
||||
|
||||
func TestOAuthStateRoundTrip(t *testing.T) {
|
||||
state := NewStateStore(testSecret, "bananas-oauth", true, http.SameSiteLaxMode, 10*time.Minute)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
if err := state.Put(rec, "verifier-value", "/return", "nonce-value", "state-value"); err != nil {
|
||||
t.Fatalf("Put: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/auth/callback", nil)
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
|
||||
tx, err := state.Get(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if tx.Verifier != "verifier-value" || tx.ReturnTo != "/return" ||
|
||||
tx.Nonce != "nonce-value" || tx.State != "state-value" {
|
||||
t.Errorf("round trip lost data: %+v", tx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthStateRejectsTampering(t *testing.T) {
|
||||
state := NewStateStore(testSecret, "bananas-oauth", true, http.SameSiteLaxMode, 10*time.Minute)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
if err := state.Put(rec, "verifier", "/", "nonce", "state"); err != nil {
|
||||
t.Fatalf("Put: %v", err)
|
||||
}
|
||||
var issued *http.Cookie
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
issued = c
|
||||
}
|
||||
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/auth/callback", nil)
|
||||
req.AddCookie(&http.Cookie{Name: issued.Name, Value: base64RawURL(`{"v":"attacker","r":"/evil","n":"x","iat":1}`) + "." + strings.Split(issued.Value, ".")[1]})
|
||||
|
||||
if _, err := state.Get(req); err == nil {
|
||||
t.Fatal("a tampered state cookie must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthStateExpires(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
state := NewStateStore(testSecret, "bananas-oauth", true, http.SameSiteLaxMode, 10*time.Minute)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
if err := state.Put(rec, "verifier", "/", "nonce", "state"); err != nil {
|
||||
t.Fatalf("Put: %v", err)
|
||||
}
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/auth/callback", nil)
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
|
||||
// The StateStore uses time.Now directly; simulate staleness by minting an
|
||||
// old state value through the exported encoder path.
|
||||
stale := oauthState{Verifier: "v", ReturnTo: "/", IssuedAt: now.Add(-time.Hour).Unix()}
|
||||
body := base64RawURL(`{"v":"v","r":"/","n":"","iat":` + itoa(stale.IssuedAt) + `}`)
|
||||
mac := hmacSHA256([]byte(testSecret), []byte(body))
|
||||
req2 := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/auth/callback", nil)
|
||||
req2.AddCookie(&http.Cookie{Name: "bananas-oauth", Value: body + "." + base64RawURLBytes(mac[:])})
|
||||
|
||||
if _, err := state.Get(req2); err == nil {
|
||||
t.Fatal("an expired state cookie must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------- sanitising URLs --
|
||||
|
||||
func TestSafeReturnToBlocksOpenRedirects(t *testing.T) {
|
||||
dangerous := []string{
|
||||
"https://evil.example.com/steal",
|
||||
"//evil.example.com/steal",
|
||||
"/\\evil.example.com",
|
||||
"javascript:alert(1)",
|
||||
"admin",
|
||||
"",
|
||||
}
|
||||
for _, input := range dangerous {
|
||||
got := SafeReturnTo(input)
|
||||
if got != "/" {
|
||||
t.Errorf("SafeReturnTo(%q) = %q, want /", input, got)
|
||||
}
|
||||
}
|
||||
|
||||
safe := map[string]string{
|
||||
"/admin": "/admin",
|
||||
"/admin/themes?x=1": "/admin/themes?x=1",
|
||||
"/": "/",
|
||||
}
|
||||
for input, want := range safe {
|
||||
if got := SafeReturnTo(input); got != want {
|
||||
t.Errorf("SafeReturnTo(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginURLIsNotAnOpenRedirect(t *testing.T) {
|
||||
// The redirect URI must always be one the provider has registered.
|
||||
cfg := config.Defaults()
|
||||
cfg.PublicBaseURL = "https://themes.example.com"
|
||||
if got := cfg.RedirectURL(); got != "https://themes.example.com/auth/callback" {
|
||||
t.Errorf("RedirectURL = %q", got)
|
||||
}
|
||||
|
||||
cfg.BasePath = "/bananas"
|
||||
if got := cfg.RedirectURL(); got != "https://themes.example.com/bananas/auth/callback" {
|
||||
t.Errorf("RedirectURL with base path = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------- authenticator --
|
||||
|
||||
func TestDevBypassGrantsConfiguredRole(t *testing.T) {
|
||||
cfg := config.Defaults()
|
||||
cfg.AuthMode = config.AuthModeDev
|
||||
cfg.Environment = "development"
|
||||
cfg.DevFallbackRole = "viewer"
|
||||
cfg.DevUsers = map[string]string{"boss@example.com": "admin"}
|
||||
|
||||
a := newTestAuthenticator(t, cfg, nil)
|
||||
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/v1/themes", nil)
|
||||
identity, err := a.Authenticate(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Authenticate: %v", err)
|
||||
}
|
||||
if identity.HighestRole() != domain.RoleViewer {
|
||||
t.Errorf("role = %v, want viewer", identity.Roles)
|
||||
}
|
||||
|
||||
req = httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/v1/themes?dev-user=boss@example.com", nil)
|
||||
identity, err = a.Authenticate(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Authenticate: %v", err)
|
||||
}
|
||||
if identity.HighestRole() != domain.RoleAdmin {
|
||||
t.Errorf("role = %v, want admin", identity.Roles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevBypassRefusedOutsideDevelopment(t *testing.T) {
|
||||
cfg := config.Defaults()
|
||||
cfg.AuthMode = config.AuthModeDev
|
||||
cfg.Environment = "production"
|
||||
cfg.SessionSecret = testSecret
|
||||
|
||||
if _, err := New(cfg, nil, nil); err == nil {
|
||||
t.Fatal("the development bypass must not be constructible in production")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthNoneRefusedOutsideDevelopment(t *testing.T) {
|
||||
cfg := config.Defaults()
|
||||
cfg.AuthMode = config.AuthModeNone
|
||||
cfg.Environment = "production"
|
||||
cfg.SessionSecret = testSecret
|
||||
|
||||
if _, err := New(cfg, nil, nil); err == nil {
|
||||
t.Fatal("AUTH_MODE=none must not be constructible in production")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnauthenticatedRequestIsRejected(t *testing.T) {
|
||||
cfg := config.Defaults()
|
||||
cfg.AuthMode = config.AuthModeOIDC
|
||||
cfg.Environment = "test"
|
||||
a := newTestAuthenticator(t, cfg, nil)
|
||||
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/v1/themes", nil)
|
||||
if _, err := a.Authenticate(req); err == nil {
|
||||
t.Fatal("expected an unauthenticated request to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleMappingFailsClosed(t *testing.T) {
|
||||
// A user in no configured group gets viewer, never more.
|
||||
p := &OIDCProvider{config: config.Config{
|
||||
OIDCAdminGroups: []string{"platform-admins"},
|
||||
OIDCEditorGroups: []string{"designers"},
|
||||
}}
|
||||
|
||||
if got := p.rolesFor([]string{"some-other-group"}, nil); got[0] != domain.RoleViewer {
|
||||
t.Errorf("roles = %v, want viewer", got)
|
||||
}
|
||||
if got := p.rolesFor([]string{"designers"}, nil); got[0] != domain.RoleEditor {
|
||||
t.Errorf("roles = %v, want editor", got)
|
||||
}
|
||||
if got := p.rolesFor([]string{"designers", "platform-admins"}, nil); got[0] != domain.RoleAdmin {
|
||||
t.Errorf("roles = %v, want admin; the most privileged group must win", got)
|
||||
}
|
||||
if got := p.rolesFor(nil, []string{"platform-admins"}); got[0] != domain.RoleAdmin {
|
||||
t.Errorf("roles = %v, want admin from realm roles", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleMappingIsCaseInsensitive(t *testing.T) {
|
||||
p := &OIDCProvider{config: config.Config{OIDCAdminGroups: []string{"Platform-Admins"}}}
|
||||
if got := p.rolesFor([]string{"PLATFORM-ADMINS"}, nil); got[0] != domain.RoleAdmin {
|
||||
t.Errorf("roles = %v, want admin", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodeChallengeIsS256(t *testing.T) {
|
||||
// RFC 7636 test vector: verifier "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
||||
// must produce challenge "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM".
|
||||
got := codeChallenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk")
|
||||
if got != "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" {
|
||||
t.Errorf("codeChallenge = %q, want the RFC 7636 value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInsecureTLSConfigIsUsedOnlyWhenRequested(t *testing.T) {
|
||||
cfg := config.Defaults()
|
||||
cfg.OIDCSkipTLSVerify = true
|
||||
if client := newProviderHTTPClient(cfg); client == nil {
|
||||
t.Fatal("expected a client")
|
||||
}
|
||||
if tlsCfg := newProviderHTTPClient(cfg).Transport.(*http.Transport).TLSClientConfig; tlsCfg == nil || !tlsCfg.InsecureSkipVerify {
|
||||
t.Error("expected TLS verification to be skipped when configured")
|
||||
}
|
||||
|
||||
cfg.OIDCSkipTLSVerify = false
|
||||
if tlsCfg := newProviderHTTPClient(cfg).Transport.(*http.Transport).TLSClientConfig; tlsCfg != nil && tlsCfg.InsecureSkipVerify {
|
||||
t.Error("TLS verification must be on by default")
|
||||
}
|
||||
var _ *tls.Config
|
||||
}
|
||||
|
||||
func TestProviderHTTPClientHasTimeout(t *testing.T) {
|
||||
// An unresponsive identity provider must not be able to hang a request
|
||||
// indefinitely.
|
||||
client := newProviderHTTPClient(config.Defaults())
|
||||
if client.Timeout == 0 {
|
||||
t.Error("provider HTTP client must have a timeout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestURLQueryEscape(t *testing.T) {
|
||||
if got := urlQueryEscape("https://x.example/y?a=1&b=2"); got != "https%3A%2F%2Fx.example%2Fy%3Fa%3D1%26b%3D2" {
|
||||
t.Errorf("urlQueryEscape = %q", got)
|
||||
}
|
||||
if _, err := url.Parse("https://x.example"); err != nil {
|
||||
t.Fatalf("unexpected: %v", err)
|
||||
}
|
||||
}
|
||||
60
internal/auth/helpers_test.go
Normal file
60
internal/auth/helpers_test.go
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/urpi/go-bananas/internal/config"
|
||||
)
|
||||
|
||||
// base64RawURL encodes without padding, matching how cookies are written.
|
||||
func base64RawURL(s string) string {
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(s))
|
||||
}
|
||||
|
||||
// base64RawURLBytes encodes raw bytes without padding.
|
||||
func base64RawURLBytes(b []byte) string {
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
// itoa renders an int64 for embedding in a JSON test fixture.
|
||||
func itoa(v int64) string { return strconv.FormatInt(v, 10) }
|
||||
|
||||
// stubResolver is a Resolver that authenticates nobody. It exists so that tests
|
||||
// can construct an Authenticator in OIDC mode without a live provider, which
|
||||
// also documents that New refuses to start in OIDC mode without one.
|
||||
type stubResolver struct{}
|
||||
|
||||
func (stubResolver) Begin(context.Context, *http.Request, http.ResponseWriter, string) error {
|
||||
return nil
|
||||
}
|
||||
func (stubResolver) Complete(context.Context, *http.Request) (*Login, error) {
|
||||
return nil, ErrNoSession
|
||||
}
|
||||
func (stubResolver) End(context.Context, string) string { return "" }
|
||||
func (stubResolver) Enabled() bool { return true }
|
||||
func (stubResolver) ClearState(http.ResponseWriter) {}
|
||||
func (stubResolver) LogoutURL(string) string { return "https://idp.example/logout" }
|
||||
|
||||
// newTestAuthenticator builds an Authenticator for a configuration.
|
||||
//
|
||||
// The key store is optional and is passed straight through: with machine
|
||||
// credentials removed it only carries the user table, which most of these tests do
|
||||
// not exercise.
|
||||
func newTestAuthenticator(t *testing.T, cfg config.Config, keys KeyStore) *Authenticator {
|
||||
t.Helper()
|
||||
if cfg.SessionSecret == "" {
|
||||
cfg.SessionSecret = testSecret
|
||||
}
|
||||
// A stub resolver rather than nil: New refuses to construct in OIDC mode
|
||||
// without one, and the point of some of these tests is exactly that an
|
||||
// unauthenticated request is rejected even when a provider is wired up.
|
||||
a, err := New(cfg, stubResolver{}, keys)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
20
internal/auth/hmac.go
Normal file
20
internal/auth/hmac.go
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
)
|
||||
|
||||
// sha256HMAC computes an HMAC-SHA256 tag.
|
||||
//
|
||||
// It is a thin wrapper so that the session store and the OIDC state store
|
||||
// cannot accidentally diverge on how they sign. The stdlib hmac package already
|
||||
// performs a constant-time internal comparison; the constant-time requirement
|
||||
// in this codebase is about how *we* compare the resulting tags.
|
||||
func sha256HMAC(key, body []byte) [32]byte {
|
||||
mac := hmac.New(sha256.New, key)
|
||||
mac.Write(body)
|
||||
var out [32]byte
|
||||
copy(out[:], mac.Sum(nil))
|
||||
return out
|
||||
}
|
||||
375
internal/auth/oidc.go
Normal file
375
internal/auth/oidc.go
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/urpi/go-bananas/internal/config"
|
||||
"github.com/urpi/go-bananas/internal/domain"
|
||||
)
|
||||
|
||||
// OIDCProvider implements Resolver against any OpenID Connect provider,
|
||||
// including the auth-dev.urpi.be realm used for local development.
|
||||
//
|
||||
// Everything provider-specific comes from the issuer's discovery document, so
|
||||
// the same code works against Keycloak, Authentik, Zitadel or Google without a
|
||||
// configuration change. The only per-provider values are the client
|
||||
// credentials and the group-to-role mapping.
|
||||
type OIDCProvider struct {
|
||||
config config.Config
|
||||
provider *oidc.Provider
|
||||
verifier *oidc.IDTokenVerifier
|
||||
oauth *oauth2.Config
|
||||
state *StateStore
|
||||
}
|
||||
|
||||
// NewOIDCProvider performs discovery against the configured issuer and returns
|
||||
// a ready provider. Discovery happens eagerly so that a misconfigured issuer
|
||||
// fails at start-up rather than on the first login, when the failure would be
|
||||
// hardest to diagnose.
|
||||
func NewOIDCProvider(ctx context.Context, cfg config.Config, state *StateStore) (*OIDCProvider, error) {
|
||||
if cfg.OIDCIssuer == "" {
|
||||
return nil, fmt.Errorf("%w: OIDC_ISSUER is required", domain.ErrInvalid)
|
||||
}
|
||||
|
||||
provider, err := newProvider(ctx, cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p := &OIDCProvider{
|
||||
config: cfg,
|
||||
provider: provider,
|
||||
state: state,
|
||||
}
|
||||
|
||||
scopes := append([]string{oidc.ScopeOpenID}, cfg.OIDCScopes...)
|
||||
p.oauth = &oauth2.Config{
|
||||
ClientID: cfg.OIDCClientID,
|
||||
ClientSecret: cfg.OIDCClientSecret,
|
||||
RedirectURL: cfg.RedirectURL(),
|
||||
Scopes: scopes,
|
||||
Endpoint: provider.Endpoint(),
|
||||
}
|
||||
p.verifier = provider.Verifier(&oidc.Config{
|
||||
ClientID: cfg.OIDCClientID,
|
||||
// Keycloak and Authentik both support discovery of the JWKS; skipping
|
||||
// the audience check is not done because a token minted for another
|
||||
// client in the same realm must not be accepted here.
|
||||
})
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// newProvider runs discovery with a bounded timeout so that an unreachable
|
||||
// issuer produces a clear error instead of hanging start-up.
|
||||
func newProvider(ctx context.Context, cfg config.Config) (*oidc.Provider, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// ClientContext carries the custom HTTP client through to discovery, which
|
||||
// is how the TLS-skip development switch and the request timeout reach the
|
||||
// provider's HTTP calls.
|
||||
ctx = oidc.ClientContext(ctx, newProviderHTTPClient(cfg))
|
||||
provider, err := oidc.NewProvider(ctx, cfg.OIDCIssuer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("OIDC discovery against %s failed: %w", cfg.OIDCIssuer, err)
|
||||
}
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
// newProviderHTTPClient builds the HTTP client used for provider traffic.
|
||||
//
|
||||
// The timeout matters: without one, an unresponsive identity provider would hold
|
||||
// a request open until the client's own timeout, and a stalled provider would
|
||||
// look like a hung application.
|
||||
func newProviderHTTPClient(cfg config.Config) *http.Client {
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
transport.MaxIdleConnsPerHost = 4
|
||||
if cfg.OIDCSkipTLSVerify {
|
||||
transport.TLSClientConfig = insecureTLSConfig()
|
||||
}
|
||||
return &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: 15 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// Enabled reports that this resolver can authenticate.
|
||||
func (p *OIDCProvider) Enabled() bool { return p != nil && p.provider != nil }
|
||||
|
||||
// Begin starts a login by redirecting the browser to the provider. The return
|
||||
// path and PKCE verifier are carried in a signed cookie rather than
|
||||
// server-side state so that any replica can complete the flow.
|
||||
func (p *OIDCProvider) Begin(_ context.Context, req *http.Request, w http.ResponseWriter, returnTo string) error {
|
||||
verifier, err := randomVerifier()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nonce, err := randomNonce()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
state, err := randomState()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The transaction is stored before the redirect, and stores the state it was
|
||||
// built with, so that the callback can prove the response belongs to this
|
||||
// browser's login attempt.
|
||||
if err := p.state.Put(w, verifier, SafeReturnTo(returnTo), nonce, state); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
opts := []oauth2.AuthCodeOption{
|
||||
// PKCE with S256. The verifier lives in a signed HttpOnly cookie, so the
|
||||
// code is bound to the browser that started the flow.
|
||||
oauth2.SetAuthURLParam("code_challenge", codeChallenge(verifier)),
|
||||
oauth2.SetAuthURLParam("code_challenge_method", "S256"),
|
||||
oauth2.SetAuthURLParam("nonce", nonce),
|
||||
oauth2.SetAuthURLParam("state", state),
|
||||
}
|
||||
http.Redirect(w, req, p.oauth.AuthCodeURL(state, opts...), http.StatusFound)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Complete finishes a login.
|
||||
//
|
||||
// The transaction cookie is the anchor: the state the provider echoes back is
|
||||
// compared against the state this service minted for this browser, and the PKCE
|
||||
// verifier and nonce both come from the same cookie. A callback that carries a
|
||||
// foreign code or a replayed state is rejected before the code is ever exchanged.
|
||||
func (p *OIDCProvider) Complete(ctx context.Context, req *http.Request) (*Login, error) {
|
||||
if err := req.ParseForm(); err != nil {
|
||||
return nil, fmt.Errorf("parse callback form: %w", err)
|
||||
}
|
||||
if providerErr := req.Form.Get("error"); providerErr != "" {
|
||||
desc := req.Form.Get("error_description")
|
||||
return nil, fmt.Errorf("identity provider returned %q: %s", providerErr, desc)
|
||||
}
|
||||
|
||||
tx, err := p.state.Get(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
returnedState := req.Form.Get("state")
|
||||
if returnedState == "" {
|
||||
return nil, fmt.Errorf("%w: callback is missing the state parameter", domain.ErrUnauthorized)
|
||||
}
|
||||
// Constant time, so that a mismatching state cannot be discovered a byte at a
|
||||
// time. The state is public, but the comparison is free to be careful.
|
||||
if subtle.ConstantTimeCompare([]byte(returnedState), []byte(tx.State)) != 1 {
|
||||
return nil, fmt.Errorf("%w: callback state does not match the login transaction", domain.ErrUnauthorized)
|
||||
}
|
||||
|
||||
code := req.Form.Get("code")
|
||||
if code == "" {
|
||||
return nil, fmt.Errorf("%w: callback is missing the authorization code", domain.ErrUnauthorized)
|
||||
}
|
||||
|
||||
token, err := p.oauth.Exchange(ctx, code, oauth2.VerifierOption(tx.Verifier))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("exchange authorization code: %w", err)
|
||||
}
|
||||
|
||||
rawIDToken, ok := token.Extra("id_token").(string)
|
||||
if !ok || rawIDToken == "" {
|
||||
return nil, fmt.Errorf("%w: provider returned no id_token", domain.ErrUnauthorized)
|
||||
}
|
||||
|
||||
idToken, err := p.verifier.Verify(ctx, rawIDToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("verify id_token: %w", err)
|
||||
}
|
||||
if idToken.Nonce != tx.Nonce {
|
||||
return nil, fmt.Errorf("%w: id_token nonce mismatch", domain.ErrUnauthorized)
|
||||
}
|
||||
|
||||
claims := struct {
|
||||
Subject string `json:"sub"`
|
||||
Email string `json:"email"`
|
||||
EmailVerified bool `json:"email_verified"`
|
||||
Name string `json:"name"`
|
||||
PreferredUser string `json:"preferred_username"`
|
||||
Groups []string `json:"groups"`
|
||||
RealmRoles []string `json:"realm_access.roles"`
|
||||
}{}
|
||||
if err := idToken.Claims(&claims); err != nil {
|
||||
return nil, fmt.Errorf("decode id_token claims: %w", err)
|
||||
}
|
||||
|
||||
identity := &Identity{
|
||||
Subject: claims.Subject,
|
||||
Email: claims.Email,
|
||||
Name: firstNonEmpty(claims.Name, claims.PreferredUser, claims.Email),
|
||||
Method: "oidc",
|
||||
}
|
||||
if identity.Subject == "" {
|
||||
return nil, fmt.Errorf("%w: id_token has no subject", domain.ErrUnauthorized)
|
||||
}
|
||||
identity.Roles = p.rolesFor(claims.Groups, claims.RealmRoles)
|
||||
|
||||
return &Login{Identity: identity, ReturnTo: tx.ReturnTo}, nil
|
||||
}
|
||||
|
||||
// rolesFor maps provider groups onto application roles.
|
||||
//
|
||||
// The most privileged matching group wins, and a user in no configured group
|
||||
// gets viewer. The default is deliberately the least privilege: a group mapping
|
||||
// mistake must fail closed.
|
||||
func (p *OIDCProvider) rolesFor(groups, realmRoles []string) []domain.Role {
|
||||
all := make(map[string]struct{}, len(groups)+len(realmRoles))
|
||||
for _, g := range groups {
|
||||
all[strings.ToLower(strings.TrimSpace(g))] = struct{}{}
|
||||
}
|
||||
for _, r := range realmRoles {
|
||||
all[strings.ToLower(strings.TrimSpace(r))] = struct{}{}
|
||||
}
|
||||
|
||||
matches := func(configured []string) bool {
|
||||
for _, want := range configured {
|
||||
if _, ok := all[strings.ToLower(strings.TrimSpace(want))]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
switch {
|
||||
case matches(p.config.OIDCAdminGroups):
|
||||
return []domain.Role{domain.RoleAdmin}
|
||||
case matches(p.config.OIDCEditorGroups):
|
||||
return []domain.Role{domain.RoleEditor}
|
||||
case matches(p.config.OIDCViewerGroups):
|
||||
return []domain.Role{domain.RoleViewer}
|
||||
default:
|
||||
return []domain.Role{domain.RoleViewer}
|
||||
}
|
||||
}
|
||||
|
||||
// End returns the provider's end-session URL.
|
||||
func (p *OIDCProvider) End(_ context.Context, redirect string) string {
|
||||
return p.LogoutURL(redirect)
|
||||
}
|
||||
|
||||
// LogoutURL builds the provider end-session URL.
|
||||
func (p *OIDCProvider) LogoutURL(redirect string) string {
|
||||
if redirect == "" {
|
||||
return ""
|
||||
}
|
||||
endSession := p.provider.Endpoint()
|
||||
// Keycloak, Authentik and Zitadel all accept the post_logout_redirect_uri
|
||||
// parameter on the end-session endpoint. A provider that does not
|
||||
// implement it ignores the parameter and lands the user on its own logout
|
||||
// confirmation page, which is a safe failure mode.
|
||||
if !strings.Contains(endSession.AuthURL, "/end-session") {
|
||||
return endSession.AuthURL
|
||||
}
|
||||
sep := "?"
|
||||
if strings.Contains(endSession.AuthURL, "?") {
|
||||
sep = "&"
|
||||
}
|
||||
return endSession.AuthURL + sep + "post_logout_redirect_uri=" + urlQueryEscape(redirect) +
|
||||
"&client_id=" + urlQueryEscape(p.config.OIDCClientID)
|
||||
}
|
||||
|
||||
// Issuer returns the configured issuer, used by the admin UI to show which
|
||||
// provider is in use.
|
||||
func (p *OIDCProvider) Issuer() string { return p.config.OIDCIssuer }
|
||||
|
||||
// RedirectURI returns the configured callback URL.
|
||||
func (p *OIDCProvider) RedirectURI() string { return p.config.RedirectURL() }
|
||||
|
||||
// Claims returns the claims supported by this provider, for documentation.
|
||||
func (p *OIDCProvider) Claims() []string {
|
||||
list := []string{"sub", "email", "name", "preferred_username", "groups", "realm_access.roles"}
|
||||
sort.Strings(list)
|
||||
return list
|
||||
}
|
||||
|
||||
// ClearState expires the transaction cookie. The HTTP layer calls it after
|
||||
// Complete so that a replayed callback cannot reuse a consumed verifier.
|
||||
func (p *OIDCProvider) ClearState(w http.ResponseWriter) { p.state.Clear(w) }
|
||||
|
||||
// ---------------------------------------------------------------- helpers --
|
||||
|
||||
// randomVerifier mints a PKCE verifier.
|
||||
func randomVerifier() (string, error) {
|
||||
return randomToken(32)
|
||||
}
|
||||
|
||||
// randomState mints an anti-forgery state value.
|
||||
func randomState() (string, error) {
|
||||
return randomToken(24)
|
||||
}
|
||||
|
||||
// randomNonce mints an ID token nonce.
|
||||
func randomNonce() (string, error) {
|
||||
return randomToken(16)
|
||||
}
|
||||
|
||||
// randomToken returns a URL-safe random string of n bytes.
|
||||
func randomToken(n int) (string, error) {
|
||||
buf := make([]byte, n)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", fmt.Errorf("read random: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
// codeChallenge returns the S256 PKCE challenge for a verifier.
|
||||
func codeChallenge(verifier string) string {
|
||||
sum := sha256.Sum256([]byte(verifier))
|
||||
return base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// firstNonEmpty returns the first non-empty string.
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, v := range values {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// urlQueryEscape percent-encodes a query parameter value.
|
||||
func urlQueryEscape(s string) string {
|
||||
var b strings.Builder
|
||||
for _, b0 := range []byte(s) {
|
||||
switch {
|
||||
case b0 >= 'a' && b0 <= 'z', b0 >= 'A' && b0 <= 'Z', b0 >= '0' && b0 <= '9',
|
||||
b0 == '-', b0 == '_', b0 == '.', b0 == '~':
|
||||
b.WriteByte(b0)
|
||||
default:
|
||||
b.WriteByte('%')
|
||||
b.WriteString(strings.ToUpper(hex.EncodeToString([]byte{b0})))
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// insecureTLSConfig returns a TLS configuration that skips verification.
|
||||
//
|
||||
// It exists so that a development-only identity provider presenting a
|
||||
// self-signed certificate can be used without wiring a CA into the image.
|
||||
// config.Validate refuses to start when this is enabled outside development.
|
||||
func insecureTLSConfig() *tls.Config {
|
||||
//nolint:gosec // InsecureSkipVerify is intentional and gated by config.Validate.
|
||||
return &tls.Config{InsecureSkipVerify: true}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue