package moduleauth import ( "context" "crypto/ed25519" "crypto/rand" "encoding/base64" "encoding/json" "errors" "net/http" "net/http/httptest" "sync/atomic" "testing" "time" ) type keyPair struct { kid string pub ed25519.PublicKey prv ed25519.PrivateKey } func newKey(t *testing.T, kid string) keyPair { t.Helper() pub, prv, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatal(err) } return keyPair{kid, pub, prv} } func b64(v any) string { raw, _ := json.Marshal(v) return base64.RawURLEncoding.EncodeToString(raw) } func (k keyPair) token(claims map[string]any, alg string) string { h := b64(map[string]string{"alg": alg, "kid": k.kid, "typ": "JWT"}) signing := h + "." + b64(claims) return signing + "." + base64.RawURLEncoding.EncodeToString(ed25519.Sign(k.prv, []byte(signing))) } func valid(k keyPair, sub, tenant string) string { now := time.Now() return k.token(map[string]any{"sub": sub, "tenant": tenant, "iat": now.Unix(), "exp": now.Add(time.Hour).Unix()}, "EdDSA") } func staticKeys(ks ...keyPair) KeyFetchFunc { return func(context.Context) (map[string]ed25519.PublicKey, error) { m := map[string]ed25519.PublicKey{} for _, k := range ks { m[k.kid] = k.pub } return m, nil } } func TestVerifyAcceptsValidTokenAndReturnsClaims(t *testing.T) { k := newKey(t, "k1") v := NewVerifier(time.Minute, "acme", staticKeys(k)) c, err := v.Verify(context.Background(), valid(k, "user-1", "acme")) if err != nil || c.Subject != "user-1" || c.Tenant != "acme" { t.Fatalf("claims=%+v err=%v", c, err) } } func TestVerifyRejectsInvalidTokens(t *testing.T) { k, other := newKey(t, "k1"), newKey(t, "k1") now := time.Now() good := map[string]any{"sub": "u", "tenant": "acme", "iat": now.Unix(), "exp": now.Add(time.Hour).Unix()} with := func(key string, val any) map[string]any { m := map[string]any{} for a, b := range good { m[a] = b } if val == nil { delete(m, key) } else { m[key] = val } return m } cases := map[string]string{ "fremde signatur": other.token(good, "EdDSA"), "alg none": k.token(good, "none"), "alg HS256": k.token(good, "HS256"), "abgelaufen": k.token(with("exp", now.Add(-time.Hour).Unix()), "EdDSA"), "ohne exp": k.token(with("exp", nil), "EdDSA"), "iat in der zukunft": k.token(with("iat", now.Add(time.Hour).Unix()), "EdDSA"), "ohne sub": k.token(with("sub", nil), "EdDSA"), "anderer tenant": k.token(with("tenant", "globex"), "EdDSA"), "ohne tenant": k.token(with("tenant", nil), "EdDSA"), "kein token": "", "zwei teile": "a.b", "unbekannte kid": newKey(t, "unbekannt").token(good, "EdDSA"), "manipulierter payload": k.token(good, "EdDSA")[:len(k.token(good, "EdDSA"))-3] + "AAA", } v := NewVerifier(time.Minute, "acme", staticKeys(k)) for name, tok := range cases { t.Run(name, func(t *testing.T) { if _, err := v.Verify(context.Background(), tok); !errors.Is(err, ErrInvalidToken) { t.Errorf("err = %v, erwartet ErrInvalidToken", err) } }) } } func TestVerifyCachesKeysAndSurvivesCoreOutage(t *testing.T) { k := newKey(t, "k1") var calls atomic.Int32 var down atomic.Bool v := NewVerifier(time.Minute, "acme", func(ctx context.Context) (map[string]ed25519.PublicKey, error) { calls.Add(1) if down.Load() { return nil, errors.New("core down") } return staticKeys(k)(ctx) }) now := time.Now() v.now = func() time.Time { return now } tok := valid(k, "u", "acme") for i := 0; i < 5; i++ { if _, err := v.Verify(context.Background(), tok); err != nil { t.Fatal(err) } } if calls.Load() != 1 { t.Fatalf("abrufe = %d, erwartet 1 (kein Abruf je Anfrage)", calls.Load()) } down.Store(true) now = now.Add(2 * time.Minute) if _, err := v.Verify(context.Background(), tok); err != nil { t.Fatalf("bekannter satz muss bei core-ausfall weitergelten: %v", err) } } func TestVerifyWithoutKeysAndCoreDownIsNotAvailable(t *testing.T) { k := newKey(t, "k1") v := NewVerifier(time.Minute, "acme", func(context.Context) (map[string]ed25519.PublicKey, error) { return nil, errors.New("core down") }) _, err := v.Verify(context.Background(), valid(k, "u", "acme")) if err == nil || errors.Is(err, ErrInvalidToken) { t.Fatalf("erwartet nicht-verfuegbar-fehler, erhalten %v", err) } } func TestVerifyPicksUpRotatedKeyWithoutDowntime(t *testing.T) { old, fresh := newKey(t, "k1"), newKey(t, "k2") var set atomic.Value set.Store([]keyPair{old}) v := NewVerifier(time.Hour, "acme", func(ctx context.Context) (map[string]ed25519.PublicKey, error) { return staticKeys(set.Load().([]keyPair)...)(ctx) }) now := time.Now() v.now = func() time.Time { return now } if _, err := v.Verify(context.Background(), valid(old, "u", "acme")); err != nil { t.Fatal(err) } set.Store([]keyPair{old, fresh}) now = now.Add(time.Minute) if _, err := v.Verify(context.Background(), valid(fresh, "u", "acme")); err != nil { t.Fatalf("token mit rotiertem schluessel: %v", err) } if _, err := v.Verify(context.Background(), valid(old, "u", "acme")); err != nil { t.Fatalf("token mit altem schluessel muss weiter gueltig sein: %v", err) } } func TestUnknownKidRefreshIsRateLimited(t *testing.T) { k := newKey(t, "k1") var calls atomic.Int32 v := NewVerifier(time.Hour, "acme", func(ctx context.Context) (map[string]ed25519.PublicKey, error) { calls.Add(1) return staticKeys(k)(ctx) }) now := time.Now() v.now = func() time.Time { return now } rogue := newKey(t, "rogue") for i := 0; i < 20; i++ { _, _ = v.Verify(context.Background(), valid(rogue, "u", "acme")) } if calls.Load() > 2 { t.Fatalf("abrufe = %d, unbekannte kids duerfen den abruf nicht verstaerken", calls.Load()) } } func TestHTTPKeyFetcherParsesCoreJWKS(t *testing.T) { k := newKey(t, "abc") srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(map[string]any{"keys": []map[string]string{{"kid": "abc", "public_key": base64.StdEncoding.EncodeToString(k.pub)}}}) })) defer srv.Close() keys, err := HTTPKeyFetcher(srv.URL, nil)(context.Background()) if err != nil || !keys["abc"].Equal(k.pub) { t.Fatalf("keys=%v err=%v", keys, err) } bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`{"keys":[{"kid":"x","public_key":"AAAA"}]}`)) })) defer bad.Close() if _, err := HTTPKeyFetcher(bad.URL, nil)(context.Background()); err == nil { t.Error("ungueltige schluessellaenge muss abgelehnt werden") } } func handlerRecordingCaller(seen *string, ident *Identity) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { *seen = r.URL.Query().Get("caller") + "|" + r.Header.Get("X-User-Id") if id, ok := IdentityFrom(r.Context()); ok { *ident = id } }) } func TestAuthenticateOverridesClientSuppliedIdentity(t *testing.T) { k := newKey(t, "k1") var seen string var ident Identity h := Authenticate(Config{Verifier: NewVerifier(time.Minute, "acme", staticKeys(k)), Prefix: "/api/dms/v1"}, handlerRecordingCaller(&seen, &ident)) req := httptest.NewRequest("GET", "/api/dms/v1/documents/x/tags?caller=angreifer", nil) req.Header.Set("Authorization", "Bearer "+valid(k, "user-1", "acme")) req.Header.Set("X-User-Id", "angreifer") rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != 200 || seen != "user-1|user-1" || ident.UserID != "user-1" || ident.Tenant != "acme" { t.Fatalf("status=%d seen=%q ident=%+v", rec.Code, seen, ident) } } func TestAuthenticateRequiresTokenUnlessLegacyOrPublic(t *testing.T) { k := newKey(t, "k1") var seen string var ident Identity cfg := Config{Verifier: NewVerifier(time.Minute, "acme", staticKeys(k)), Prefix: "/api/dms/v1", Public: map[string]bool{"/openapi.json": true}} run := func(c Config, path, auth string) int { req := httptest.NewRequest("GET", path, nil) if auth != "" { req.Header.Set("Authorization", auth) } rec := httptest.NewRecorder() Authenticate(c, handlerRecordingCaller(&seen, &ident)).ServeHTTP(rec, req) return rec.Code } if c := run(cfg, "/api/dms/v1/search?caller=x", ""); c != 401 { t.Errorf("ohne token: %d", c) } if c := run(cfg, "/api/dms/v1/search", "Bearer muell"); c != 401 { t.Errorf("ungueltiges token: %d", c) } if c := run(cfg, "/api/dms/v1/openapi.json", ""); c != 200 { t.Errorf("oeffentlicher pfad: %d", c) } if c := run(cfg, "/healthz", ""); c != 200 { t.Errorf("ausserhalb des praefixes: %d", c) } legacy := cfg legacy.AllowCaller = true if c := run(legacy, "/api/dms/v1/search?caller=x", ""); c != 200 { t.Errorf("legacy ohne token: %d", c) } if c := run(legacy, "/api/dms/v1/search?caller=x", "Bearer muell"); c != 401 { t.Errorf("legacy mit ungueltigem token muss abgelehnt werden: %d", c) } if c := run(legacy, "/api/dms/v1/tags", ""); c != 401 { t.Errorf("legacy ohne token und ohne caller muss 401 liefern: %d", c) } callerOnly := Config{Prefix: "/api/dms/v1", AllowCaller: true, Public: cfg.Public} if c := run(callerOnly, "/api/dms/v1/tags?caller=x", ""); c != 200 { t.Errorf("uebergangsmodus ohne verifier mit caller: %d", c) } if c := run(callerOnly, "/api/dms/v1/tags", ""); c != 401 { t.Errorf("uebergangsmodus ohne verifier und ohne caller: %d", c) } if c := run(callerOnly, "/api/dms/v1/tags", "Bearer irgendwas"); c != 401 { t.Errorf("uebergangsmodus ohne verifier mit token muss abgelehnt werden statt abzustuerzen: %d", c) } } func TestAuthenticateCoreOutageWithoutKeysReturns503(t *testing.T) { k := newKey(t, "k1") cfg := Config{Verifier: NewVerifier(time.Minute, "acme", func(context.Context) (map[string]ed25519.PublicKey, error) { return nil, errors.New("down") }), Prefix: "/api/dms/v1"} req := httptest.NewRequest("GET", "/api/dms/v1/search", nil) req.Header.Set("Authorization", "Bearer "+valid(k, "u", "acme")) rec := httptest.NewRecorder() Authenticate(cfg, http.NotFoundHandler()).ServeHTTP(rec, req) if rec.Code != 503 { t.Fatalf("status = %d", rec.Code) } }