diff --git a/backend/internal/auth/auth_test.go b/backend/internal/auth/auth_test.go index 542ab68..a923eca 100644 --- a/backend/internal/auth/auth_test.go +++ b/backend/internal/auth/auth_test.go @@ -3,6 +3,9 @@ package auth import ( "encoding/base64" "encoding/json" + "net/http" + "net/http/httptest" + "strings" "testing" "Noooste/garage-ui/internal/config" @@ -96,3 +99,458 @@ func TestGenerateSessionToken_ZeroSessionMaxAge_IsNotImmediatelyExpired(t *testi t.Fatalf("freshly issued admin session token failed validation: %v", err) } } + +// --------------------------------------------------------------------------- +// Task 5: ValidateBasicAuth +// (ParseBasicAuth was removed from production in commit d0040be; nothing to test.) +// --------------------------------------------------------------------------- + +func TestValidateBasicAuth(t *testing.T) { + svc := &Service{ + authConfig: &config.AuthConfig{ + Admin: config.AdminAuthConfig{ + Enabled: true, + Username: "admin", + Password: "correct-horse", + }, + }, + } + + tests := []struct { + name string + user string + pass string + want bool + }{ + {"correct credentials", "admin", "correct-horse", true}, + {"wrong password", "admin", "nope", false}, + {"wrong username", "root", "correct-horse", false}, + {"both wrong", "x", "y", false}, + {"empty username", "", "correct-horse", false}, + {"empty password", "admin", "", false}, + {"both empty", "", "", false}, + {"username prefix attack", "admi", "correct-horse", false}, + {"password prefix attack", "admin", "correct-hors", false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := svc.ValidateBasicAuth(tc.user, tc.pass); got != tc.want { + t.Errorf("ValidateBasicAuth(%q,%q) = %v, want %v", tc.user, tc.pass, got, tc.want) + } + }) + } +} + +func TestValidateBasicAuth_AdminDisabledStillComparesAgainstEmpty(t *testing.T) { + // When admin is disabled, the configured username/password are typically + // empty strings. ValidateBasicAuth itself does not gate on Enabled (that + // happens in middleware). Pin that behavior so a future refactor can't + // silently change semantics. + svc := &Service{ + authConfig: &config.AuthConfig{ + Admin: config.AdminAuthConfig{Enabled: false}, + }, + } + if !svc.ValidateBasicAuth("", "") { + t.Error("empty creds should match empty configured creds") + } + if svc.ValidateBasicAuth("anything", "") { + t.Error("non-empty user should not match empty configured user") + } +} + +// --------------------------------------------------------------------------- +// Task 6: OIDC initOIDC cases +// --------------------------------------------------------------------------- + +// newDiscoveryServer returns an httptest.Server that serves a minimal but +// valid OIDC discovery document and a JWKS endpoint (empty key set is fine +// for init — we are not verifying any token here). The discovery document's +// `issuer` field MUST equal the server's URL or oidc.NewProvider rejects it. +func newDiscoveryServer(t *testing.T) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + var srv *httptest.Server + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { + doc := map[string]any{ + "issuer": srv.URL, + "authorization_endpoint": srv.URL + "/auth", + "token_endpoint": srv.URL + "/token", + "jwks_uri": srv.URL + "/jwks", + "userinfo_endpoint": srv.URL + "/userinfo", + "id_token_signing_alg_values_supported": []string{"RS256", "EdDSA"}, + "response_types_supported": []string{"code"}, + "subject_types_supported": []string{"public"}, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(doc) + }) + mux.HandleFunc("/jwks", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"keys":[]}`)) + }) + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +func TestNewAuthService_OIDCDisabled_DoesNotInitProvider(t *testing.T) { + svc, err := NewAuthService( + &config.AuthConfig{ + Admin: config.AdminAuthConfig{Enabled: true, Username: "u", Password: "p"}, + OIDC: config.OIDCConfig{Enabled: false}, + }, + &config.ServerConfig{}, + ) + if err != nil { + t.Fatalf("NewAuthService: %v", err) + } + if svc.oidcProvider != nil { + t.Error("oidcProvider should be nil when OIDC disabled") + } + if svc.oidcVerifier != nil { + t.Error("oidcVerifier should be nil when OIDC disabled") + } + if svc.oauth2Config != nil { + t.Error("oauth2Config should be nil when OIDC disabled") + } + if svc.jwtService == nil { + t.Error("jwtService must always be initialized") + } +} + +func TestNewAuthService_OIDCEnabled_DiscoversProvider(t *testing.T) { + disco := newDiscoveryServer(t) + + authCfg := &config.AuthConfig{ + OIDC: config.OIDCConfig{ + Enabled: true, + ClientID: "test-client", + IssuerURL: disco.URL, + Scopes: []string{"openid", "profile"}, + AdminRole: "admin", + }, + } + srvCfg := &config.ServerConfig{ + RootURL: "https://garage-ui.example", + } + + svc, err := NewAuthService(authCfg, srvCfg) + if err != nil { + t.Fatalf("NewAuthService: %v", err) + } + if svc.oidcProvider == nil { + t.Fatal("oidcProvider not initialized") + } + if svc.oidcVerifier == nil { + t.Fatal("oidcVerifier not initialized") + } + if svc.oauth2Config == nil { + t.Fatal("oauth2Config not initialized") + } + if svc.oauth2Config.ClientID != "test-client" { + t.Errorf("ClientID = %q, want test-client", svc.oauth2Config.ClientID) + } + wantRedirect := "https://garage-ui.example/auth/oidc/callback" + if svc.oauth2Config.RedirectURL != wantRedirect { + t.Errorf("RedirectURL = %q, want %q", svc.oauth2Config.RedirectURL, wantRedirect) + } + if len(svc.oauth2Config.Scopes) != 2 { + t.Errorf("Scopes length = %d, want 2", len(svc.oauth2Config.Scopes)) + } + // Endpoint should be wired from the discovery doc. + if svc.oauth2Config.Endpoint.AuthURL != disco.URL+"/auth" { + t.Errorf("Endpoint.AuthURL = %q", svc.oauth2Config.Endpoint.AuthURL) + } + if svc.oauth2Config.Endpoint.TokenURL != disco.URL+"/token" { + t.Errorf("Endpoint.TokenURL = %q", svc.oauth2Config.Endpoint.TokenURL) + } +} + +func TestNewAuthService_OIDCEnabled_BadIssuerURLReturnsError(t *testing.T) { + authCfg := &config.AuthConfig{ + OIDC: config.OIDCConfig{ + Enabled: true, + ClientID: "test-client", + IssuerURL: "http://127.0.0.1:1", // refused — nothing listens on port 1 + Scopes: []string{"openid"}, + AdminRole: "admin", + }, + } + srvCfg := &config.ServerConfig{RootURL: "https://garage-ui.example"} + + _, err := NewAuthService(authCfg, srvCfg) + if err == nil { + t.Fatal("expected error for unreachable issuer, got nil") + } + if !strings.Contains(err.Error(), "failed to initialize OIDC") { + t.Errorf("expected wrapping error, got %v", err) + } +} + +func TestGetAuthorizationURL_OIDCDisabledReturnsError(t *testing.T) { + svc := &Service{authConfig: &config.AuthConfig{}, serverConfig: &config.ServerConfig{}} + if _, err := svc.GetAuthorizationURL("state-x"); err == nil { + t.Error("expected error when OIDC not initialized") + } +} + +func TestGetAuthorizationURL_OIDCEnabledIncludesState(t *testing.T) { + disco := newDiscoveryServer(t) + svc, err := NewAuthService( + &config.AuthConfig{ + OIDC: config.OIDCConfig{ + Enabled: true, + ClientID: "test-client", + IssuerURL: disco.URL, + Scopes: []string{"openid"}, + AdminRole: "admin", + }, + }, + &config.ServerConfig{RootURL: "https://garage-ui.example"}, + ) + if err != nil { + t.Fatalf("NewAuthService: %v", err) + } + + url, err := svc.GetAuthorizationURL("my-state-token") + if err != nil { + t.Fatalf("GetAuthorizationURL: %v", err) + } + if !strings.Contains(url, "state=my-state-token") { + t.Errorf("URL missing state param: %s", url) + } + if !strings.Contains(url, "client_id=test-client") { + t.Errorf("URL missing client_id: %s", url) + } + if !strings.Contains(url, "redirect_uri=") { + t.Errorf("URL missing redirect_uri: %s", url) + } +} + +// --------------------------------------------------------------------------- +// Task 7: ValidateSessionToken and expanded ExtractRolesFromAccessToken +// --------------------------------------------------------------------------- + +// newServiceWithJWT wires a Service with a real JWTService so the session +// helpers can be exercised end-to-end. OIDC is left disabled. +func newServiceWithJWT(t *testing.T) *Service { + t.Helper() + jwtSvc, err := NewJWTService() + if err != nil { + t.Fatalf("NewJWTService: %v", err) + } + return &Service{ + authConfig: &config.AuthConfig{}, + serverConfig: &config.ServerConfig{}, + jwtService: jwtSvc, + } +} + +func TestValidateSessionToken_HappyPath(t *testing.T) { + svc := newServiceWithJWT(t) + user := &UserInfo{ + Username: "alice", + Email: "alice@example.com", + Name: "Alice", + Roles: []string{"admin"}, + } + tok, err := svc.GenerateSessionToken(user) + if err != nil { + t.Fatalf("GenerateSessionToken: %v", err) + } + got, err := svc.ValidateSessionToken(tok) + if err != nil { + t.Fatalf("ValidateSessionToken: %v", err) + } + if got.Username != user.Username || got.Email != user.Email || got.Name != user.Name { + t.Errorf("got %+v, want %+v", got, user) + } + if len(got.Roles) != 1 || got.Roles[0] != "admin" { + t.Errorf("Roles = %v, want [admin]", got.Roles) + } +} + +func TestValidateSessionToken_Expired(t *testing.T) { + svc := newServiceWithJWT(t) + // Bypass GenerateSessionToken's "fall back to 24h on non-positive" guard + // by going straight through the JWT service with a negative TTL. + tok, err := svc.jwtService.GenerateToken(&UserInfo{Username: "a"}, -1) + if err != nil { + t.Fatalf("GenerateToken: %v", err) + } + if _, err := svc.ValidateSessionToken(tok); err == nil { + t.Error("expected expired-token error, got nil") + } +} + +func TestValidateSessionToken_BadSignatureRejected(t *testing.T) { + signer := newServiceWithJWT(t) + verifier := newServiceWithJWT(t) + tok, err := signer.GenerateSessionToken(&UserInfo{Username: "a"}) + if err != nil { + t.Fatalf("GenerateSessionToken: %v", err) + } + if _, err := verifier.ValidateSessionToken(tok); err == nil { + t.Error("expected signature-mismatch error, got nil") + } +} + +func TestValidateSessionToken_EmptyTokenRejected(t *testing.T) { + svc := newServiceWithJWT(t) + if _, err := svc.ValidateSessionToken(""); err == nil { + t.Error("expected error for empty token, got nil") + } +} + +// makeAccessToken builds a JWT-shaped string with arbitrary claims. The +// signature segment is junk because ExtractRolesFromAccessToken does NOT +// verify the signature (per the doc-comment: "obtained via a verified code +// exchange, so parsing without re-verifying is safe"). +func makeAccessToken(t *testing.T, claims map[string]any) string { + t.Helper() + raw, err := json.Marshal(claims) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return "header." + base64.RawURLEncoding.EncodeToString(raw) + ".sig" +} + +func TestExtractRolesFromAccessToken_DeeplyNestedPath(t *testing.T) { + tok := makeAccessToken(t, map[string]any{ + "a": map[string]any{ + "b": map[string]any{ + "c": map[string]any{ + "roles": []any{"r1", "r2", "r3"}, + }, + }, + }, + }) + svc := &Service{ + authConfig: &config.AuthConfig{ + OIDC: config.OIDCConfig{RoleAttributePath: "a.b.c.roles"}, + }, + } + got := svc.ExtractRolesFromAccessToken(tok) + if len(got) != 3 || got[0] != "r1" || got[2] != "r3" { + t.Errorf("got %v, want [r1 r2 r3]", got) + } +} + +func TestExtractRolesFromAccessToken_MixedTypeArrayDropsNonStrings(t *testing.T) { + tok := makeAccessToken(t, map[string]any{ + "roles": []any{"admin", 42, "viewer", true, nil, "writer"}, + }) + svc := &Service{ + authConfig: &config.AuthConfig{ + OIDC: config.OIDCConfig{RoleAttributePath: "roles"}, + }, + } + got := svc.ExtractRolesFromAccessToken(tok) + want := []string{"admin", "viewer", "writer"} + if len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("got[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +func TestExtractRolesFromAccessToken_EmptyPathReturnsNil(t *testing.T) { + tok := makeAccessToken(t, map[string]any{"roles": []any{"admin"}}) + svc := &Service{ + authConfig: &config.AuthConfig{OIDC: config.OIDCConfig{RoleAttributePath: ""}}, + } + if got := svc.ExtractRolesFromAccessToken(tok); got != nil { + t.Errorf("expected nil for empty path, got %v", got) + } +} + +func TestExtractRolesFromAccessToken_IntermediateNodeNotMap(t *testing.T) { + // Path tries to descend through a string — extractRoles must bail with nil. + tok := makeAccessToken(t, map[string]any{ + "resource_access": "this-should-be-a-map", + }) + svc := &Service{ + authConfig: &config.AuthConfig{ + OIDC: config.OIDCConfig{RoleAttributePath: "resource_access.client.roles"}, + }, + } + if got := svc.ExtractRolesFromAccessToken(tok); got != nil { + t.Errorf("expected nil when path traverses non-map, got %v", got) + } +} + +func TestExtractRolesFromAccessToken_FinalValueWrongType(t *testing.T) { + // Final value is a plain string, not an array — extractStringArray returns nil. + tok := makeAccessToken(t, map[string]any{ + "roles": "admin", + }) + svc := &Service{ + authConfig: &config.AuthConfig{ + OIDC: config.OIDCConfig{RoleAttributePath: "roles"}, + }, + } + if got := svc.ExtractRolesFromAccessToken(tok); got != nil { + t.Errorf("expected nil for non-array roles, got %v", got) + } +} + +func TestExtractRolesFromAccessToken_BadBase64InPayload(t *testing.T) { + svc := &Service{ + authConfig: &config.AuthConfig{ + OIDC: config.OIDCConfig{RoleAttributePath: "roles"}, + }, + } + if got := svc.ExtractRolesFromAccessToken("hdr.!!!not-base64!!!.sig"); got != nil { + t.Errorf("expected nil for bad base64, got %v", got) + } +} + +func TestExtractRolesFromAccessToken_BadJSONInPayload(t *testing.T) { + svc := &Service{ + authConfig: &config.AuthConfig{ + OIDC: config.OIDCConfig{RoleAttributePath: "roles"}, + }, + } + // Valid base64 of "not json" + tok := "hdr." + base64.RawURLEncoding.EncodeToString([]byte("not json")) + ".sig" + if got := svc.ExtractRolesFromAccessToken(tok); got != nil { + t.Errorf("expected nil for non-JSON payload, got %v", got) + } +} + +// --------------------------------------------------------------------------- +// Task 8: IsAdmin coverage +// --------------------------------------------------------------------------- + +func TestIsAdmin(t *testing.T) { + tests := []struct { + name string + adminRole string + userRoles []string + want bool + }{ + {"empty admin role config returns false", "", []string{"admin"}, false}, + {"user has admin role", "admin", []string{"viewer", "admin"}, true}, + {"user lacks admin role", "admin", []string{"viewer"}, false}, + {"user has no roles", "admin", nil, false}, + {"role match is exact (case-sensitive)", "admin", []string{"Admin"}, false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + svc := &Service{ + authConfig: &config.AuthConfig{ + OIDC: config.OIDCConfig{AdminRole: tc.adminRole}, + }, + } + if got := svc.IsAdmin(&UserInfo{Roles: tc.userRoles}); got != tc.want { + t.Errorf("IsAdmin = %v, want %v", got, tc.want) + } + }) + } +} diff --git a/backend/internal/auth/jwt_test.go b/backend/internal/auth/jwt_test.go new file mode 100644 index 0000000..38d5851 --- /dev/null +++ b/backend/internal/auth/jwt_test.go @@ -0,0 +1,469 @@ +package auth + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "errors" + "strings" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// generatePKCS8PEM produces a PEM-encoded PKCS#8 Ed25519 private key. +// This is the format `openssl genpkey -algorithm ED25519` emits and the +// format the production code documents in jwt_private_key. +func generatePKCS8PEM(t *testing.T) (string, ed25519.PrivateKey) { + t.Helper() + _, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("ed25519.GenerateKey: %v", err) + } + der, err := x509.MarshalPKCS8PrivateKey(priv) + if err != nil { + t.Fatalf("MarshalPKCS8PrivateKey: %v", err) + } + pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}) + return string(pemBytes), priv +} + +// generateRawPEM wraps a raw 64-byte Ed25519 key in a PEM block. The +// production code accepts this as a fallback when PKCS#8 parsing fails. +func generateRawPEM(t *testing.T) (string, ed25519.PrivateKey) { + t.Helper() + _, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("ed25519.GenerateKey: %v", err) + } + pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: priv}) + return string(pemBytes), priv +} + +func TestParseEd25519PrivateKeyFromPEM_PKCS8(t *testing.T) { + pemStr, want := generatePKCS8PEM(t) + got, err := parseEd25519PrivateKeyFromPEM(pemStr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !got.Equal(want) { + t.Errorf("parsed key does not equal generated key") + } +} + +func TestParseEd25519PrivateKeyFromPEM_RawBytes(t *testing.T) { + pemStr, want := generateRawPEM(t) + got, err := parseEd25519PrivateKeyFromPEM(pemStr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !got.Equal(want) { + t.Errorf("parsed raw key does not equal generated key") + } +} + +func TestParseEd25519PrivateKeyFromPEM_NotPEM(t *testing.T) { + _, err := parseEd25519PrivateKeyFromPEM("this is not a pem block") + if err == nil { + t.Fatal("expected error for non-PEM input, got nil") + } + if !strings.Contains(err.Error(), "decode PEM block") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestParseEd25519PrivateKeyFromPEM_PKCS8WrongKeyType(t *testing.T) { + // Generate a non-Ed25519 PKCS#8 key (RSA would require crypto/rsa; instead + // we craft a PKCS#8 wrapping for an ECDSA key via x509). The simplest + // portable way is to use a known-bad DER blob: a PKCS#8 wrapping of an + // ed25519 PUBLIC key, which ParsePKCS8PrivateKey will reject as not a + // private key. To keep the test deterministic and dependency-free, we + // instead build a PEM of length-mismatched bytes that's neither PKCS#8 + // nor 64 raw bytes. + pemBytes := pem.EncodeToMemory(&pem.Block{ + Type: "PRIVATE KEY", + Bytes: []byte("definitely not a valid pkcs8 or raw ed25519 key"), + }) + _, err := parseEd25519PrivateKeyFromPEM(string(pemBytes)) + if err == nil { + t.Fatal("expected error for invalid key bytes, got nil") + } + if !strings.Contains(err.Error(), "invalid Ed25519 private key format") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestNewJWTService_AutoGeneratesKeyPair(t *testing.T) { + svc, err := NewJWTService() + if err != nil { + t.Fatalf("NewJWTService: %v", err) + } + if svc.privateKey == nil { + t.Error("privateKey is nil after auto-generate") + } + if svc.publicKey == nil { + t.Error("publicKey is nil after auto-generate") + } + if len(svc.privateKey) != ed25519.PrivateKeySize { + t.Errorf("privateKey size = %d, want %d", len(svc.privateKey), ed25519.PrivateKeySize) + } + if len(svc.publicKey) != ed25519.PublicKeySize { + t.Errorf("publicKey size = %d, want %d", len(svc.publicKey), ed25519.PublicKeySize) + } + if svc.stateStore == nil || svc.stateStore.states == nil { + t.Error("stateStore not initialized") + } +} + +func TestNewJWTServiceWithKey_EmptyStringAutoGenerates(t *testing.T) { + svc, err := NewJWTServiceWithKey("") + if err != nil { + t.Fatalf("NewJWTServiceWithKey(\"\"): %v", err) + } + if svc.privateKey == nil || svc.publicKey == nil { + t.Error("expected auto-generated keys for empty PEM input") + } +} + +func TestNewJWTServiceWithKey_PKCS8(t *testing.T) { + pemStr, want := generatePKCS8PEM(t) + svc, err := NewJWTServiceWithKey(pemStr) + if err != nil { + t.Fatalf("NewJWTServiceWithKey: %v", err) + } + if !svc.privateKey.Equal(want) { + t.Error("loaded privateKey does not match input") + } + // Public key must match the public part of the loaded private key. + wantPub := want.Public().(ed25519.PublicKey) + if !svc.publicKey.Equal(wantPub) { + t.Error("derived publicKey does not match") + } +} + +func TestNewJWTServiceWithKey_BadPEMReturnsWrappedError(t *testing.T) { + _, err := NewJWTServiceWithKey("garbage") + if err == nil { + t.Fatal("expected error for bad PEM, got nil") + } + if !strings.Contains(err.Error(), "failed to parse Ed25519 private key") { + t.Errorf("expected wrapping error, got %v", err) + } +} + +func newTestUserInfo() *UserInfo { + return &UserInfo{ + Username: "alice", + Email: "alice@example.com", + Name: "Alice Example", + Roles: []string{"admin", "viewer"}, + } +} + +func TestGenerateAndValidateToken_RoundTrip(t *testing.T) { + svc, err := NewJWTService() + if err != nil { + t.Fatalf("NewJWTService: %v", err) + } + + user := newTestUserInfo() + tok, err := svc.GenerateToken(user, 60) + if err != nil { + t.Fatalf("GenerateToken: %v", err) + } + if tok == "" { + t.Fatal("GenerateToken returned empty string") + } + + claims, err := svc.ValidateToken(tok) + if err != nil { + t.Fatalf("ValidateToken: %v", err) + } + if claims.Username != user.Username { + t.Errorf("Username = %q, want %q", claims.Username, user.Username) + } + if claims.Email != user.Email { + t.Errorf("Email = %q, want %q", claims.Email, user.Email) + } + if claims.Name != user.Name { + t.Errorf("Name = %q, want %q", claims.Name, user.Name) + } + if len(claims.Roles) != 2 || claims.Roles[0] != "admin" || claims.Roles[1] != "viewer" { + t.Errorf("Roles = %v, want [admin viewer]", claims.Roles) + } + // ExpiresAt should be ~60s in the future. + if claims.ExpiresAt == nil { + t.Fatal("ExpiresAt nil") + } + if d := time.Until(claims.ExpiresAt.Time); d <= 0 || d > 61*time.Second { + t.Errorf("ExpiresAt delta = %v, want (0,61s]", d) + } +} + +func TestValidateToken_Expired(t *testing.T) { + svc, err := NewJWTService() + if err != nil { + t.Fatalf("NewJWTService: %v", err) + } + // sessionMaxAge = -1s → token is born expired. + tok, err := svc.GenerateToken(newTestUserInfo(), -1) + if err != nil { + t.Fatalf("GenerateToken: %v", err) + } + _, err = svc.ValidateToken(tok) + if err == nil { + t.Fatal("expected expired-token error, got nil") + } + if !strings.Contains(err.Error(), "failed to parse token") { + t.Errorf("unexpected error: %v", err) + } + // jwt/v5 surfaces ErrTokenExpired wrapped in the parse error. + if !errors.Is(err, jwt.ErrTokenExpired) { + t.Errorf("expected wrapped jwt.ErrTokenExpired, got %v", err) + } +} + +func TestValidateToken_SignedByDifferentKey(t *testing.T) { + signer, err := NewJWTService() + if err != nil { + t.Fatalf("signer: %v", err) + } + verifier, err := NewJWTService() + if err != nil { + t.Fatalf("verifier: %v", err) + } + tok, err := signer.GenerateToken(newTestUserInfo(), 60) + if err != nil { + t.Fatalf("GenerateToken: %v", err) + } + if _, err := verifier.ValidateToken(tok); err == nil { + t.Fatal("expected signature-mismatch error, got nil") + } +} + +func TestValidateToken_Malformed(t *testing.T) { + svc, err := NewJWTService() + if err != nil { + t.Fatalf("NewJWTService: %v", err) + } + cases := []string{ + "", + "not.a.jwt", + "only-one-segment", + "two.segments", + "aaaa.bbbb.cccc", // valid shape, invalid base64/JSON + } + for _, c := range cases { + t.Run(c, func(t *testing.T) { + if _, err := svc.ValidateToken(c); err == nil { + t.Errorf("expected error for %q, got nil", c) + } + }) + } +} + +func TestValidateToken_WrongSigningMethod(t *testing.T) { + svc, err := NewJWTService() + if err != nil { + t.Fatalf("NewJWTService: %v", err) + } + // Forge an HS256 token with the same claim shape; ValidateToken's + // keyfunc must reject the alg before signature verification. + claims := SessionClaims{ + Username: "mallory", + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + }, + } + tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + signed, err := tok.SignedString([]byte("a-shared-secret")) + if err != nil { + t.Fatalf("sign HS256: %v", err) + } + _, err = svc.ValidateToken(signed) + if err == nil { + t.Fatal("expected error for non-EdDSA token, got nil") + } + if !strings.Contains(err.Error(), "unexpected signing method") { + t.Errorf("expected signing-method error, got %v", err) + } +} + +func TestGenerateToken_NilPrivateKeyReturnsError(t *testing.T) { + // Construct a service with a nil key directly. This guards the explicit + // nil-check at the top of GenerateToken. + svc := &JWTService{} + _, err := svc.GenerateToken(newTestUserInfo(), 60) + if err == nil { + t.Fatal("expected error for nil private key, got nil") + } + if !strings.Contains(err.Error(), "private key not initialized") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestValidateToken_NilPublicKeyReturnsError(t *testing.T) { + svc := &JWTService{} + _, err := svc.ValidateToken("anything") + if err == nil { + t.Fatal("expected error for nil public key, got nil") + } + if !strings.Contains(err.Error(), "public key not initialized") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestGenerateStateToken_ProducesUniqueValues(t *testing.T) { + svc, err := NewJWTService() + if err != nil { + t.Fatalf("NewJWTService: %v", err) + } + a, err := svc.GenerateStateToken() + if err != nil { + t.Fatalf("GenerateStateToken: %v", err) + } + b, err := svc.GenerateStateToken() + if err != nil { + t.Fatalf("GenerateStateToken: %v", err) + } + if a == "" || b == "" { + t.Fatal("state token is empty") + } + if a == b { + t.Errorf("state tokens collided: %q", a) + } +} + +func TestValidateAndConsumeState_HappyPath(t *testing.T) { + svc, err := NewJWTService() + if err != nil { + t.Fatalf("NewJWTService: %v", err) + } + tok, err := svc.GenerateStateToken() + if err != nil { + t.Fatalf("GenerateStateToken: %v", err) + } + if !svc.ValidateAndConsumeState(tok) { + t.Error("first consume should succeed") + } +} + +func TestValidateAndConsumeState_IsSingleUse(t *testing.T) { + svc, err := NewJWTService() + if err != nil { + t.Fatalf("NewJWTService: %v", err) + } + tok, err := svc.GenerateStateToken() + if err != nil { + t.Fatalf("GenerateStateToken: %v", err) + } + _ = svc.ValidateAndConsumeState(tok) + if svc.ValidateAndConsumeState(tok) { + t.Error("second consume should fail") + } +} + +func TestValidateAndConsumeState_UnknownTokenRejected(t *testing.T) { + svc, err := NewJWTService() + if err != nil { + t.Fatalf("NewJWTService: %v", err) + } + if svc.ValidateAndConsumeState("never-issued") { + t.Error("unknown token must not validate") + } +} + +func TestValidateAndConsumeState_ExpiredTokenRejected(t *testing.T) { + svc, err := NewJWTService() + if err != nil { + t.Fatalf("NewJWTService: %v", err) + } + // Inject an expired entry directly to avoid a real 10-minute wait. + svc.stateStore.states["expired"] = StateData{ + Created: time.Now().Add(-20 * time.Minute), + ExpiresAt: time.Now().Add(-10 * time.Minute), + } + if svc.ValidateAndConsumeState("expired") { + t.Error("expired token must not validate") + } + // And it should be deleted as a side effect of the rejection. + if _, exists := svc.stateStore.states["expired"]; exists { + t.Error("expired token should be removed from the store") + } +} + +func TestGetPublicKeyPEM_ParsesBackToOriginalKey(t *testing.T) { + svc, err := NewJWTService() + if err != nil { + t.Fatalf("NewJWTService: %v", err) + } + pemStr, err := svc.GetPublicKeyPEM() + if err != nil { + t.Fatalf("GetPublicKeyPEM: %v", err) + } + block, _ := pem.Decode([]byte(pemStr)) + if block == nil { + t.Fatalf("returned PEM did not decode: %q", pemStr) + } + if block.Type != "PUBLIC KEY" { + t.Errorf("PEM type = %q, want PUBLIC KEY", block.Type) + } + // The implementation writes the raw 32-byte public key as the block body. + if len(block.Bytes) != ed25519.PublicKeySize { + t.Errorf("body length = %d, want %d", len(block.Bytes), ed25519.PublicKeySize) + } + if !ed25519.PublicKey(block.Bytes).Equal(svc.publicKey) { + t.Error("decoded public key does not match service key") + } +} + +func TestGetPublicKeyBase64_RoundTripsToOriginalKey(t *testing.T) { + svc, err := NewJWTService() + if err != nil { + t.Fatalf("NewJWTService: %v", err) + } + b64, err := svc.GetPublicKeyBase64() + if err != nil { + t.Fatalf("GetPublicKeyBase64: %v", err) + } + if b64 == "" { + t.Fatal("empty base64 output") + } + // base64.RawURLEncoding (no padding) is what the production code uses. + // Decode and compare. + // Use the std encoding through helper to keep the import list small. + got, err := decodeRawURL(b64) + if err != nil { + t.Fatalf("base64 decode: %v", err) + } + if !ed25519.PublicKey(got).Equal(svc.publicKey) { + t.Error("base64-decoded key does not match service key") + } +} + +func TestGetPublicKeyPEM_NilKeyReturnsError(t *testing.T) { + svc := &JWTService{} + if _, err := svc.GetPublicKeyPEM(); err == nil { + t.Error("expected error for nil public key") + } +} + +func TestGetPublicKeyBase64_NilKeyReturnsError(t *testing.T) { + svc := &JWTService{} + if _, err := svc.GetPublicKeyBase64(); err == nil { + t.Error("expected error for nil public key") + } +} + +// decodeRawURL is a tiny shim around encoding/base64's RawURLEncoding decoder +// so the test body stays focused on assertions, not encoding plumbing. +func decodeRawURL(s string) ([]byte, error) { + return base64RawURLDecode(s) +} + +func base64RawURLDecode(s string) ([]byte, error) { + return base64.RawURLEncoding.DecodeString(s) +}