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
+18
View File
@@ -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,
+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)
}