diff --git a/.env.example b/.env.example index d951fae..5195a02 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,8 @@ SUPABASE_URL=http://localhost:8000 SUPABASE_AUTH_EXTERNAL_URL=http://localhost:8000/auth/v1 SUPABASE_JWKS_URL=http://localhost:8000/auth/v1/.well-known/jwks.json SUPABASE_ISSUER=http://localhost:8000/auth/v1 +# Legacy self-host fallback while local JWKS has no asymmetric keys. +SUPABASE_JWT_SECRET= SUPABASE_ANON_KEY= SUPABASE_SERVICE_ROLE_KEY= diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e00513..527bc2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 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-Migration fuer tenant-bezogene Tabellen mit Membership-basierten Policies angelegt. - Audit-Log-Migration mit append-only Triggern und Tenant-Zeit-Index angelegt. diff --git a/README.md b/README.md index 8a60613..8e272bf 100644 --- a/README.md +++ b/README.md @@ -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. +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: - Supabase API Gateway: `http://localhost:8000` @@ -50,6 +52,11 @@ Aktuelle Targets: - `make migrate-down`: letzte Migration zurueckrollen - `make migrate-version`: aktuelle Migrationsversion anzeigen +Backend-Endpunkte: + +- `GET /healthz`: oeffentlicher Healthcheck +- `GET /me`: geschuetzt, gibt den authentifizierten Principal aus dem JWT zurueck + ## CI Forgejo Actions laufen unter `.forgejo/workflows/ci.yml`. diff --git a/TODO.md b/TODO.md index 9e55020..32dfc56 100644 --- a/TODO.md +++ b/TODO.md @@ -70,6 +70,13 @@ Arbeitsliste auf Basis von `proxmox-console-entwicklungsplan.md`. Die Entwurfsda - [x] Audit-Log auf owner/admin beschraenkt - [x] Direkter Query als Nicht-Mitglied lokal verifiziert - [x] Supabase-Advisor-Follow-up: RLS auf allen uebrigen Public-Tabellen aktiviert +- [x] E1-T02: JWT-Validierungs-Middleware im Backend + - [x] `Authorization: Bearer ` 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 @@ -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: 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: JWT-Middleware-Tests fuer gueltige, abgelaufene, manipulierte und fehlende Tokens erfolgreich. diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 4f1bae1..06e7045 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "errors" "net/http" "os" @@ -9,6 +10,8 @@ import ( "syscall" "time" + "proxui/backend/internal/auth" + "forgejo.digital-droplets.de/philschlo/proxui/platform/config" "forgejo.digital-droplets.de/philschlo/proxui/platform/logging" ) @@ -22,12 +25,27 @@ func main() { } 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.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = 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{ Addr: cfg.BackendAddr, diff --git a/backend/internal/auth/middleware.go b/backend/internal/auth/middleware.go new file mode 100644 index 0000000..b3e4097 --- /dev/null +++ b/backend/internal/auth/middleware.go @@ -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"}) +} diff --git a/backend/internal/auth/middleware_test.go b/backend/internal/auth/middleware_test.go new file mode 100644 index 0000000..e684bb5 --- /dev/null +++ b/backend/internal/auth/middleware_test.go @@ -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) + } +} diff --git a/backend/internal/auth/principal.go b/backend/internal/auth/principal.go new file mode 100644 index 0000000..8db8660 --- /dev/null +++ b/backend/internal/auth/principal.go @@ -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()) +} diff --git a/backend/internal/auth/validator.go b/backend/internal/auth/validator.go new file mode 100644 index 0000000..e73cfb1 --- /dev/null +++ b/backend/internal/auth/validator.go @@ -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"` +} diff --git a/backend/internal/auth/validator_test.go b/backend/internal/auth/validator_test.go new file mode 100644 index 0000000..e551e3d --- /dev/null +++ b/backend/internal/auth/validator_test.go @@ -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) +} diff --git a/platform/config/config.go b/platform/config/config.go index 12be3e4..219ebf6 100644 --- a/platform/config/config.go +++ b/platform/config/config.go @@ -9,17 +9,18 @@ import ( ) type Config struct { - AppEnv string - LogLevel slog.Level - BackendAddr string - ConsoleProxyAddr string + AppEnv string + LogLevel slog.Level + BackendAddr string + ConsoleProxyAddr string WorkerConcurrency int - DatabaseURL string - DatabaseDirectURL string - SupabaseURL string - SupabaseJWKSURL string - SupabaseIssuer string - RedisAddr string + DatabaseURL string + DatabaseDirectURL string + SupabaseURL string + SupabaseJWKSURL string + SupabaseIssuer string + SupabaseJWTSecret string + RedisAddr string } func Load() (Config, error) { @@ -37,17 +38,18 @@ func Load() (Config, error) { } return Config{ - AppEnv: getenv("APP_ENV", "development"), - LogLevel: logLevel, - BackendAddr: getenv("BACKEND_ADDR", ":8080"), - ConsoleProxyAddr: getenv("CONSOLE_PROXY_ADDR", ":8081"), + AppEnv: getenv("APP_ENV", "development"), + LogLevel: logLevel, + BackendAddr: getenv("BACKEND_ADDR", ":8080"), + ConsoleProxyAddr: getenv("CONSOLE_PROXY_ADDR", ":8081"), WorkerConcurrency: workerConcurrency, - DatabaseURL: os.Getenv("DATABASE_URL"), - DatabaseDirectURL: os.Getenv("DATABASE_DIRECT_URL"), - SupabaseURL: os.Getenv("SUPABASE_URL"), - SupabaseJWKSURL: os.Getenv("SUPABASE_JWKS_URL"), - SupabaseIssuer: os.Getenv("SUPABASE_ISSUER"), - RedisAddr: getenv("REDIS_ADDR", "localhost:6379"), + DatabaseURL: os.Getenv("DATABASE_URL"), + DatabaseDirectURL: os.Getenv("DATABASE_DIRECT_URL"), + SupabaseURL: os.Getenv("SUPABASE_URL"), + SupabaseJWKSURL: os.Getenv("SUPABASE_JWKS_URL"), + SupabaseIssuer: os.Getenv("SUPABASE_ISSUER"), + SupabaseJWTSecret: os.Getenv("SUPABASE_JWT_SECRET"), + RedisAddr: getenv("REDIS_ADDR", "localhost:6379"), }, nil } diff --git a/platform/config/config_test.go b/platform/config/config_test.go index 6b06b34..5fe356a 100644 --- a/platform/config/config_test.go +++ b/platform/config/config_test.go @@ -102,6 +102,7 @@ func clearConfigEnv(t *testing.T) { "SUPABASE_URL", "SUPABASE_JWKS_URL", "SUPABASE_ISSUER", + "SUPABASE_JWT_SECRET", "REDIS_ADDR", } { t.Setenv(key, "")