Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion internal/auth/jwt.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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)
Expand Down
61 changes: 61 additions & 0 deletions internal/auth/jwt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
35 changes: 34 additions & 1 deletion internal/handler/apikey.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading