feat: add backend jwt middleware
This commit is contained in:
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ type Config struct {
|
|||||||
SupabaseURL string
|
SupabaseURL string
|
||||||
SupabaseJWKSURL string
|
SupabaseJWKSURL string
|
||||||
SupabaseIssuer string
|
SupabaseIssuer string
|
||||||
|
SupabaseJWTSecret string
|
||||||
RedisAddr string
|
RedisAddr string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +48,7 @@ func Load() (Config, error) {
|
|||||||
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"),
|
||||||
|
SupabaseJWTSecret: os.Getenv("SUPABASE_JWT_SECRET"),
|
||||||
RedisAddr: getenv("REDIS_ADDR", "localhost:6379"),
|
RedisAddr: getenv("REDIS_ADDR", "localhost:6379"),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, "")
|
||||||
|
|||||||
Reference in New Issue
Block a user