376 lines
12 KiB
Go
376 lines
12 KiB
Go
|
|
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}
|
||
|
|
}
|