jwt.test.ts 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
  1. // @vitest-environment node
  2. import { describe, it, expect, beforeAll } from "vitest";
  3. import { SignJWT, jwtVerify, decodeProtectedHeader, decodeJwt } from "jose";
  4. import { mintAccessToken, verifyAccessToken } from "@/lib/auth/jwt";
  5. const SECRET = "test-jwt-secret-with-at-least-32-characters-of-length-yes";
  6. beforeAll(() => {
  7. process.env.JWT_SECRET = SECRET;
  8. });
  9. const sub = "00000000-0000-0000-0000-000000000abc";
  10. const session_id = "11111111-1111-1111-1111-111111111111";
  11. describe("mintAccessToken / verifyAccessToken", () => {
  12. it("roundtrips and preserves iat_original", async () => {
  13. const iat_original = Math.floor(Date.now() / 1000) - 60;
  14. const token = await mintAccessToken({ sub, session_id, iat_original });
  15. const claims = await verifyAccessToken(token);
  16. expect(claims).not.toBeNull();
  17. expect(claims!.sub).toBe(sub);
  18. expect(claims!.session_id).toBe(session_id);
  19. expect(claims!.iat_original).toBe(iat_original);
  20. });
  21. it("sets the protected header (alg=HS256, typ=JWT, kid=v1)", async () => {
  22. const token = await mintAccessToken({
  23. sub,
  24. session_id,
  25. iat_original: Math.floor(Date.now() / 1000),
  26. });
  27. const header = decodeProtectedHeader(token);
  28. expect(header.alg).toBe("HS256");
  29. expect(header.typ).toBe("JWT");
  30. expect(header.kid).toBe("v1");
  31. });
  32. it("sets iss=moviedice and aud=authenticated", async () => {
  33. const token = await mintAccessToken({
  34. sub,
  35. session_id,
  36. iat_original: Math.floor(Date.now() / 1000),
  37. });
  38. const payload = decodeJwt(token);
  39. expect(payload.iss).toBe("moviedice");
  40. expect(payload.aud).toBe("authenticated");
  41. expect(payload.role).toBe("authenticated");
  42. expect(payload.is_anonymous).toBe(true);
  43. });
  44. it("rejects an expired token", async () => {
  45. const secret = new TextEncoder().encode(SECRET);
  46. const past = Math.floor(Date.now() / 1000) - 7200; // 2h ago
  47. const token = await new SignJWT({
  48. sub,
  49. session_id,
  50. iat_original: past,
  51. role: "authenticated",
  52. })
  53. .setProtectedHeader({ alg: "HS256", typ: "JWT", kid: "v1" })
  54. .setIssuedAt(past)
  55. .setNotBefore(past - 10)
  56. .setExpirationTime(past + 3600) // expired 1h ago
  57. .setIssuer("moviedice")
  58. .setAudience("authenticated")
  59. .sign(secret);
  60. expect(await verifyAccessToken(token)).toBeNull();
  61. });
  62. it("rejects a token whose nbf is in the future (beyond skew)", async () => {
  63. const secret = new TextEncoder().encode(SECRET);
  64. const future = Math.floor(Date.now() / 1000) + 600; // +10min
  65. const token = await new SignJWT({
  66. sub,
  67. session_id,
  68. iat_original: future,
  69. role: "authenticated",
  70. })
  71. .setProtectedHeader({ alg: "HS256", typ: "JWT", kid: "v1" })
  72. .setIssuedAt(future)
  73. .setNotBefore(future)
  74. .setExpirationTime(future + 3600)
  75. .setIssuer("moviedice")
  76. .setAudience("authenticated")
  77. .sign(secret);
  78. expect(await verifyAccessToken(token)).toBeNull();
  79. });
  80. it("rejects a token signed with the wrong secret", async () => {
  81. const wrong = new TextEncoder().encode("wrong-secret-also-32-characters-long-yes-yes!");
  82. const now = Math.floor(Date.now() / 1000);
  83. const token = await new SignJWT({
  84. sub,
  85. session_id,
  86. iat_original: now,
  87. role: "authenticated",
  88. })
  89. .setProtectedHeader({ alg: "HS256", typ: "JWT", kid: "v1" })
  90. .setIssuedAt()
  91. .setNotBefore("-10s")
  92. .setExpirationTime("1h")
  93. .setIssuer("moviedice")
  94. .setAudience("authenticated")
  95. .sign(wrong);
  96. expect(await verifyAccessToken(token)).toBeNull();
  97. });
  98. it("rejects a token with wrong issuer or audience", async () => {
  99. const secret = new TextEncoder().encode(SECRET);
  100. const now = Math.floor(Date.now() / 1000);
  101. const wrongIss = await new SignJWT({ sub, session_id, iat_original: now })
  102. .setProtectedHeader({ alg: "HS256", typ: "JWT", kid: "v1" })
  103. .setIssuedAt()
  104. .setNotBefore("-10s")
  105. .setExpirationTime("1h")
  106. .setIssuer("not-moviedice")
  107. .setAudience("authenticated")
  108. .sign(secret);
  109. expect(await verifyAccessToken(wrongIss)).toBeNull();
  110. const wrongAud = await new SignJWT({ sub, session_id, iat_original: now })
  111. .setProtectedHeader({ alg: "HS256", typ: "JWT", kid: "v1" })
  112. .setIssuedAt()
  113. .setNotBefore("-10s")
  114. .setExpirationTime("1h")
  115. .setIssuer("moviedice")
  116. .setAudience("not-authenticated")
  117. .sign(secret);
  118. expect(await verifyAccessToken(wrongAud)).toBeNull();
  119. });
  120. it("verifies a token signed seconds in the future thanks to nbf skew", async () => {
  121. // mintAccessToken sets nbf = -10s; jose's jwtVerify uses clockTolerance:0
  122. // by default but our nbf is already 10s in the past, so it should verify
  123. // immediately.
  124. const token = await mintAccessToken({
  125. sub,
  126. session_id,
  127. iat_original: Math.floor(Date.now() / 1000),
  128. });
  129. const claims = await verifyAccessToken(token);
  130. expect(claims).not.toBeNull();
  131. });
  132. });