|
|
@@ -0,0 +1,278 @@
|
|
|
+package authd
|
|
|
+
|
|
|
+import (
|
|
|
+ "context"
|
|
|
+ "net/http"
|
|
|
+ "net/http/httptest"
|
|
|
+ "strings"
|
|
|
+ "testing"
|
|
|
+ "time"
|
|
|
+
|
|
|
+ "github.com/golang-jwt/jwt/v5"
|
|
|
+)
|
|
|
+
|
|
|
+func newTestVerifier(t *testing.T) *Authd {
|
|
|
+ t.Helper()
|
|
|
+ cfg := DefaultConfig()
|
|
|
+ cfg.JWTSecret = []byte("test-secret-with-32-bytes-min-len-abc")
|
|
|
+ cfg.BcryptCost = 4
|
|
|
+ a, err := New(nil, cfg)
|
|
|
+ if err != nil {
|
|
|
+ t.Fatalf("new: %v", err)
|
|
|
+ }
|
|
|
+ return a
|
|
|
+}
|
|
|
+
|
|
|
+func mintToken(t *testing.T, a *Authd, user *User) string {
|
|
|
+ t.Helper()
|
|
|
+ tok, _, _, err := a.mintAccessToken(user)
|
|
|
+ if err != nil {
|
|
|
+ t.Fatalf("mint: %v", err)
|
|
|
+ }
|
|
|
+ return tok
|
|
|
+}
|
|
|
+
|
|
|
+func TestRequireAuth_NoHeader(t *testing.T) {
|
|
|
+ a := newTestVerifier(t)
|
|
|
+ called := false
|
|
|
+ h := a.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
+ called = true
|
|
|
+ }))
|
|
|
+ rr := httptest.NewRecorder()
|
|
|
+ h.ServeHTTP(rr, httptest.NewRequest("GET", "/", nil))
|
|
|
+ if rr.Code != http.StatusUnauthorized {
|
|
|
+ t.Errorf("status = %d, want 401", rr.Code)
|
|
|
+ }
|
|
|
+ if called {
|
|
|
+ t.Error("downstream handler was called despite missing header")
|
|
|
+ }
|
|
|
+ if !strings.Contains(rr.Body.String(), "missing_bearer") {
|
|
|
+ t.Errorf("body = %q, want it to contain 'missing_bearer'", rr.Body.String())
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func TestRequireAuth_BadScheme(t *testing.T) {
|
|
|
+ a := newTestVerifier(t)
|
|
|
+ called := false
|
|
|
+ h := a.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
+ called = true
|
|
|
+ }))
|
|
|
+ rr := httptest.NewRecorder()
|
|
|
+ req := httptest.NewRequest("GET", "/", nil)
|
|
|
+ req.Header.Set("Authorization", "Basic dXNlcjpwYXNz")
|
|
|
+ h.ServeHTTP(rr, req)
|
|
|
+ if rr.Code != http.StatusUnauthorized {
|
|
|
+ t.Errorf("status = %d, want 401", rr.Code)
|
|
|
+ }
|
|
|
+ if called {
|
|
|
+ t.Error("handler called with non-Bearer scheme")
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func TestRequireAuth_BadToken(t *testing.T) {
|
|
|
+ a := newTestVerifier(t)
|
|
|
+ called := false
|
|
|
+ h := a.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
+ called = true
|
|
|
+ }))
|
|
|
+ rr := httptest.NewRecorder()
|
|
|
+ req := httptest.NewRequest("GET", "/", nil)
|
|
|
+ req.Header.Set("Authorization", "Bearer not-a-jwt")
|
|
|
+ h.ServeHTTP(rr, req)
|
|
|
+ if rr.Code != http.StatusUnauthorized {
|
|
|
+ t.Errorf("status = %d, want 401", rr.Code)
|
|
|
+ }
|
|
|
+ if called {
|
|
|
+ t.Error("handler called with bad token")
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func TestRequireAuth_Expired(t *testing.T) {
|
|
|
+ cfg := DefaultConfig()
|
|
|
+ cfg.JWTSecret = []byte("test-secret-with-32-bytes-min-len-abc")
|
|
|
+ cfg.AccessTokenTTL = -1 * time.Minute
|
|
|
+ a, _ := New(nil, cfg)
|
|
|
+ tok, _, _, _ := a.mintAccessToken(&User{ID: "u-1", Status: "active"})
|
|
|
+
|
|
|
+ called := false
|
|
|
+ h := a.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
+ called = true
|
|
|
+ }))
|
|
|
+ rr := httptest.NewRecorder()
|
|
|
+ req := httptest.NewRequest("GET", "/", nil)
|
|
|
+ req.Header.Set("Authorization", "Bearer "+tok)
|
|
|
+ h.ServeHTTP(rr, req)
|
|
|
+ if rr.Code != http.StatusUnauthorized {
|
|
|
+ t.Errorf("expired token: status = %d, want 401", rr.Code)
|
|
|
+ }
|
|
|
+ if called {
|
|
|
+ t.Error("handler called with expired token")
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func TestRequireAuth_Valid(t *testing.T) {
|
|
|
+ a := newTestVerifier(t)
|
|
|
+ tok := mintToken(t, a, &User{ID: "u-1", TenantID: "t-1", Role: "tenant_admin", Status: "active"})
|
|
|
+
|
|
|
+ var gotClaims *AccessClaims
|
|
|
+ called := false
|
|
|
+ h := a.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
+ called = true
|
|
|
+ gotClaims = ClaimsFromContext(r.Context())
|
|
|
+ w.WriteHeader(http.StatusOK)
|
|
|
+ }))
|
|
|
+ rr := httptest.NewRecorder()
|
|
|
+ req := httptest.NewRequest("GET", "/", nil)
|
|
|
+ req.Header.Set("Authorization", "Bearer "+tok)
|
|
|
+ h.ServeHTTP(rr, req)
|
|
|
+ if rr.Code != http.StatusOK {
|
|
|
+ t.Errorf("status = %d, want 200", rr.Code)
|
|
|
+ }
|
|
|
+ if !called {
|
|
|
+ t.Error("handler not called with valid token")
|
|
|
+ }
|
|
|
+ if gotClaims == nil {
|
|
|
+ t.Fatal("claims not in context")
|
|
|
+ }
|
|
|
+ if gotClaims.UserID != "u-1" {
|
|
|
+ t.Errorf("UserID = %q, want u-1", gotClaims.UserID)
|
|
|
+ }
|
|
|
+ if gotClaims.TenantID != "t-1" {
|
|
|
+ t.Errorf("TenantID = %q, want t-1", gotClaims.TenantID)
|
|
|
+ }
|
|
|
+ if gotClaims.Role != "tenant_admin" {
|
|
|
+ t.Errorf("Role = %q, want tenant_admin", gotClaims.Role)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func TestRequireAuth_AlgNone(t *testing.T) {
|
|
|
+ // Forge a token signed with alg=none, claim super_admin. The
|
|
|
+ // middleware must reject it.
|
|
|
+ a := newTestVerifier(t)
|
|
|
+ claims := AccessClaims{
|
|
|
+ UserID: "u-evil",
|
|
|
+ Role: "super_admin",
|
|
|
+ TokenType: "access",
|
|
|
+ RegisteredClaims: jwt.RegisteredClaims{
|
|
|
+ Issuer: "broad-announce",
|
|
|
+ Subject: "u-evil",
|
|
|
+ ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
|
|
+ },
|
|
|
+ }
|
|
|
+ t1 := jwt.NewWithClaims(jwt.SigningMethodNone, claims)
|
|
|
+ noneToken, err := t1.SignedString(jwt.UnsafeAllowNoneSignatureType)
|
|
|
+ if err != nil {
|
|
|
+ t.Fatalf("sign none: %v", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ called := false
|
|
|
+ h := a.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
+ called = true
|
|
|
+ }))
|
|
|
+ rr := httptest.NewRecorder()
|
|
|
+ req := httptest.NewRequest("GET", "/", nil)
|
|
|
+ req.Header.Set("Authorization", "Bearer "+noneToken)
|
|
|
+ h.ServeHTTP(rr, req)
|
|
|
+ if rr.Code != http.StatusUnauthorized {
|
|
|
+ t.Errorf("alg=none: status = %d, want 401", rr.Code)
|
|
|
+ }
|
|
|
+ if called {
|
|
|
+ t.Error("handler called with alg=none token (CRITICAL)")
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func TestRequireRole_Allowed(t *testing.T) {
|
|
|
+ a := newTestVerifier(t)
|
|
|
+ tok := mintToken(t, a, &User{ID: "u-1", Role: "super_admin", Status: "active"})
|
|
|
+
|
|
|
+ called := false
|
|
|
+ h := a.RequireRole("super_admin", "tenant_admin")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
+ called = true
|
|
|
+ w.WriteHeader(http.StatusOK)
|
|
|
+ }))
|
|
|
+ rr := httptest.NewRecorder()
|
|
|
+ req := httptest.NewRequest("GET", "/", nil)
|
|
|
+ req.Header.Set("Authorization", "Bearer "+tok)
|
|
|
+ h.ServeHTTP(rr, req)
|
|
|
+ if rr.Code != http.StatusOK {
|
|
|
+ t.Errorf("status = %d, want 200", rr.Code)
|
|
|
+ }
|
|
|
+ if !called {
|
|
|
+ t.Error("handler not called for allowed role")
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func TestRequireRole_Forbidden(t *testing.T) {
|
|
|
+ a := newTestVerifier(t)
|
|
|
+ tok := mintToken(t, a, &User{ID: "u-1", Role: "viewer", Status: "active"})
|
|
|
+
|
|
|
+ called := false
|
|
|
+ h := a.RequireRole("super_admin")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
+ called = true
|
|
|
+ }))
|
|
|
+ rr := httptest.NewRecorder()
|
|
|
+ req := httptest.NewRequest("GET", "/", nil)
|
|
|
+ req.Header.Set("Authorization", "Bearer "+tok)
|
|
|
+ h.ServeHTTP(rr, req)
|
|
|
+ if rr.Code != http.StatusForbidden {
|
|
|
+ t.Errorf("status = %d, want 403", rr.Code)
|
|
|
+ }
|
|
|
+ if called {
|
|
|
+ t.Error("handler called for forbidden role")
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func TestRequireRole_NoToken(t *testing.T) {
|
|
|
+ a := newTestVerifier(t)
|
|
|
+ called := false
|
|
|
+ h := a.RequireRole("super_admin")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
+ called = true
|
|
|
+ }))
|
|
|
+ rr := httptest.NewRecorder()
|
|
|
+ h.ServeHTTP(rr, httptest.NewRequest("GET", "/", nil))
|
|
|
+ if rr.Code != http.StatusUnauthorized {
|
|
|
+ t.Errorf("status = %d, want 401 (no token wins over forbidden)", rr.Code)
|
|
|
+ }
|
|
|
+ if called {
|
|
|
+ t.Error("handler called with no token")
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func TestClaimsFromContext_Empty(t *testing.T) {
|
|
|
+ if c := ClaimsFromContext(context.Background()); c != nil {
|
|
|
+ t.Errorf("expected nil for empty ctx, got %+v", c)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func TestBearerFromRequest_CaseInsensitive(t *testing.T) {
|
|
|
+ tests := []struct {
|
|
|
+ header string
|
|
|
+ want string
|
|
|
+ wantErr bool
|
|
|
+ }{
|
|
|
+ {"Bearer abc", "abc", false},
|
|
|
+ {"bearer abc", "abc", false},
|
|
|
+ {"BEARER abc", "abc", false},
|
|
|
+ {"Bearer abc", "abc", false}, // double space tolerated
|
|
|
+ {"Bearer\tabc", "abc", false}, // tab tolerated
|
|
|
+ {"Basic abc", "", true},
|
|
|
+ {"", "", true},
|
|
|
+ {"Bearer", "", true},
|
|
|
+ {"Bearer ", "", true},
|
|
|
+ }
|
|
|
+ for _, tt := range tests {
|
|
|
+ t.Run(tt.header, func(t *testing.T) {
|
|
|
+ r := httptest.NewRequest("GET", "/", nil)
|
|
|
+ if tt.header != "" {
|
|
|
+ r.Header.Set("Authorization", tt.header)
|
|
|
+ }
|
|
|
+ got, err := bearerFromRequest(r)
|
|
|
+ if (err != nil) != tt.wantErr {
|
|
|
+ t.Errorf("err = %v, wantErr %v", err, tt.wantErr)
|
|
|
+ }
|
|
|
+ if got != tt.want {
|
|
|
+ t.Errorf("got = %q, want %q", got, tt.want)
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+}
|