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
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