Files
anchorage/internal/pkg/auth/oidc.go
William Gill 12bf35caf8 anchorage v1.0 initial tree
Greenfield Go multi-tenant IPFS Pinning Service wire-compatible with the
IPFS Pinning Services API spec. Paired 1:1 with Kubo over localhost RPC,
clustered via embedded NATS JetStream, Postgres source-of-truth with
RLS-enforced tenancy, Fiber + huma v2 for the HTTP surface, Authentik
OIDC for session login with kid-rotated HS256 JWT API tokens.

Feature-complete against the 22-milestone build plan, including the
ship-it v1.0 gap items:

  * admin CLIs: drain/uncordon, maintenance, mint-token, rotate-key,
    prune-denylist, rebalance --dry-run, cache-stats, cluster-presences
  * TTL leader election via NATS KV, fence tokens, JetStream dedup
  * rebalancer (plan/apply split), reconciler, requeue sweeper
  * ristretto caches with NATS-backed cross-node invalidation
    (placements live-nodes + token denylist)
  * maintenance watchdog for stuck cluster-pause flag
  * Prometheus /metrics with CIDR ACL, HTTP/pin/scheduler/cache gauges
  * rate limiting: session (10/min) + anonymous global (120/min)
  * integration tests: rebalance, refcount multi-org, RLS belt
  * goreleaser (tar + deb/rpm/apk + Alpine Docker) targeting Gitea

Stack: Cobra/Viper, Fiber v2 + huma v2, embedded NATS JetStream,
pgx/sqlc/golang-migrate, ristretto, TypeID, prometheus/client_golang,
testcontainers-go.
2026-04-16 18:13:36 -05:00

135 lines
4.0 KiB
Go

package auth
import (
"context"
"errors"
"fmt"
"net/url"
"strings"
"time"
"github.com/MicahParks/keyfunc/v3"
"github.com/golang-jwt/jwt/v5"
)
// OIDCClaims is the subset of an Authentik ID token anchorage cares about.
//
// Standard OIDC claims (sub, email, name) land directly; custom claims
// added via Authentik property mappings (like the `groups` claim from
// docs/authentik-setup.md §4) ride along too.
type OIDCClaims struct {
Sub string `json:"sub"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
Name string `json:"name,omitempty"`
PreferredName string `json:"preferred_username,omitempty"`
Groups []string `json:"groups,omitempty"`
jwt.RegisteredClaims
}
// DisplayName picks the best human-readable name from the claims — preferring
// `name`, falling back to `preferred_username`, then email. Never empty.
func (c *OIDCClaims) DisplayName() string {
switch {
case c.Name != "":
return c.Name
case c.PreferredName != "":
return c.PreferredName
default:
return c.Email
}
}
// OIDCVerifier validates ID tokens against a single Authentik provider.
//
// JWKS are fetched from <issuer>/jwks/ on construction and auto-refreshed
// by keyfunc/v3 (default interval ~1h). Revocation of the signing key is
// therefore picked up without an anchorage restart.
type OIDCVerifier struct {
issuer string
audience string
jwks keyfunc.Keyfunc
}
// OIDCVerifierOptions configures NewOIDCVerifier.
type OIDCVerifierOptions struct {
// Issuer is the full OIDC issuer URL (must match the ID token's
// `iss` claim byte-for-byte).
Issuer string
// Audience is the expected `aud` claim value — typically the
// OAuth2 client ID as configured in Authentik.
Audience string
// JWKSURL overrides the default <issuer>/jwks/ derivation. Leave
// empty in production; useful for tests pointing at httptest.
JWKSURL string
}
// NewOIDCVerifier fetches the JWKS for the given issuer once, then
// relies on keyfunc's background refresh to track cert rotations.
//
// The initial JWKS fetch is bounded by ctx — pass a deadline to avoid
// hanging app startup if Authentik is unreachable at boot.
func NewOIDCVerifier(ctx context.Context, opts OIDCVerifierOptions) (*OIDCVerifier, error) {
if opts.Issuer == "" {
return nil, errors.New("auth: OIDC Issuer is required")
}
if opts.Audience == "" {
return nil, errors.New("auth: OIDC Audience is required")
}
jwksURL := opts.JWKSURL
if jwksURL == "" {
jwksURL = strings.TrimRight(opts.Issuer, "/") + "/jwks/"
}
if _, err := url.Parse(jwksURL); err != nil {
return nil, fmt.Errorf("auth: parse jwks url: %w", err)
}
k, err := keyfunc.NewDefaultCtx(ctx, []string{jwksURL})
if err != nil {
return nil, fmt.Errorf("auth: init jwks client for %s: %w", jwksURL, err)
}
return &OIDCVerifier{
issuer: opts.Issuer,
audience: opts.Audience,
jwks: k,
}, nil
}
// Verify validates an Authentik-issued ID token end-to-end: signature via
// JWKS, issuer / audience / expiry / not-before clock checks, and a
// minimum shape check (sub + email required).
//
// Returns the parsed claims on success.
func (v *OIDCVerifier) Verify(_ context.Context, idToken string) (*OIDCClaims, error) {
if v == nil {
return nil, errors.New("auth: nil OIDCVerifier")
}
if strings.TrimSpace(idToken) == "" {
return nil, errors.New("auth: empty id token")
}
claims := &OIDCClaims{}
tok, err := jwt.ParseWithClaims(idToken, claims, v.jwks.Keyfunc,
jwt.WithIssuer(v.issuer),
jwt.WithAudience(v.audience),
// Allow a small clock skew — Authentik and anchorage are on
// different hosts, NTP is imperfect.
jwt.WithLeeway(60*time.Second),
)
if err != nil {
return nil, fmt.Errorf("auth: verify id token: %w", err)
}
if !tok.Valid {
return nil, errors.New("auth: id token invalid")
}
if claims.Sub == "" {
return nil, errors.New("auth: id token missing sub claim")
}
if claims.Email == "" {
return nil, errors.New("auth: id token missing email claim — add `email` to the Authentik scope mappings")
}
return claims, nil
}