authd_test.go 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  1. package authd
  2. import (
  3. "context"
  4. "testing"
  5. "time"
  6. "github.com/golang-jwt/jwt/v5"
  7. "golang.org/x/crypto/bcrypt"
  8. )
  9. // These tests cover the pure-Go logic in authd.go that does NOT
  10. // require Postgres: bcrypt password hashing/verification, JWT
  11. // signing/verification, magic-link token generation/hashing, and
  12. // the SecureEqual helper. The DB-backed paths are tested in
  13. // store_test.go (build tag 'postgres').
  14. func TestHashPassword_And_Verify(t *testing.T) {
  15. cfg := DefaultConfig()
  16. cfg.JWTSecret = []byte("this-is-a-test-secret-with-32-bytes-min")
  17. a, err := New(nil, cfg) // nil pool ok for password tests
  18. if err != nil {
  19. t.Fatalf("new authd: %v", err)
  20. }
  21. hash, err := a.HashPassword(context.Background(), "hunter2-correct-horse")
  22. if err != nil {
  23. t.Fatalf("hash: %v", err)
  24. }
  25. if hash == "" {
  26. t.Fatal("hash is empty")
  27. }
  28. if err := a.VerifyPassword(hash, "hunter2-correct-horse"); err != nil {
  29. t.Errorf("verify correct: %v", err)
  30. }
  31. if err := a.VerifyPassword(hash, "wrong"); err == nil {
  32. t.Error("verify wrong: expected error, got nil")
  33. }
  34. }
  35. func TestHashPassword_BcryptCost(t *testing.T) {
  36. cfg := DefaultConfig()
  37. cfg.JWTSecret = []byte("this-is-a-test-secret-with-32-bytes-min")
  38. cfg.BcryptCost = bcrypt.MinCost // speed up test
  39. a, _ := New(nil, cfg)
  40. start := time.Now()
  41. _, err := a.HashPassword(context.Background(), "x")
  42. if err != nil {
  43. t.Fatal(err)
  44. }
  45. if elapsed := time.Since(start); elapsed > 2*time.Second {
  46. t.Errorf("bcrypt MinCost should be <2s, got %s", elapsed)
  47. }
  48. }
  49. func TestNew_RejectsShortSecret(t *testing.T) {
  50. cfg := DefaultConfig()
  51. cfg.JWTSecret = []byte("too-short")
  52. if _, err := New(nil, cfg); err == nil {
  53. t.Error("expected error for short JWT secret, got nil")
  54. }
  55. }
  56. func TestNew_AppliesDefaults(t *testing.T) {
  57. cfg := Config{
  58. JWTSecret: []byte("this-is-a-test-secret-with-32-bytes-min"),
  59. }
  60. a, err := New(nil, cfg)
  61. if err != nil {
  62. t.Fatalf("new: %v", err)
  63. }
  64. if a.cfg.AccessTokenTTL != 15*time.Minute {
  65. t.Errorf("AccessTokenTTL default = %s, want 15m", a.cfg.AccessTokenTTL)
  66. }
  67. if a.cfg.RefreshTokenTTL != 7*24*time.Hour {
  68. t.Errorf("RefreshTokenTTL default = %s, want 7d", a.cfg.RefreshTokenTTL)
  69. }
  70. if a.cfg.MagicLinkTTL != 24*time.Hour {
  71. t.Errorf("MagicLinkTTL default = %s, want 24h", a.cfg.MagicLinkTTL)
  72. }
  73. if a.cfg.BcryptCost != 12 {
  74. t.Errorf("BcryptCost default = %d, want 12", a.cfg.BcryptCost)
  75. }
  76. }
  77. func TestIssueMagicLink_ReturnsHexToken(t *testing.T) {
  78. cfg := DefaultConfig()
  79. cfg.JWTSecret = []byte("this-is-a-test-secret-with-32-bytes-min")
  80. a, _ := New(nil, cfg)
  81. // IssueMagicLink requires a real DB (InsertMagicLink). We can
  82. // only test the token shape via the random source by
  83. // calling the parts we can. Skip if pool is nil.
  84. // To avoid coupling, we just check that the method exists and
  85. // the error path is what we expect (DB unavailable).
  86. _, _, err := a.IssueMagicLink(context.Background(), "00000000-0000-0000-0000-000000000000", "invite")
  87. if err == nil {
  88. t.Error("expected DB error, got nil (pool was nil)")
  89. }
  90. }
  91. func TestConsumeMagicLink_BadInput(t *testing.T) {
  92. cfg := DefaultConfig()
  93. cfg.JWTSecret = []byte("this-is-a-test-secret-with-32-bytes-min")
  94. a, _ := New(nil, cfg)
  95. // Wrong length
  96. _, err := a.ConsumeMagicLink(context.Background(), "tooshort", "1.1.1.1", "ua")
  97. if err != ErrMagicLinkInvalid {
  98. t.Errorf("short token: err = %v, want ErrMagicLinkInvalid", err)
  99. }
  100. // Bad hex
  101. _, err = a.ConsumeMagicLink(context.Background(), "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", "1.1.1.1", "ua")
  102. if err != ErrMagicLinkInvalid {
  103. t.Errorf("bad hex: err = %v, want ErrMagicLinkInvalid", err)
  104. }
  105. }
  106. func TestVerifyAccessToken_RoundTrip(t *testing.T) {
  107. cfg := DefaultConfig()
  108. cfg.JWTSecret = []byte("this-is-a-test-secret-with-32-bytes-min")
  109. a, _ := New(nil, cfg)
  110. user := &User{ID: "u-1", TenantID: "t-1", Role: "tenant_admin", Status: "active"}
  111. accessJWT, jti, exp, err := a.mintAccessToken(user)
  112. if err != nil {
  113. t.Fatalf("mint: %v", err)
  114. }
  115. if accessJWT == "" {
  116. t.Fatal("empty token")
  117. }
  118. if jti == "" {
  119. t.Fatal("empty jti")
  120. }
  121. if !exp.After(time.Now()) {
  122. t.Fatal("token already expired")
  123. }
  124. claims, err := a.VerifyAccessToken(accessJWT)
  125. if err != nil {
  126. t.Fatalf("verify: %v", err)
  127. }
  128. if claims.UserID != "u-1" {
  129. t.Errorf("UserID = %q, want u-1", claims.UserID)
  130. }
  131. if claims.TenantID != "t-1" {
  132. t.Errorf("TenantID = %q, want t-1", claims.TenantID)
  133. }
  134. if claims.Role != "tenant_admin" {
  135. t.Errorf("Role = %q, want tenant_admin", claims.Role)
  136. }
  137. if claims.TokenType != "access" {
  138. t.Errorf("TokenType = %q, want access", claims.TokenType)
  139. }
  140. }
  141. func TestVerifyAccessToken_RejectsBadSecret(t *testing.T) {
  142. cfg := DefaultConfig()
  143. cfg.JWTSecret = []byte("this-is-a-test-secret-with-32-bytes-min")
  144. a, _ := New(nil, cfg)
  145. user := &User{ID: "u-1", Status: "active"}
  146. accessJWT, _, _, _ := a.mintAccessToken(user)
  147. // Same secret, different bytes
  148. other := DefaultConfig()
  149. other.JWTSecret = []byte("different-secret-with-32-bytes-min!!")
  150. b, _ := New(nil, other)
  151. if _, err := b.VerifyAccessToken(accessJWT); err == nil {
  152. t.Error("expected error from different-secret verifier, got nil")
  153. }
  154. }
  155. func TestVerifyAccessToken_RejectsExpired(t *testing.T) {
  156. cfg := DefaultConfig()
  157. cfg.JWTSecret = []byte("this-is-a-test-secret-with-32-bytes-min")
  158. cfg.AccessTokenTTL = -1 * time.Minute // already expired
  159. a, _ := New(nil, cfg)
  160. user := &User{ID: "u-1", Status: "active"}
  161. accessJWT, _, _, _ := a.mintAccessToken(user)
  162. _, err := a.VerifyAccessToken(accessJWT)
  163. if err == nil {
  164. t.Error("expected expired-token error, got nil")
  165. }
  166. }
  167. func TestVerifyAccessToken_RejectsAlgConfusion(t *testing.T) {
  168. // Classic alg=none / RS256-confused-as-HS256 attack. The verifier
  169. // must reject any non-HMAC alg. We craft a token signed with the
  170. // "none" alg and confirm it's rejected.
  171. cfg := DefaultConfig()
  172. cfg.JWTSecret = []byte("this-is-a-test-secret-with-32-bytes-min")
  173. a, _ := New(nil, cfg)
  174. claims := AccessClaims{
  175. UserID: "u-1",
  176. Role: "tenant_admin",
  177. TokenType: "access",
  178. RegisteredClaims: jwt.RegisteredClaims{
  179. Issuer: "broad-announce",
  180. Subject: "u-1",
  181. ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
  182. },
  183. }
  184. t1 := jwt.NewWithClaims(jwt.SigningMethodNone, claims)
  185. noneToken, err := t1.SignedString(jwt.UnsafeAllowNoneSignatureType)
  186. if err != nil {
  187. t.Fatalf("sign with none: %v", err)
  188. }
  189. if _, err := a.VerifyAccessToken(noneToken); err == nil {
  190. t.Error("expected verifier to reject 'none' alg, got nil error")
  191. }
  192. }
  193. func TestSecureEqual(t *testing.T) {
  194. if !SecureEqual([]byte("abc"), []byte("abc")) {
  195. t.Error("equal slices: want true")
  196. }
  197. if SecureEqual([]byte("abc"), []byte("abd")) {
  198. t.Error("differing slices: want false")
  199. }
  200. if SecureEqual([]byte("abc"), []byte("abcd")) {
  201. t.Error("different lengths: want false")
  202. }
  203. }
  204. func TestNewJTI_Unique(t *testing.T) {
  205. seen := make(map[string]struct{}, 1000)
  206. for i := 0; i < 1000; i++ {
  207. id := newJTI()
  208. if _, dup := seen[id]; dup {
  209. t.Fatalf("duplicate JTI on iter %d: %s", i, id)
  210. }
  211. seen[id] = struct{}{}
  212. }
  213. }
  214. func TestNewJTI_HexShape(t *testing.T) {
  215. id := newJTI()
  216. if len(id) != 32 {
  217. t.Errorf("JTI length = %d, want 32 hex chars (16 bytes)", len(id))
  218. }
  219. for _, c := range id {
  220. if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
  221. t.Fatalf("non-hex char in JTI: %q", c)
  222. }
  223. }
  224. }