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.
135 lines
4.0 KiB
Go
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
|
|
}
|