Supabase JWKS verification + go-chi/chi v5 HTTP middleware adapter for the
Solid Systems adult-portfolio Go services
(700 Days, MoT, Traced, Didja).
No v0.1.0 tag exists yet. The public API surface is captured below in
"Planned public API" and will be implemented across the next two plans of the
Didja Phase 1 milestone:
- Plan 03 lands the
Verifier(JWKS fetcher + kid-rotation + verify). - Plan 04 lands the chi middleware adapter (
Middleware,ClaimsFromContext). - Plan 09 cuts the
v0.1.0tag once the API surface stops moving.
Until v0.1.0 ships, consumers MUST depend on this module via a replace
directive against a local clone — see "Consuming pre-tag" below.
Stateless Supabase JWT verification (JWKS fetch + cache + kid-rotation handling)
plus a chi HTTP middleware adapter. Lifted from the verified-half of the
700-days pkg/auth/supabase.go so multiple portfolio services don't rediscover
JWKS edge cases (kid-miss refetch, ECDSA P-256 parsing, kid-fallback boundary
hole). Does NOT include Supabase admin operations — those remain in 700-days.
In scope at v0.1.0:
- JWKS
Verifierwith TTL cache + kid-miss-refetch-once Claimsstruct (typed UUIDSub, parsed at verification time)chimiddleware (func Middleware(*Verifier) func(http.Handler) http.Handler)- Fail-closed on empty issuer (per 700-days hardening)
- Opt-in kid-fallback boundary hole, off by default
Out of scope at v0.1.0:
- GitHub Actions CI (Didja's integration tests exercise this module end-to-end)
example/binary (README + unit tests are sufficient docs)- Pluggable
JWKSFetcherinterface (httptest stub is enough for v0.1.0 tests) - Optional-auth routes (wiring the middleware = auth enforced, no per-route opt-in)
- Supabase admin HTTP operations (
GetUserByID,DeleteUser, etc. — stay in 700-days)
package auth
type Config struct {
SupabaseURL string
Issuer string
Audience string
AllowKidFallback bool
JWKSCacheTTL time.Duration
HTTPClient *http.Client
}
type Verifier struct { /* unexported */ }
func NewVerifier(cfg Config) (*Verifier, error)
func (v *Verifier) Verify(tokenStr string) (*Claims, error)
type Claims struct {
Sub uuid.UUID
Email string
Role string
Aud []string
Exp time.Time
RawJWT string
}
func Middleware(v *Verifier) func(http.Handler) http.Handler
func ClaimsFromContext(ctx context.Context) (*Claims, bool)
func MustClaims(ctx context.Context) *Claims
func UserIDFromContext(ctx context.Context) (uuid.UUID, bool)Consumer services configure the verifier from these environment variables. The Didja, 700-days, MoT, and Traced services all use the same set.
| Env var | Required | Default | Meaning |
|---|---|---|---|
SUPABASE_URL |
yes | — | Supabase project base URL. JWKS is fetched from ${SUPABASE_URL}/auth/v1/.well-known/jwks.json. |
SUPABASE_JWT_ISSUER |
yes | — | Expected iss claim. Fail-closed on empty. Empty string is a configuration error, not a permissive default. |
SUPABASE_JWT_AUDIENCE |
no | authenticated |
Expected aud claim. |
SUPABASE_JWT_ALLOW_KID_FALLBACK |
no | false |
If true, falls back to cachedKeys[0] when a token's kid is not in the cache even after refetch. DO NOT enable in production unless actively debugging a Supabase key-rotation incident — it's an opt-in boundary hole per 700-days hardening (C-4). |
SUPABASE_JWKS_CACHE_TTL |
no | 1h |
How long JWKS responses are cached before re-fetching. |
The middleware writes {"error":"unauthorized","code":"<reason>"} with HTTP 401
on any auth failure and terminates the chain. Codes:
| Code | Meaning |
|---|---|
missing_authorization |
No Authorization header, or not Bearer .... |
invalid_token |
Token malformed, signature invalid, or kid not found post-refetch. |
expired_token |
exp claim is in the past. |
wrong_audience |
aud claim does not match SUPABASE_JWT_AUDIENCE. |
wrong_issuer |
iss claim does not match SUPABASE_JWT_ISSUER. |
jwks_unavailable |
JWKS endpoint returned non-2xx or unparseable JSON. |
While v0.1.0 has not yet been tagged, consumer modules MUST use a local
replace directive. From the consumer module root:
# 1. Clone portfolio-auth-go next to the consumer
git clone https://github.com/solidsystems/portfolio-auth-go.git ../portfolio-auth-go
# 2. Add a replace directive pointing at the local clone
go mod edit -replace github.com/solidsystems/portfolio-auth-go=../portfolio-auth-go
# 3. Add the require line (version is irrelevant under replace; v0.0.0 works)
go mod edit -require github.com/solidsystems/portfolio-auth-go@v0.0.0
go mod tidyOnce v0.1.0 is tagged (Didja Phase 1 Plan 09), swap to a standard module
dependency:
go get github.com/solidsystems/portfolio-auth-go@v0.1.0
go mod edit -dropreplace github.com/solidsystems/portfolio-auth-go
go mod tidyMIT — see LICENSE.