| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247 |
- package authd
- import (
- "context"
- "testing"
- "time"
- "github.com/golang-jwt/jwt/v5"
- "golang.org/x/crypto/bcrypt"
- )
- // These tests cover the pure-Go logic in authd.go that does NOT
- // require Postgres: bcrypt password hashing/verification, JWT
- // signing/verification, magic-link token generation/hashing, and
- // the SecureEqual helper. The DB-backed paths are tested in
- // store_test.go (build tag 'postgres').
- func TestHashPassword_And_Verify(t *testing.T) {
- cfg := DefaultConfig()
- cfg.JWTSecret = []byte("this-is-a-test-secret-with-32-bytes-min")
- a, err := New(nil, cfg) // nil pool ok for password tests
- if err != nil {
- t.Fatalf("new authd: %v", err)
- }
- hash, err := a.HashPassword(context.Background(), "hunter2-correct-horse")
- if err != nil {
- t.Fatalf("hash: %v", err)
- }
- if hash == "" {
- t.Fatal("hash is empty")
- }
- if err := a.VerifyPassword(hash, "hunter2-correct-horse"); err != nil {
- t.Errorf("verify correct: %v", err)
- }
- if err := a.VerifyPassword(hash, "wrong"); err == nil {
- t.Error("verify wrong: expected error, got nil")
- }
- }
- func TestHashPassword_BcryptCost(t *testing.T) {
- cfg := DefaultConfig()
- cfg.JWTSecret = []byte("this-is-a-test-secret-with-32-bytes-min")
- cfg.BcryptCost = bcrypt.MinCost // speed up test
- a, _ := New(nil, cfg)
- start := time.Now()
- _, err := a.HashPassword(context.Background(), "x")
- if err != nil {
- t.Fatal(err)
- }
- if elapsed := time.Since(start); elapsed > 2*time.Second {
- t.Errorf("bcrypt MinCost should be <2s, got %s", elapsed)
- }
- }
- func TestNew_RejectsShortSecret(t *testing.T) {
- cfg := DefaultConfig()
- cfg.JWTSecret = []byte("too-short")
- if _, err := New(nil, cfg); err == nil {
- t.Error("expected error for short JWT secret, got nil")
- }
- }
- func TestNew_AppliesDefaults(t *testing.T) {
- cfg := Config{
- JWTSecret: []byte("this-is-a-test-secret-with-32-bytes-min"),
- }
- a, err := New(nil, cfg)
- if err != nil {
- t.Fatalf("new: %v", err)
- }
- if a.cfg.AccessTokenTTL != 15*time.Minute {
- t.Errorf("AccessTokenTTL default = %s, want 15m", a.cfg.AccessTokenTTL)
- }
- if a.cfg.RefreshTokenTTL != 7*24*time.Hour {
- t.Errorf("RefreshTokenTTL default = %s, want 7d", a.cfg.RefreshTokenTTL)
- }
- if a.cfg.MagicLinkTTL != 24*time.Hour {
- t.Errorf("MagicLinkTTL default = %s, want 24h", a.cfg.MagicLinkTTL)
- }
- if a.cfg.BcryptCost != 12 {
- t.Errorf("BcryptCost default = %d, want 12", a.cfg.BcryptCost)
- }
- }
- func TestIssueMagicLink_ReturnsHexToken(t *testing.T) {
- cfg := DefaultConfig()
- cfg.JWTSecret = []byte("this-is-a-test-secret-with-32-bytes-min")
- a, _ := New(nil, cfg)
- // IssueMagicLink requires a real DB (InsertMagicLink). We can
- // only test the token shape via the random source by
- // calling the parts we can. Skip if pool is nil.
- // To avoid coupling, we just check that the method exists and
- // the error path is what we expect (DB unavailable).
- _, _, err := a.IssueMagicLink(context.Background(), "00000000-0000-0000-0000-000000000000", "invite")
- if err == nil {
- t.Error("expected DB error, got nil (pool was nil)")
- }
- }
- func TestConsumeMagicLink_BadInput(t *testing.T) {
- cfg := DefaultConfig()
- cfg.JWTSecret = []byte("this-is-a-test-secret-with-32-bytes-min")
- a, _ := New(nil, cfg)
- // Wrong length
- _, err := a.ConsumeMagicLink(context.Background(), "tooshort", "1.1.1.1", "ua")
- if err != ErrMagicLinkInvalid {
- t.Errorf("short token: err = %v, want ErrMagicLinkInvalid", err)
- }
- // Bad hex
- _, err = a.ConsumeMagicLink(context.Background(), "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", "1.1.1.1", "ua")
- if err != ErrMagicLinkInvalid {
- t.Errorf("bad hex: err = %v, want ErrMagicLinkInvalid", err)
- }
- }
- func TestVerifyAccessToken_RoundTrip(t *testing.T) {
- cfg := DefaultConfig()
- cfg.JWTSecret = []byte("this-is-a-test-secret-with-32-bytes-min")
- a, _ := New(nil, cfg)
- user := &User{ID: "u-1", TenantID: "t-1", Role: "tenant_admin", Status: "active"}
- accessJWT, jti, exp, err := a.mintAccessToken(user)
- if err != nil {
- t.Fatalf("mint: %v", err)
- }
- if accessJWT == "" {
- t.Fatal("empty token")
- }
- if jti == "" {
- t.Fatal("empty jti")
- }
- if !exp.After(time.Now()) {
- t.Fatal("token already expired")
- }
- claims, err := a.VerifyAccessToken(accessJWT)
- if err != nil {
- t.Fatalf("verify: %v", err)
- }
- if claims.UserID != "u-1" {
- t.Errorf("UserID = %q, want u-1", claims.UserID)
- }
- if claims.TenantID != "t-1" {
- t.Errorf("TenantID = %q, want t-1", claims.TenantID)
- }
- if claims.Role != "tenant_admin" {
- t.Errorf("Role = %q, want tenant_admin", claims.Role)
- }
- if claims.TokenType != "access" {
- t.Errorf("TokenType = %q, want access", claims.TokenType)
- }
- }
- func TestVerifyAccessToken_RejectsBadSecret(t *testing.T) {
- cfg := DefaultConfig()
- cfg.JWTSecret = []byte("this-is-a-test-secret-with-32-bytes-min")
- a, _ := New(nil, cfg)
- user := &User{ID: "u-1", Status: "active"}
- accessJWT, _, _, _ := a.mintAccessToken(user)
- // Same secret, different bytes
- other := DefaultConfig()
- other.JWTSecret = []byte("different-secret-with-32-bytes-min!!")
- b, _ := New(nil, other)
- if _, err := b.VerifyAccessToken(accessJWT); err == nil {
- t.Error("expected error from different-secret verifier, got nil")
- }
- }
- func TestVerifyAccessToken_RejectsExpired(t *testing.T) {
- cfg := DefaultConfig()
- cfg.JWTSecret = []byte("this-is-a-test-secret-with-32-bytes-min")
- cfg.AccessTokenTTL = -1 * time.Minute // already expired
- a, _ := New(nil, cfg)
- user := &User{ID: "u-1", Status: "active"}
- accessJWT, _, _, _ := a.mintAccessToken(user)
- _, err := a.VerifyAccessToken(accessJWT)
- if err == nil {
- t.Error("expected expired-token error, got nil")
- }
- }
- func TestVerifyAccessToken_RejectsAlgConfusion(t *testing.T) {
- // Classic alg=none / RS256-confused-as-HS256 attack. The verifier
- // must reject any non-HMAC alg. We craft a token signed with the
- // "none" alg and confirm it's rejected.
- cfg := DefaultConfig()
- cfg.JWTSecret = []byte("this-is-a-test-secret-with-32-bytes-min")
- a, _ := New(nil, cfg)
- claims := AccessClaims{
- UserID: "u-1",
- Role: "tenant_admin",
- TokenType: "access",
- RegisteredClaims: jwt.RegisteredClaims{
- Issuer: "broad-announce",
- Subject: "u-1",
- ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
- },
- }
- t1 := jwt.NewWithClaims(jwt.SigningMethodNone, claims)
- noneToken, err := t1.SignedString(jwt.UnsafeAllowNoneSignatureType)
- if err != nil {
- t.Fatalf("sign with none: %v", err)
- }
- if _, err := a.VerifyAccessToken(noneToken); err == nil {
- t.Error("expected verifier to reject 'none' alg, got nil error")
- }
- }
- func TestSecureEqual(t *testing.T) {
- if !SecureEqual([]byte("abc"), []byte("abc")) {
- t.Error("equal slices: want true")
- }
- if SecureEqual([]byte("abc"), []byte("abd")) {
- t.Error("differing slices: want false")
- }
- if SecureEqual([]byte("abc"), []byte("abcd")) {
- t.Error("different lengths: want false")
- }
- }
- func TestNewJTI_Unique(t *testing.T) {
- seen := make(map[string]struct{}, 1000)
- for i := 0; i < 1000; i++ {
- id := newJTI()
- if _, dup := seen[id]; dup {
- t.Fatalf("duplicate JTI on iter %d: %s", i, id)
- }
- seen[id] = struct{}{}
- }
- }
- func TestNewJTI_HexShape(t *testing.T) {
- id := newJTI()
- if len(id) != 32 {
- t.Errorf("JTI length = %d, want 32 hex chars (16 bytes)", len(id))
- }
- for _, c := range id {
- if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
- t.Fatalf("non-hex char in JTI: %q", c)
- }
- }
- }
|