diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go index efccc2d..e27f718 100644 --- a/internal/auth/jwt.go +++ b/internal/auth/jwt.go @@ -139,12 +139,14 @@ func padBytes(b []byte, size int) []byte { var _ = (*big.Int)(nil) func (s *JWTService) Verify(tokenString string) (*Claims, error) { + // Pin ES256 explicitly via WithValidMethods — a bare *SigningMethodECDSA check + // would also accept ES384/ES512, and this closes any alg-confusion ambiguity. token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodECDSA); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } return s.publicKey, nil - }) + }, jwt.WithValidMethods([]string{"ES256"})) if err != nil { return nil, fmt.Errorf("parse token: %w", err) } @@ -154,6 +156,17 @@ func (s *JWTService) Verify(tokenString string) (*Claims, error) { return nil, fmt.Errorf("invalid token claims") } + // Issuer binding. Only enforced when an issuer is configured so deployments + // that never set one keep their existing behavior. When configured, reject + // any token whose `iss` claim doesn't match the service's issuer — this stops + // tokens minted by a different issuer from being accepted here. + if s.issuer != "" { + iss, _ := mapClaims["iss"].(string) + if iss != s.issuer { + return nil, fmt.Errorf("invalid issuer") + } + } + userID, _ := mapClaims["userId"].(float64) orgSlug, _ := mapClaims["orgSlug"].(string) projectName, _ := mapClaims["projectName"].(string) diff --git a/internal/auth/jwt_test.go b/internal/auth/jwt_test.go index 7126f44..211426c 100644 --- a/internal/auth/jwt_test.go +++ b/internal/auth/jwt_test.go @@ -8,8 +8,31 @@ import ( "encoding/pem" "testing" "time" + + "github.com/golang-jwt/jwt/v5" ) +// EXC-320: the verifier must pin ES256 and reject alg-confusion / alg:none. +func TestVerify_RejectsNonES256Algorithms(t *testing.T) { + svc, _ := NewJWTService(testKeyPEM(t), "excalibase", 3600) + claims := jwt.MapClaims{ + "sub": "u@test.com", "userId": 1, "projectId": "p", "iss": "excalibase", + "exp": time.Now().Add(time.Hour).Unix(), + } + + hs, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, claims). + SignedString([]byte("0123456789abcdef0123456789abcdef")) + if _, err := svc.Verify(hs); err == nil { + t.Fatal("expected HS256 token to be rejected by ES256-pinned verifier") + } + + none, _ := jwt.NewWithClaims(jwt.SigningMethodNone, claims). + SignedString(jwt.UnsafeAllowNoneSignatureType) + if _, err := svc.Verify(none); err == nil { + t.Fatal("expected alg:none token to be rejected") + } +} + func testKeyPEM(t *testing.T) string { t.Helper() priv, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) @@ -96,6 +119,44 @@ func TestWrongKey(t *testing.T) { func _ () { _ = time.Now() } // keep time import used +// A token signed by an issuer different from the verifier's configured issuer +// must be rejected. We sign with svcA (issuer "issuer-a") and verify with svcB +// (issuer "issuer-b") using the SAME key, so the signature is valid and only the +// issuer differs — isolating the issuer check from the signature check. +func TestVerify_RejectsWrongIssuer(t *testing.T) { + priv, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + b, _ := x509.MarshalECPrivateKey(priv) + keyPEM := string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: b})) + + svcA, _ := NewJWTService(keyPEM, "issuer-a", 3600) + svcB, _ := NewJWTService(keyPEM, "issuer-b", 3600) + + token, err := svcA.Sign(Claims{Sub: "u@test.com", UserID: 1, ProjectID: "p"}) + if err != nil { + t.Fatalf("Sign: %v", err) + } + if _, err := svcB.Verify(token); err == nil { + t.Fatal("expected error verifying a token with a mismatched issuer") + } +} + +// When no issuer is configured (empty string), the issuer check must be skipped +// so deployments that never set an issuer keep working. +func TestVerify_SkipsIssuerCheckWhenUnconfigured(t *testing.T) { + priv, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + b, _ := x509.MarshalECPrivateKey(priv) + keyPEM := string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: b})) + + // Signer stamps iss="something"; verifier has empty issuer → must accept. + signer, _ := NewJWTService(keyPEM, "something", 3600) + verifier, _ := NewJWTService(keyPEM, "", 3600) + + token, _ := signer.Sign(Claims{Sub: "u@test.com", UserID: 1, ProjectID: "p"}) + if _, err := verifier.Verify(token); err != nil { + t.Fatalf("expected token to verify when issuer unconfigured, got %v", err) + } +} + // --- Phase 3: scope + keyId round-trip --- // A Claims round-trip must carry scope and keyId untouched when they are set diff --git a/internal/handler/apikey.go b/internal/handler/apikey.go index 8cc7821..e317fbd 100644 --- a/internal/handler/apikey.go +++ b/internal/handler/apikey.go @@ -23,6 +23,14 @@ func (h *AuthHandler) APIKeyRoutes(r chi.Router) { r.Delete("/{id}", h.RevokeAPIKey) } +// canManageAPIKeys reports whether the token scope is permitted to manage api +// keys. Publishable / browser ("public") tokens are read-only credentials for +// edge traffic and must never create, list, or revoke keys. Only first-party +// authenticated users and trusted service tokens may. +func canManageAPIKeys(scope string) bool { + return scope == "authenticated" || scope == "service" +} + // CreateAPIKey generates a new api key for the project, stores its hash, and // returns the plaintext exactly once. func (h *AuthHandler) CreateAPIKey(w http.ResponseWriter, r *http.Request) { @@ -32,6 +40,14 @@ func (h *AuthHandler) CreateAPIKey(w http.ResponseWriter, r *http.Request) { httpError(w, "missing claims", 401) return } + if claims.ProjectID != projectID { + httpError(w, "token project mismatch", http.StatusForbidden) + return + } + if !canManageAPIKeys(claims.Scope) { + httpError(w, "insufficient scope to manage api keys", http.StatusForbidden) + return + } var req domain.CreateAPIKeyRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -85,10 +101,19 @@ func (h *AuthHandler) CreateAPIKey(w http.ResponseWriter, r *http.Request) { // plaintext are never included in the response. func (h *AuthHandler) ListAPIKeys(w http.ResponseWriter, r *http.Request) { projectID := projectKey(r) - if middleware.ClaimsFromContext(r.Context()) == nil { + claims := middleware.ClaimsFromContext(r.Context()) + if claims == nil { httpError(w, "missing claims", 401) return } + if claims.ProjectID != projectID { + httpError(w, "token project mismatch", http.StatusForbidden) + return + } + if !canManageAPIKeys(claims.Scope) { + httpError(w, "insufficient scope to manage api keys", http.StatusForbidden) + return + } pool, err := h.poolMgr.GetPool(r.Context(), chi.URLParam(r, "orgSlug"), projectID) if err != nil { @@ -137,6 +162,14 @@ func (h *AuthHandler) RevokeAPIKey(w http.ResponseWriter, r *http.Request) { httpError(w, "missing claims", 401) return } + if claims.ProjectID != projectID { + httpError(w, "token project mismatch", http.StatusForbidden) + return + } + if !canManageAPIKeys(claims.Scope) { + httpError(w, "insufficient scope to manage api keys", http.StatusForbidden) + return + } idStr := chi.URLParam(r, "id") id, err := strconv.ParseInt(idStr, 10, 64) diff --git a/internal/handler/apikey_authz_test.go b/internal/handler/apikey_authz_test.go new file mode 100644 index 0000000..9e3ff75 --- /dev/null +++ b/internal/handler/apikey_authz_test.go @@ -0,0 +1,229 @@ +package handler + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/json" + "encoding/pem" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/excalibase/auth/internal/auth" + "github.com/excalibase/auth/internal/pool" + "github.com/go-chi/chi/v5" +) + +// setupAuthzRouter builds the same router as setupUnitRouter but also returns the +// JWTService so tests can mint valid tokens for specific projects/scopes. The +// pool manager points at an unreachable vault, so a request that survives the +// authz checks lands on the DB layer and yields 503 — letting us distinguish +// "passed authorization" (503) from "blocked by authorization" (403). +func setupAuthzRouter(t *testing.T) (chi.Router, *auth.JWTService) { + t.Helper() + priv, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + b, _ := x509.MarshalECPrivateKey(priv) + keyPEM := string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: b})) + + jwtSvc, _ := auth.NewJWTService(keyPEM, "excalibase", 3600) + mgr := pool.NewManager("http://127.0.0.1:1", "fake-pat", time.Hour) + h := NewAuthHandler(mgr, jwtSvc, 900, 604800) + + r := chi.NewRouter() + r.Route("/auth", h.Routes) + return r, jwtSvc +} + +func mintToken(t *testing.T, svc *auth.JWTService, projectID, scope string) string { + t.Helper() + tok, err := svc.Sign(auth.Claims{ + Sub: "alice@test.com", + UserID: 7, + ProjectID: projectID, + Role: "user", + Scope: scope, + }) + if err != nil { + t.Fatalf("Sign: %v", err) + } + return tok +} + +// (a) A token whose ProjectID matches the URL project must pass the authz checks +// and reach the DB layer (503 here, since the test vault is unreachable). It must +// NOT be rejected with 401/403. +func TestAPIKey_Create_SameProjectPassesAuthz(t *testing.T) { + r, svc := setupAuthzRouter(t) + token := mintToken(t, svc, "test-project", "authenticated") + + body := `{"name":"ci","keyType":"publishable"}` + req := httptest.NewRequest("POST", "/auth/test-org/test-project/api-keys/", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code == 401 || w.Code == 403 { + t.Fatalf("same-project authenticated token must pass authz, got %d", w.Code) + } + if w.Code != 503 { + t.Errorf("expected 503 (authz passed, DB unreachable), got %d", w.Code) + } +} + +// (b) A token for project A calling project B's URL must be forbidden. +func TestAPIKey_Create_CrossProjectForbidden(t *testing.T) { + r, svc := setupAuthzRouter(t) + token := mintToken(t, svc, "project-a", "authenticated") + + body := `{"name":"ci","keyType":"publishable"}` + req := httptest.NewRequest("POST", "/auth/test-org/project-b/api-keys/", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != 403 { + t.Fatalf("cross-project token must be 403, got %d", w.Code) + } + assertErrorContains(t, w.Body.Bytes(), "token project mismatch") +} + +func TestAPIKey_List_CrossProjectForbidden(t *testing.T) { + r, svc := setupAuthzRouter(t) + token := mintToken(t, svc, "project-a", "authenticated") + + req := httptest.NewRequest("GET", "/auth/test-org/project-b/api-keys/", nil) + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != 403 { + t.Fatalf("cross-project list must be 403, got %d", w.Code) + } +} + +func TestAPIKey_Revoke_CrossProjectForbidden(t *testing.T) { + r, svc := setupAuthzRouter(t) + token := mintToken(t, svc, "project-a", "authenticated") + + req := httptest.NewRequest("DELETE", "/auth/test-org/project-b/api-keys/42", nil) + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != 403 { + t.Fatalf("cross-project revoke must be 403, got %d", w.Code) + } +} + +// (c) A public-scope (publishable / browser) token must not manage api keys even +// for its own project. +func TestAPIKey_Create_PublicScopeForbidden(t *testing.T) { + r, svc := setupAuthzRouter(t) + token := mintToken(t, svc, "test-project", "public") + + body := `{"name":"ci","keyType":"publishable"}` + req := httptest.NewRequest("POST", "/auth/test-org/test-project/api-keys/", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != 403 { + t.Fatalf("public-scope token must be 403 on CreateAPIKey, got %d", w.Code) + } + assertErrorContains(t, w.Body.Bytes(), "scope") +} + +func TestAPIKey_List_PublicScopeForbidden(t *testing.T) { + r, svc := setupAuthzRouter(t) + token := mintToken(t, svc, "test-project", "public") + + req := httptest.NewRequest("GET", "/auth/test-org/test-project/api-keys/", nil) + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != 403 { + t.Fatalf("public-scope token must be 403 on ListAPIKeys, got %d", w.Code) + } +} + +// A service-scope token (secret key) is allowed to manage keys for its project. +func TestAPIKey_Create_ServiceScopeSameProjectPassesAuthz(t *testing.T) { + r, svc := setupAuthzRouter(t) + token := mintToken(t, svc, "test-project", "service") + + body := `{"name":"ci","keyType":"secret"}` + req := httptest.NewRequest("POST", "/auth/test-org/test-project/api-keys/", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code == 401 || w.Code == 403 { + t.Fatalf("service-scope same-project token must pass authz, got %d", w.Code) + } +} + +// (d) /validate with a token for project A against project B must report not-valid. +func TestValidate_CrossProjectNotValid(t *testing.T) { + r, svc := setupAuthzRouter(t) + token := mintToken(t, svc, "project-a", "authenticated") + + body := `{"token":"` + token + `"}` + req := httptest.NewRequest("POST", "/auth/test-org/project-b/validate", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("validate returns 200 with valid=false body, got %d", w.Code) + } + var out map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if valid, _ := out["valid"].(bool); valid { + t.Fatalf("cross-project token must be valid=false, got %v", out) + } +} + +// /validate with a matching-project token keeps the success shape. +func TestValidate_SameProjectStillValid(t *testing.T) { + r, svc := setupAuthzRouter(t) + token := mintToken(t, svc, "test-project", "authenticated") + + body := `{"token":"` + token + `"}` + req := httptest.NewRequest("POST", "/auth/test-org/test-project/validate", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + var out map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if valid, _ := out["valid"].(bool); !valid { + t.Fatalf("same-project token must be valid=true, got %v", out) + } + if out["projectId"] != "test-project" { + t.Errorf("projectId in response: got %v, want test-project", out["projectId"]) + } +} + +func assertErrorContains(t *testing.T, body []byte, want string) { + t.Helper() + var out map[string]interface{} + if err := json.Unmarshal(body, &out); err != nil { + t.Fatalf("unmarshal error body: %v", err) + } + msg, _ := out["error"].(string) + if !strings.Contains(msg, want) { + t.Errorf("error body %q should contain %q", msg, want) + } +} diff --git a/internal/handler/auth.go b/internal/handler/auth.go index 9db10a0..9375549 100644 --- a/internal/handler/auth.go +++ b/internal/handler/auth.go @@ -197,6 +197,14 @@ func (h *AuthHandler) Validate(w http.ResponseWriter, r *http.Request) { return } + // Bind the token to the project in the URL. A signature-valid token for + // project A must not validate against project B's endpoint — otherwise + // /validate becomes a cross-project oracle. + if claims.ProjectID != projectKey(r) { + writeJSON(w, map[string]interface{}{"valid": false, "error": "token project mismatch"}) + return + } + writeJSON(w, map[string]interface{}{ "valid": true, "email": claims.Sub,