test: add unit tests for authentication and JWT functionalities

Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
This commit is contained in:
Noooste
2026-04-19 11:23:44 +02:00
parent cbdab9a775
commit ab188dac4a
2 changed files with 927 additions and 0 deletions
+458
View File
@@ -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)
}
})
}
}
+469
View File
@@ -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)
}