feat: add backend jwt middleware

This commit is contained in:
Philipp
2026-06-10 16:23:55 +02:00
parent d87d8c6be7
commit 9947286b1e
12 changed files with 795 additions and 20 deletions
+2
View File
@@ -14,6 +14,8 @@ SUPABASE_URL=http://localhost:8000
SUPABASE_AUTH_EXTERNAL_URL=http://localhost:8000/auth/v1 SUPABASE_AUTH_EXTERNAL_URL=http://localhost:8000/auth/v1
SUPABASE_JWKS_URL=http://localhost:8000/auth/v1/.well-known/jwks.json SUPABASE_JWKS_URL=http://localhost:8000/auth/v1/.well-known/jwks.json
SUPABASE_ISSUER=http://localhost:8000/auth/v1 SUPABASE_ISSUER=http://localhost:8000/auth/v1
# Legacy self-host fallback while local JWKS has no asymmetric keys.
SUPABASE_JWT_SECRET=<copy-JWT_SECRET-from-deploy/supabase/.env>
SUPABASE_ANON_KEY=<copy-from-deploy/supabase/.env> SUPABASE_ANON_KEY=<copy-from-deploy/supabase/.env>
SUPABASE_SERVICE_ROLE_KEY=<copy-from-deploy/supabase/.env> SUPABASE_SERVICE_ROLE_KEY=<copy-from-deploy/supabase/.env>
+1
View File
@@ -2,6 +2,7 @@
## Unreleased ## Unreleased
- Backend-JWT-Middleware mit JWKS-Validierung, lokalem HS256-Fallback und geschuetztem `/me` Endpunkt angelegt.
- RLS-Advisor-Cleanup fuer alle uebrigen Public-Tabellen angelegt. - RLS-Advisor-Cleanup fuer alle uebrigen Public-Tabellen angelegt.
- RLS-Migration fuer tenant-bezogene Tabellen mit Membership-basierten Policies angelegt. - RLS-Migration fuer tenant-bezogene Tabellen mit Membership-basierten Policies angelegt.
- Audit-Log-Migration mit append-only Triggern und Tenant-Zeit-Index angelegt. - Audit-Log-Migration mit append-only Triggern und Tenant-Zeit-Index angelegt.
+7
View File
@@ -30,6 +30,8 @@ Supabase self-hosted liegt unter `deploy/supabase/`. Die lokale `deploy/supabase
Migrationen nutzen `MIGRATE_DATABASE_URL`, falls gesetzt. Andernfalls wird `DATABASE_DIRECT_URL` aus `.env` oder eine lokale Supavisor-URL aus `deploy/supabase/.env` verwendet. Migrationen nutzen `MIGRATE_DATABASE_URL`, falls gesetzt. Andernfalls wird `DATABASE_DIRECT_URL` aus `.env` oder eine lokale Supavisor-URL aus `deploy/supabase/.env` verwendet.
Das Backend validiert Supabase-JWTs ueber `SUPABASE_JWKS_URL` und `SUPABASE_ISSUER`. Fuer das lokale Self-Hosted-Setup mit leerem JWKS wird zusaetzlich `SUPABASE_JWT_SECRET` als HS256-Fallback genutzt.
Lokale Dienste: Lokale Dienste:
- Supabase API Gateway: `http://localhost:8000` - Supabase API Gateway: `http://localhost:8000`
@@ -50,6 +52,11 @@ Aktuelle Targets:
- `make migrate-down`: letzte Migration zurueckrollen - `make migrate-down`: letzte Migration zurueckrollen
- `make migrate-version`: aktuelle Migrationsversion anzeigen - `make migrate-version`: aktuelle Migrationsversion anzeigen
Backend-Endpunkte:
- `GET /healthz`: oeffentlicher Healthcheck
- `GET /me`: geschuetzt, gibt den authentifizierten Principal aus dem JWT zurueck
## CI ## CI
Forgejo Actions laufen unter `.forgejo/workflows/ci.yml`. Forgejo Actions laufen unter `.forgejo/workflows/ci.yml`.
+8
View File
@@ -70,6 +70,13 @@ Arbeitsliste auf Basis von `proxmox-console-entwicklungsplan.md`. Die Entwurfsda
- [x] Audit-Log auf owner/admin beschraenkt - [x] Audit-Log auf owner/admin beschraenkt
- [x] Direkter Query als Nicht-Mitglied lokal verifiziert - [x] Direkter Query als Nicht-Mitglied lokal verifiziert
- [x] Supabase-Advisor-Follow-up: RLS auf allen uebrigen Public-Tabellen aktiviert - [x] Supabase-Advisor-Follow-up: RLS auf allen uebrigen Public-Tabellen aktiviert
- [x] E1-T02: JWT-Validierungs-Middleware im Backend
- [x] `Authorization: Bearer <jwt>` Middleware angelegt
- [x] RS256/ES256 Validierung gegen Supabase-JWKS angelegt
- [x] Lokalen HS256-Fallback fuer Self-Hosted-Legacy-Keys angelegt
- [x] Issuer, Expiry, Not-Before und Signatur werden validiert
- [x] `sub`, `email`, `role` werden als Principal in den Request-Context gelegt
- [x] Geschuetzten `/me` Endpunkt angelegt
## MVP-Backlog ## MVP-Backlog
@@ -117,3 +124,4 @@ Arbeitsliste auf Basis von `proxmox-console-entwicklungsplan.md`. Die Entwurfsda
- 2026-06-10: Audit-Log-Migration `0007_audit_log` angelegt und Append-only-Verhalten lokal gegen Supabase verifiziert. - 2026-06-10: Audit-Log-Migration `0007_audit_log` angelegt und Append-only-Verhalten lokal gegen Supabase verifiziert.
- 2026-06-10: RLS-Migration `0008_rls_policies` angelegt und Mitglied/Nicht-Mitglied-Isolation lokal gegen Supabase verifiziert. - 2026-06-10: RLS-Migration `0008_rls_policies` angelegt und Mitglied/Nicht-Mitglied-Isolation lokal gegen Supabase verifiziert.
- 2026-06-10: RLS-Advisor-Cleanup `0009_rls_advisor_cleanup` angelegt; alle Public-Tabellen haben RLS aktiv. - 2026-06-10: RLS-Advisor-Cleanup `0009_rls_advisor_cleanup` angelegt; alle Public-Tabellen haben RLS aktiv.
- 2026-06-10: JWT-Middleware-Tests fuer gueltige, abgelaufene, manipulierte und fehlende Tokens erfolgreich.
+18
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"net/http" "net/http"
"os" "os"
@@ -9,6 +10,8 @@ import (
"syscall" "syscall"
"time" "time"
"proxui/backend/internal/auth"
"forgejo.digital-droplets.de/philschlo/proxui/platform/config" "forgejo.digital-droplets.de/philschlo/proxui/platform/config"
"forgejo.digital-droplets.de/philschlo/proxui/platform/logging" "forgejo.digital-droplets.de/philschlo/proxui/platform/logging"
) )
@@ -22,12 +25,27 @@ func main() {
} }
logger := logging.New("backend", cfg.AppEnv, cfg.LogLevel) logger := logging.New("backend", cfg.AppEnv, cfg.LogLevel)
jwtValidator, err := auth.NewValidator(
cfg.SupabaseIssuer,
cfg.SupabaseJWKSURL,
auth.WithHMACSecret(cfg.SupabaseJWTSecret),
)
if err != nil {
logger.Error("failed to initialize jwt validator", "error", err)
os.Exit(1)
}
authMiddleware := auth.NewMiddleware(jwtValidator)
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"ok","service":"backend"}`)) _, _ = w.Write([]byte(`{"status":"ok","service":"backend"}`))
}) })
mux.Handle("GET /me", authMiddleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, _ := auth.PrincipalFromRequest(r)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(principal)
})))
server := &http.Server{ server := &http.Server{
Addr: cfg.BackendAddr, Addr: cfg.BackendAddr,
+49
View File
@@ -0,0 +1,49 @@
package auth
import (
"encoding/json"
"net/http"
"strings"
)
type Middleware struct {
validator *Validator
}
func NewMiddleware(validator *Validator) Middleware {
return Middleware{validator: validator}
}
func (m Middleware) RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token, ok := bearerToken(r.Header.Get("Authorization"))
if !ok {
writeUnauthorized(w)
return
}
principal, err := m.validator.Validate(r.Context(), token)
if err != nil {
writeUnauthorized(w)
return
}
next.ServeHTTP(w, r.WithContext(withPrincipal(r.Context(), principal)))
})
}
func bearerToken(header string) (string, bool) {
const prefix = "Bearer "
if !strings.HasPrefix(header, prefix) {
return "", false
}
token := strings.TrimSpace(strings.TrimPrefix(header, prefix))
return token, token != ""
}
func writeUnauthorized(w http.ResponseWriter) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"})
}
+67
View File
@@ -0,0 +1,67 @@
package auth
import (
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestRequireAuthRejectsMissingToken(t *testing.T) {
env := newTestJWTEnv(t)
validator, err := NewValidator(env.issuer, env.jwksURL, WithNow(func() time.Time {
return env.now
}), WithHTTPClient(env.client))
if err != nil {
t.Fatalf("NewValidator() error = %v", err)
}
middleware := NewMiddleware(validator)
handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
req := httptest.NewRequest(http.MethodGet, "/me", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
}
}
func TestRequireAuthAcceptsValidToken(t *testing.T) {
env := newTestJWTEnv(t)
validator, err := NewValidator(env.issuer, env.jwksURL, WithNow(func() time.Time {
return env.now
}), WithHTTPClient(env.client))
if err != nil {
t.Fatalf("NewValidator() error = %v", err)
}
middleware := NewMiddleware(validator)
handler := middleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := PrincipalFromRequest(r)
if !ok {
t.Fatal("principal missing from request")
}
if principal.Subject != testSubject {
t.Fatalf("principal.Subject = %q, want %q", principal.Subject, testSubject)
}
if principal.Email != "user@example.test" {
t.Fatalf("principal.Email = %q", principal.Email)
}
w.WriteHeader(http.StatusNoContent)
}))
req := httptest.NewRequest(http.MethodGet, "/me", nil)
req.Header.Set("Authorization", "Bearer "+env.token(t, tokenOptions{}))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNoContent)
}
}
+27
View File
@@ -0,0 +1,27 @@
package auth
import (
"context"
"net/http"
)
type Principal struct {
Subject string
Email string
Role string
}
type principalContextKey struct{}
func PrincipalFromContext(ctx context.Context) (Principal, bool) {
principal, ok := ctx.Value(principalContextKey{}).(Principal)
return principal, ok
}
func withPrincipal(ctx context.Context, principal Principal) context.Context {
return context.WithValue(ctx, principalContextKey{}, principal)
}
func PrincipalFromRequest(r *http.Request) (Principal, bool) {
return PrincipalFromContext(r.Context())
}
+350
View File
@@ -0,0 +1,350 @@
package auth
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/hmac"
"crypto/rsa"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"math/big"
"net/http"
"strings"
"sync"
"time"
)
var (
ErrInvalidToken = errors.New("invalid token")
ErrExpiredToken = errors.New("expired token")
)
type Validator struct {
issuer string
jwksURL string
hmacKey []byte
client *http.Client
now func() time.Time
mu sync.Mutex
cachedAt time.Time
cacheTTL time.Duration
cachedSet keySet
}
type Option func(*Validator)
func WithHTTPClient(client *http.Client) Option {
return func(v *Validator) {
v.client = client
}
}
func WithNow(now func() time.Time) Option {
return func(v *Validator) {
v.now = now
}
}
func WithCacheTTL(ttl time.Duration) Option {
return func(v *Validator) {
v.cacheTTL = ttl
}
}
func WithHMACSecret(secret string) Option {
return func(v *Validator) {
secret = strings.TrimSpace(secret)
if secret != "" {
v.hmacKey = []byte(secret)
}
}
}
func NewValidator(issuer string, jwksURL string, opts ...Option) (*Validator, error) {
issuer = strings.TrimSpace(issuer)
jwksURL = strings.TrimSpace(jwksURL)
if issuer == "" {
return nil, fmt.Errorf("issuer is required")
}
validator := &Validator{
issuer: issuer,
jwksURL: jwksURL,
client: http.DefaultClient,
now: time.Now,
cacheTTL: 5 * time.Minute,
}
for _, opt := range opts {
opt(validator)
}
if validator.jwksURL == "" && len(validator.hmacKey) == 0 {
return nil, fmt.Errorf("jwks url or hmac secret is required")
}
return validator, nil
}
func (v *Validator) Validate(ctx context.Context, token string) (Principal, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return Principal{}, ErrInvalidToken
}
var header jwtHeader
if err := decodeJSONPart(parts[0], &header); err != nil {
return Principal{}, ErrInvalidToken
}
if header.Alg == "" {
return Principal{}, ErrInvalidToken
}
var claims jwtClaims
if err := decodeJSONPart(parts[1], &claims); err != nil {
return Principal{}, ErrInvalidToken
}
if subtle.ConstantTimeCompare([]byte(claims.Issuer), []byte(v.issuer)) != 1 {
return Principal{}, ErrInvalidToken
}
if claims.Subject == "" {
return Principal{}, ErrInvalidToken
}
now := v.now().Unix()
if claims.ExpiresAt <= now {
return Principal{}, ErrExpiredToken
}
if claims.NotBefore != 0 && claims.NotBefore > now {
return Principal{}, ErrInvalidToken
}
signingInput := parts[0] + "." + parts[1]
signature, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
return Principal{}, ErrInvalidToken
}
if header.Alg == "HS256" {
if err := v.verifyHMAC([]byte(signingInput), signature); err != nil {
return Principal{}, err
}
return Principal{
Subject: claims.Subject,
Email: claims.Email,
Role: claims.Role,
}, nil
}
if header.KID == "" {
return Principal{}, ErrInvalidToken
}
keys, err := v.keys(ctx)
if err != nil {
return Principal{}, err
}
key, ok := keys[header.KID]
if !ok {
return Principal{}, ErrInvalidToken
}
if key.Alg != "" && key.Alg != header.Alg {
return Principal{}, ErrInvalidToken
}
if err := verifySignature(header.Alg, key, []byte(signingInput), signature); err != nil {
return Principal{}, err
}
return Principal{
Subject: claims.Subject,
Email: claims.Email,
Role: claims.Role,
}, nil
}
func (v *Validator) verifyHMAC(signingInput []byte, signature []byte) error {
if len(v.hmacKey) == 0 {
return ErrInvalidToken
}
mac := hmac.New(sha256.New, v.hmacKey)
_, _ = mac.Write(signingInput)
if !hmac.Equal(signature, mac.Sum(nil)) {
return ErrInvalidToken
}
return nil
}
func (v *Validator) keys(ctx context.Context) (keySet, error) {
v.mu.Lock()
defer v.mu.Unlock()
if v.cachedSet != nil && v.now().Sub(v.cachedAt) < v.cacheTTL {
return v.cachedSet, nil
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, v.jwksURL, nil)
if err != nil {
return nil, err
}
resp, err := v.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("fetch jwks: status %d", resp.StatusCode)
}
var raw jwksResponse
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
return nil, err
}
keys := make(keySet, len(raw.Keys))
for _, key := range raw.Keys {
if key.KID != "" {
keys[key.KID] = key
}
}
if len(keys) == 0 {
return nil, fmt.Errorf("jwks contains no usable keys")
}
v.cachedSet = keys
v.cachedAt = v.now()
return keys, nil
}
func verifySignature(alg string, key jwk, signingInput []byte, signature []byte) error {
digest := sha256.Sum256(signingInput)
switch alg {
case "RS256":
publicKey, err := rsaPublicKey(key)
if err != nil {
return ErrInvalidToken
}
if err := rsa.VerifyPKCS1v15(publicKey, crypto.SHA256, digest[:], signature); err != nil {
return ErrInvalidToken
}
return nil
case "ES256":
publicKey, err := ecdsaPublicKey(key)
if err != nil {
return ErrInvalidToken
}
if len(signature) != 64 {
return ErrInvalidToken
}
r := new(big.Int).SetBytes(signature[:32])
s := new(big.Int).SetBytes(signature[32:])
if !ecdsa.Verify(publicKey, digest[:], r, s) {
return ErrInvalidToken
}
return nil
default:
return ErrInvalidToken
}
}
func decodeJSONPart(part string, out any) error {
data, err := base64.RawURLEncoding.DecodeString(part)
if err != nil {
return err
}
return json.Unmarshal(data, out)
}
func rsaPublicKey(key jwk) (*rsa.PublicKey, error) {
if key.Kty != "RSA" {
return nil, ErrInvalidToken
}
nBytes, err := base64.RawURLEncoding.DecodeString(key.N)
if err != nil {
return nil, err
}
eBytes, err := base64.RawURLEncoding.DecodeString(key.E)
if err != nil {
return nil, err
}
e := 0
for _, b := range eBytes {
e = e<<8 + int(b)
}
if e == 0 {
return nil, ErrInvalidToken
}
return &rsa.PublicKey{
N: new(big.Int).SetBytes(nBytes),
E: e,
}, nil
}
func ecdsaPublicKey(key jwk) (*ecdsa.PublicKey, error) {
if key.Kty != "EC" || key.Crv != "P-256" {
return nil, ErrInvalidToken
}
xBytes, err := base64.RawURLEncoding.DecodeString(key.X)
if err != nil {
return nil, err
}
yBytes, err := base64.RawURLEncoding.DecodeString(key.Y)
if err != nil {
return nil, err
}
publicKey := &ecdsa.PublicKey{
Curve: elliptic.P256(),
X: new(big.Int).SetBytes(xBytes),
Y: new(big.Int).SetBytes(yBytes),
}
if !publicKey.Curve.IsOnCurve(publicKey.X, publicKey.Y) {
return nil, ErrInvalidToken
}
return publicKey, nil
}
type keySet map[string]jwk
type jwksResponse struct {
Keys []jwk `json:"keys"`
}
type jwk struct {
KID string `json:"kid"`
Kty string `json:"kty"`
Alg string `json:"alg"`
N string `json:"n"`
E string `json:"e"`
Crv string `json:"crv"`
X string `json:"x"`
Y string `json:"y"`
}
type jwtHeader struct {
Alg string `json:"alg"`
KID string `json:"kid"`
Typ string `json:"typ"`
}
type jwtClaims struct {
Issuer string `json:"iss"`
Subject string `json:"sub"`
Email string `json:"email"`
Role string `json:"role"`
ExpiresAt int64 `json:"exp"`
NotBefore int64 `json:"nbf"`
IssuedAt int64 `json:"iat"`
}
+243
View File
@@ -0,0 +1,243 @@
package auth
import (
"context"
"crypto"
"crypto/hmac"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"io"
"math/big"
"net/http"
"strings"
"testing"
"time"
)
const (
testIssuer = "https://auth.example.test"
testSubject = "00000000-0000-0000-0000-000000000001"
testKID = "test-key"
)
func TestValidateAcceptsValidToken(t *testing.T) {
env := newTestJWTEnv(t)
validator, err := NewValidator(env.issuer, env.jwksURL, WithNow(func() time.Time {
return env.now
}), WithHTTPClient(env.client))
if err != nil {
t.Fatalf("NewValidator() error = %v", err)
}
principal, err := validator.Validate(context.Background(), env.token(t, tokenOptions{}))
if err != nil {
t.Fatalf("Validate() error = %v", err)
}
if principal.Subject != testSubject {
t.Fatalf("Subject = %q, want %q", principal.Subject, testSubject)
}
if principal.Email != "user@example.test" {
t.Fatalf("Email = %q", principal.Email)
}
if principal.Role != "authenticated" {
t.Fatalf("Role = %q", principal.Role)
}
}
func TestValidateRejectsExpiredToken(t *testing.T) {
env := newTestJWTEnv(t)
validator, err := NewValidator(env.issuer, env.jwksURL, WithNow(func() time.Time {
return env.now
}), WithHTTPClient(env.client))
if err != nil {
t.Fatalf("NewValidator() error = %v", err)
}
_, err = validator.Validate(context.Background(), env.token(t, tokenOptions{
expiresAt: env.now.Add(-time.Minute),
}))
if err == nil {
t.Fatal("Validate() error = nil, want error")
}
}
func TestValidateRejectsManipulatedToken(t *testing.T) {
env := newTestJWTEnv(t)
validator, err := NewValidator(env.issuer, env.jwksURL, WithNow(func() time.Time {
return env.now
}), WithHTTPClient(env.client))
if err != nil {
t.Fatalf("NewValidator() error = %v", err)
}
token := env.token(t, tokenOptions{})
parts := strings.Split(token, ".")
if len(parts) != 3 {
t.Fatalf("token has %d parts, want 3", len(parts))
}
var claims map[string]any
mustDecodeJWTPart(t, parts[1], &claims)
claims["email"] = "attacker@example.test"
parts[1] = mustEncodeJSON(t, claims)
_, err = validator.Validate(context.Background(), strings.Join(parts, "."))
if err == nil {
t.Fatal("Validate() error = nil, want error")
}
}
func TestValidateAcceptsLegacyHS256Token(t *testing.T) {
now := time.Unix(1_800_000_000, 0).UTC()
validator, err := NewValidator(testIssuer, "", WithHMACSecret("local-test-secret"), WithNow(func() time.Time {
return now
}))
if err != nil {
t.Fatalf("NewValidator() error = %v", err)
}
token := hs256Token(t, "local-test-secret", now, now.Add(time.Hour))
principal, err := validator.Validate(context.Background(), token)
if err != nil {
t.Fatalf("Validate() error = %v", err)
}
if principal.Subject != testSubject {
t.Fatalf("Subject = %q, want %q", principal.Subject, testSubject)
}
}
type testJWTEnv struct {
issuer string
jwksURL string
now time.Time
key *rsa.PrivateKey
client *http.Client
}
type tokenOptions struct {
expiresAt time.Time
}
func newTestJWTEnv(t *testing.T) testJWTEnv {
t.Helper()
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("GenerateKey() error = %v", err)
}
jwks := jwksResponse{Keys: []jwk{{
KID: testKID,
Kty: "RSA",
Alg: "RS256",
N: base64.RawURLEncoding.EncodeToString(privateKey.PublicKey.N.Bytes()),
E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(privateKey.PublicKey.E)).Bytes()),
}}}
jwksBody, err := json.Marshal(jwks)
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
return testJWTEnv{
issuer: testIssuer,
jwksURL: "https://jwks.example.test",
now: time.Unix(1_800_000_000, 0).UTC(),
key: privateKey,
client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(string(jwksBody))),
Request: req,
}, nil
})},
}
}
func (e testJWTEnv) token(t *testing.T, opts tokenOptions) string {
t.Helper()
expiresAt := opts.expiresAt
if expiresAt.IsZero() {
expiresAt = e.now.Add(time.Hour)
}
header := mustEncodeJSON(t, map[string]any{
"alg": "RS256",
"kid": testKID,
"typ": "JWT",
})
claims := mustEncodeJSON(t, map[string]any{
"iss": e.issuer,
"sub": testSubject,
"email": "user@example.test",
"role": "authenticated",
"iat": e.now.Unix(),
"exp": expiresAt.Unix(),
})
signingInput := header + "." + claims
digest := sha256.Sum256([]byte(signingInput))
signature, err := rsa.SignPKCS1v15(rand.Reader, e.key, crypto.SHA256, digest[:])
if err != nil {
t.Fatalf("SignPKCS1v15() error = %v", err)
}
return signingInput + "." + base64.RawURLEncoding.EncodeToString(signature)
}
func mustEncodeJSON(t *testing.T, value any) string {
t.Helper()
data, err := json.Marshal(value)
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
return base64.RawURLEncoding.EncodeToString(data)
}
func mustDecodeJWTPart(t *testing.T, part string, out any) {
t.Helper()
data, err := base64.RawURLEncoding.DecodeString(part)
if err != nil {
t.Fatalf("DecodeString() error = %v", err)
}
if err := json.Unmarshal(data, out); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
}
func hs256Token(t *testing.T, secret string, now time.Time, expiresAt time.Time) string {
t.Helper()
header := mustEncodeJSON(t, map[string]any{
"alg": "HS256",
"typ": "JWT",
})
claims := mustEncodeJSON(t, map[string]any{
"iss": testIssuer,
"sub": testSubject,
"email": "user@example.test",
"role": "authenticated",
"iat": now.Unix(),
"exp": expiresAt.Unix(),
})
signingInput := header + "." + claims
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(signingInput))
return signingInput + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return fn(req)
}
+22 -20
View File
@@ -9,17 +9,18 @@ import (
) )
type Config struct { type Config struct {
AppEnv string AppEnv string
LogLevel slog.Level LogLevel slog.Level
BackendAddr string BackendAddr string
ConsoleProxyAddr string ConsoleProxyAddr string
WorkerConcurrency int WorkerConcurrency int
DatabaseURL string DatabaseURL string
DatabaseDirectURL string DatabaseDirectURL string
SupabaseURL string SupabaseURL string
SupabaseJWKSURL string SupabaseJWKSURL string
SupabaseIssuer string SupabaseIssuer string
RedisAddr string SupabaseJWTSecret string
RedisAddr string
} }
func Load() (Config, error) { func Load() (Config, error) {
@@ -37,17 +38,18 @@ func Load() (Config, error) {
} }
return Config{ return Config{
AppEnv: getenv("APP_ENV", "development"), AppEnv: getenv("APP_ENV", "development"),
LogLevel: logLevel, LogLevel: logLevel,
BackendAddr: getenv("BACKEND_ADDR", ":8080"), BackendAddr: getenv("BACKEND_ADDR", ":8080"),
ConsoleProxyAddr: getenv("CONSOLE_PROXY_ADDR", ":8081"), ConsoleProxyAddr: getenv("CONSOLE_PROXY_ADDR", ":8081"),
WorkerConcurrency: workerConcurrency, WorkerConcurrency: workerConcurrency,
DatabaseURL: os.Getenv("DATABASE_URL"), DatabaseURL: os.Getenv("DATABASE_URL"),
DatabaseDirectURL: os.Getenv("DATABASE_DIRECT_URL"), DatabaseDirectURL: os.Getenv("DATABASE_DIRECT_URL"),
SupabaseURL: os.Getenv("SUPABASE_URL"), SupabaseURL: os.Getenv("SUPABASE_URL"),
SupabaseJWKSURL: os.Getenv("SUPABASE_JWKS_URL"), SupabaseJWKSURL: os.Getenv("SUPABASE_JWKS_URL"),
SupabaseIssuer: os.Getenv("SUPABASE_ISSUER"), SupabaseIssuer: os.Getenv("SUPABASE_ISSUER"),
RedisAddr: getenv("REDIS_ADDR", "localhost:6379"), SupabaseJWTSecret: os.Getenv("SUPABASE_JWT_SECRET"),
RedisAddr: getenv("REDIS_ADDR", "localhost:6379"),
}, nil }, nil
} }
+1
View File
@@ -102,6 +102,7 @@ func clearConfigEnv(t *testing.T) {
"SUPABASE_URL", "SUPABASE_URL",
"SUPABASE_JWKS_URL", "SUPABASE_JWKS_URL",
"SUPABASE_ISSUER", "SUPABASE_ISSUER",
"SUPABASE_JWT_SECRET",
"REDIS_ADDR", "REDIS_ADDR",
} { } {
t.Setenv(key, "") t.Setenv(key, "")