diff --git a/auth/auth.go b/auth/auth.go index 7cc0074a..acf442e0 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -17,8 +17,16 @@ import ( type TokenInfo struct { Scopes []string Expiration time.Time - // TODO: add standard JWT fields - Extra map[string]any + Extra map[string]any + + // Standard JWT fields + // See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1 + Issuer string `json:"iss,omitempty"` + Subject string `json:"sub,omitempty"` + Audience []string `json:"aud,omitempty"` + NotBefore time.Time `json:"nbf,omitempty"` + IssuedAt time.Time `json:"iat,omitempty"` + JWTID string `json:"jti,omitempty"` } // The error that a TokenVerifier should return if the token cannot be verified. diff --git a/auth/auth_test.go b/auth/auth_test.go index ef8ea7b3..e26baa48 100644 --- a/auth/auth_test.go +++ b/auth/auth_test.go @@ -8,6 +8,7 @@ import ( "context" "errors" "net/http" + "net/http/httptest" "testing" "time" ) @@ -76,3 +77,114 @@ func TestVerify(t *testing.T) { }) } } + +func TestRequireBearerToken_ClaimsTable(t *testing.T) { + issuedAt := time.Unix(1730000000, 0).UTC() + notBefore := issuedAt.Add(-time.Minute) + + verifier := func(_ context.Context, token string, _ *http.Request) (*TokenInfo, error) { + switch token { + case "claims": + return &TokenInfo{ + Scopes: []string{"s1"}, + Expiration: time.Now().Add(time.Hour), + Issuer: "https://issuer.example", + Subject: "user-123", + Audience: []string{"aud1", "aud2"}, + NotBefore: notBefore, + IssuedAt: issuedAt, + JWTID: "jwt-id-abc", + }, nil + case "claims-zero": + return &TokenInfo{Expiration: time.Now().Add(time.Hour)}, nil + default: + return nil, ErrInvalidToken + } + } + + for _, tt := range []struct { + name string + header string + checkFunc func(t *testing.T, ti *TokenInfo) + }{ + { + name: "claims present", + header: "Bearer claims", + checkFunc: func(t *testing.T, ti *TokenInfo) { + if ti == nil { + t.Fatalf("TokenInfo missing in context") + } + if ti.Issuer != "https://issuer.example" { + t.Fatalf("iss got %q", ti.Issuer) + } + if ti.Subject != "user-123" { + t.Fatalf("sub got %q", ti.Subject) + } + if len(ti.Audience) != 2 || ti.Audience[0] != "aud1" || ti.Audience[1] != "aud2" { + t.Fatalf("aud got %v", ti.Audience) + } + if ti.NotBefore.IsZero() { + t.Fatalf("nbf is zero") + } + if ti.IssuedAt.IsZero() { + t.Fatalf("iat is zero") + } + if ti.JWTID != "jwt-id-abc" { + t.Fatalf("jti got %q", ti.JWTID) + } + if ti.Expiration.IsZero() { + t.Fatalf("exp is zero") + } + }, + }, + { + name: "claims zero values (except exp)", + header: "Bearer claims-zero", + checkFunc: func(t *testing.T, ti *TokenInfo) { + if ti == nil { + t.Fatalf("TokenInfo missing in context") + } + if ti.Issuer != "" { + t.Fatalf("iss expected empty, got %q", ti.Issuer) + } + if ti.Subject != "" { + t.Fatalf("sub expected empty, got %q", ti.Subject) + } + if len(ti.Audience) != 0 { + t.Fatalf("aud expected empty, got %v", ti.Audience) + } + if !ti.NotBefore.IsZero() { + t.Fatalf("nbf expected zero, got %v", ti.NotBefore) + } + if !ti.IssuedAt.IsZero() { + t.Fatalf("iat expected zero, got %v", ti.IssuedAt) + } + if ti.JWTID != "" { + t.Fatalf("jti expected empty, got %q", ti.JWTID) + } + if ti.Expiration.IsZero() { + t.Fatalf("exp should be set for middleware to pass") + } + }, + }, + } { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("Authorization", tt.header) + rw := httptest.NewRecorder() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ti := TokenInfoFromContext(r.Context()) + // Run the provided check against the token info in context + tt.checkFunc(t, ti) + w.WriteHeader(http.StatusOK) + }) + + wrapped := RequireBearerToken(verifier, nil)(handler) + wrapped.ServeHTTP(rw, req) + if rw.Result().StatusCode != http.StatusOK { + t.Fatalf("unexpected status: %d", rw.Result().StatusCode) + } + }) + } +}