@forjio/engine-auth 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/dist/__tests__/api-keys.test.d.ts +2 -0
  2. package/dist/__tests__/api-keys.test.d.ts.map +1 -0
  3. package/dist/__tests__/api-keys.test.js +78 -0
  4. package/dist/__tests__/api-keys.test.js.map +1 -0
  5. package/dist/__tests__/helpers/express-mock.d.ts +13 -0
  6. package/dist/__tests__/helpers/express-mock.d.ts.map +1 -0
  7. package/dist/__tests__/helpers/express-mock.js +46 -0
  8. package/dist/__tests__/helpers/express-mock.js.map +1 -0
  9. package/dist/__tests__/helpers/redis-mock.d.ts +19 -0
  10. package/dist/__tests__/helpers/redis-mock.d.ts.map +1 -0
  11. package/dist/__tests__/helpers/redis-mock.js +69 -0
  12. package/dist/__tests__/helpers/redis-mock.js.map +1 -0
  13. package/dist/__tests__/jwt.test.d.ts +2 -0
  14. package/dist/__tests__/jwt.test.d.ts.map +1 -0
  15. package/dist/__tests__/jwt.test.js +98 -0
  16. package/dist/__tests__/jwt.test.js.map +1 -0
  17. package/dist/__tests__/middleware.test.d.ts +2 -0
  18. package/dist/__tests__/middleware.test.d.ts.map +1 -0
  19. package/dist/__tests__/middleware.test.js +173 -0
  20. package/dist/__tests__/middleware.test.js.map +1 -0
  21. package/dist/__tests__/session.test.d.ts +2 -0
  22. package/dist/__tests__/session.test.d.ts.map +1 -0
  23. package/dist/__tests__/session.test.js +96 -0
  24. package/dist/__tests__/session.test.js.map +1 -0
  25. package/dist/api-keys.d.ts +29 -0
  26. package/dist/api-keys.d.ts.map +1 -0
  27. package/dist/api-keys.js +50 -0
  28. package/dist/api-keys.js.map +1 -0
  29. package/dist/index.d.ts +10 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +31 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/jwt.d.ts +7 -0
  34. package/dist/jwt.d.ts.map +1 -0
  35. package/dist/jwt.js +66 -0
  36. package/dist/jwt.js.map +1 -0
  37. package/dist/middleware.d.ts +29 -0
  38. package/dist/middleware.d.ts.map +1 -0
  39. package/dist/middleware.js +94 -0
  40. package/dist/middleware.js.map +1 -0
  41. package/dist/oauth.d.ts +16 -0
  42. package/dist/oauth.d.ts.map +1 -0
  43. package/dist/oauth.js +78 -0
  44. package/dist/oauth.js.map +1 -0
  45. package/dist/session.d.ts +9 -0
  46. package/dist/session.d.ts.map +1 -0
  47. package/dist/session.js +61 -0
  48. package/dist/session.js.map +1 -0
  49. package/dist/types.d.ts +50 -0
  50. package/dist/types.d.ts.map +1 -0
  51. package/dist/types.js +3 -0
  52. package/dist/types.js.map +1 -0
  53. package/package.json +34 -0
  54. package/src/__tests__/api-keys.test.ts +94 -0
  55. package/src/__tests__/helpers/express-mock.ts +47 -0
  56. package/src/__tests__/helpers/redis-mock.ts +65 -0
  57. package/src/__tests__/jwt.test.ts +122 -0
  58. package/src/__tests__/middleware.test.ts +220 -0
  59. package/src/__tests__/session.test.ts +120 -0
  60. package/src/api-keys.ts +56 -0
  61. package/src/index.ts +46 -0
  62. package/src/jwt.ts +61 -0
  63. package/src/middleware.ts +113 -0
  64. package/src/oauth.ts +111 -0
  65. package/src/session.ts +78 -0
  66. package/src/types.ts +45 -0
  67. package/tsconfig.json +19 -0
  68. package/vitest.config.ts +21 -0
@@ -0,0 +1,61 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.createSessionStore = createSessionStore;
7
+ const ioredis_1 = __importDefault(require("ioredis"));
8
+ const uuid_1 = require("uuid");
9
+ const KEY_PREFIX = 'engine:session:';
10
+ const USER_SESSIONS_PREFIX = 'engine:user-sessions:';
11
+ const DEFAULT_TTL_SECONDS = 7 * 24 * 60 * 60; // 7 days
12
+ function createSessionStore(redisUrl) {
13
+ const redis = new ioredis_1.default(redisUrl);
14
+ return {
15
+ async create(sessionData) {
16
+ const sessionId = (0, uuid_1.v4)();
17
+ const key = `${KEY_PREFIX}${sessionId}`;
18
+ const serialized = JSON.stringify(sessionData);
19
+ await redis.setex(key, DEFAULT_TTL_SECONDS, serialized);
20
+ // Track session IDs per user for bulk destruction
21
+ const userKey = `${USER_SESSIONS_PREFIX}${sessionData.userId}`;
22
+ await redis.sadd(userKey, sessionId);
23
+ await redis.expire(userKey, DEFAULT_TTL_SECONDS);
24
+ return sessionId;
25
+ },
26
+ async get(sessionId) {
27
+ const key = `${KEY_PREFIX}${sessionId}`;
28
+ const data = await redis.get(key);
29
+ if (!data)
30
+ return null;
31
+ const session = JSON.parse(data);
32
+ // Check logical expiry
33
+ if (session.expiresAt < Date.now()) {
34
+ await redis.del(key);
35
+ return null;
36
+ }
37
+ return session;
38
+ },
39
+ async destroy(sessionId) {
40
+ const key = `${KEY_PREFIX}${sessionId}`;
41
+ // Get session to find userId before deleting
42
+ const data = await redis.get(key);
43
+ if (data) {
44
+ const session = JSON.parse(data);
45
+ const userKey = `${USER_SESSIONS_PREFIX}${session.userId}`;
46
+ await redis.srem(userKey, sessionId);
47
+ }
48
+ await redis.del(key);
49
+ },
50
+ async destroyAllForUser(userId) {
51
+ const userKey = `${USER_SESSIONS_PREFIX}${userId}`;
52
+ const sessionIds = await redis.smembers(userKey);
53
+ if (sessionIds.length > 0) {
54
+ const keys = sessionIds.map((id) => `${KEY_PREFIX}${id}`);
55
+ await redis.del(...keys);
56
+ }
57
+ await redis.del(userKey);
58
+ },
59
+ };
60
+ }
61
+ //# sourceMappingURL=session.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session.js","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,50 @@
1
+ export interface EngineUser {
2
+ id: string;
3
+ email: string;
4
+ name: string | null;
5
+ role: 'user' | 'admin';
6
+ subscriptionTier: string | null;
7
+ midtransCustomerId: string | null;
8
+ emailVerified: boolean;
9
+ }
10
+ export interface ApiKey {
11
+ id: string;
12
+ key: string;
13
+ userId: string;
14
+ productSlug: string;
15
+ scopes: string[];
16
+ expiresAt: Date | null;
17
+ lastUsedAt: Date | null;
18
+ createdAt: Date;
19
+ }
20
+ export interface SessionData {
21
+ userId: string;
22
+ email: string;
23
+ role: string;
24
+ productSlug: string;
25
+ expiresAt: number;
26
+ }
27
+ export interface JwtPayload {
28
+ userId: string;
29
+ email: string;
30
+ role: string;
31
+ subscriptionTier: string | null;
32
+ }
33
+ export interface AuthConfig {
34
+ jwtSecret: string;
35
+ jwtRefreshSecret: string;
36
+ jwtExpiresIn?: string;
37
+ jwtRefreshExpiresIn?: string;
38
+ redisUrl?: string;
39
+ google?: {
40
+ clientId: string;
41
+ clientSecret: string;
42
+ callbackUrl: string;
43
+ };
44
+ github?: {
45
+ clientId: string;
46
+ clientSecret: string;
47
+ callbackUrl: string;
48
+ };
49
+ }
50
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,UAAU;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC;IACvB,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,kBAAkB,EAAE,MAAM,GAAG,IAAI,CAAC;IAClC,aAAa,EAAE,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,MAAM;IACrB,EAAE,EAAE,MAAM,CAAC;IACX,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,SAAS,EAAE,IAAI,GAAG,IAAI,CAAC;IACvB,UAAU,EAAE,IAAI,GAAG,IAAI,CAAC;IACxB,SAAS,EAAE,IAAI,CAAC;CACjB;AAED,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,UAAU;IACzB,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;CACjC;AAED,MAAM,WAAW,UAAU;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,gBAAgB,EAAE,MAAM,CAAC;IACzB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,YAAY,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,CAAC;IACzE,MAAM,CAAC,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,YAAY,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,CAAC;CAC1E"}
package/dist/types.js ADDED
@@ -0,0 +1,3 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@forjio/engine-auth",
3
+ "version": "0.1.0",
4
+ "main": "dist/index.js",
5
+ "types": "dist/index.d.ts",
6
+ "scripts": {
7
+ "build": "tsc",
8
+ "dev": "tsc --watch",
9
+ "type-check": "tsc --noEmit",
10
+ "test": "vitest run",
11
+ "test:watch": "vitest"
12
+ },
13
+ "dependencies": {
14
+ "jsonwebtoken": "^9.0.2",
15
+ "bcryptjs": "^2.4.3",
16
+ "passport": "^0.7.0",
17
+ "passport-google-oauth20": "^2.0.0",
18
+ "passport-github2": "^0.1.12",
19
+ "ioredis": "^5.3.2",
20
+ "express": "^4.18.2",
21
+ "uuid": "^9.0.0"
22
+ },
23
+ "devDependencies": {
24
+ "typescript": "^5.2.0",
25
+ "@types/jsonwebtoken": "^9.0.5",
26
+ "@types/bcryptjs": "^2.4.6",
27
+ "@types/passport": "^1.0.16",
28
+ "@types/passport-google-oauth20": "^2.0.14",
29
+ "@types/passport-github2": "^1.2.9",
30
+ "@types/express": "^4.17.21",
31
+ "@types/uuid": "^9.0.7",
32
+ "vitest": "^2.1.8"
33
+ }
34
+ }
@@ -0,0 +1,94 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ generateApiKey,
4
+ hashApiKey,
5
+ validateApiKeyFormat,
6
+ createApiKeyPair,
7
+ } from '../api-keys';
8
+
9
+ describe('api-keys', () => {
10
+ describe('generateApiKey', () => {
11
+ it('should generate key with ek_ prefix', () => {
12
+ const key = generateApiKey();
13
+ expect(key.startsWith('ek_')).toBe(true);
14
+ });
15
+
16
+ it('should generate unique keys', () => {
17
+ const key1 = generateApiKey();
18
+ const key2 = generateApiKey();
19
+ expect(key1).not.toBe(key2);
20
+ });
21
+
22
+ it('should generate key of expected length', () => {
23
+ const key = generateApiKey();
24
+ // ek_ prefix (3) + 32 bytes hex encoded (64) = 67 chars
25
+ expect(key.length).toBe(67);
26
+ });
27
+
28
+ it('should accept a custom prefix', () => {
29
+ const key = generateApiKey('custom_');
30
+ expect(key.startsWith('custom_')).toBe(true);
31
+ });
32
+ });
33
+
34
+ describe('hashApiKey', () => {
35
+ it('should return consistent hash for same key', () => {
36
+ const key = 'ek_abc123';
37
+ const hash1 = hashApiKey(key);
38
+ const hash2 = hashApiKey(key);
39
+ expect(hash1).toBe(hash2);
40
+ });
41
+
42
+ it('should return different hash for different key', () => {
43
+ const hash1 = hashApiKey('ek_key1');
44
+ const hash2 = hashApiKey('ek_key2');
45
+ expect(hash1).not.toBe(hash2);
46
+ });
47
+
48
+ it('should not be reversible (hash !== key)', () => {
49
+ const key = generateApiKey();
50
+ const hash = hashApiKey(key);
51
+ expect(hash).not.toBe(key);
52
+ expect(hash).not.toContain('ek_');
53
+ });
54
+ });
55
+
56
+ describe('validateApiKeyFormat', () => {
57
+ it('should accept valid ek_ prefixed key', () => {
58
+ const key = generateApiKey();
59
+ expect(validateApiKeyFormat(key)).toBe(true);
60
+ });
61
+
62
+ it('should reject key without prefix', () => {
63
+ const hex = 'a'.repeat(64);
64
+ expect(validateApiKeyFormat(hex)).toBe(false);
65
+ });
66
+
67
+ it('should reject empty string', () => {
68
+ expect(validateApiKeyFormat('')).toBe(false);
69
+ });
70
+
71
+ it('should reject key with wrong length', () => {
72
+ expect(validateApiKeyFormat('ek_tooshort')).toBe(false);
73
+ });
74
+
75
+ it('should reject key with non-hex characters', () => {
76
+ const badKey = 'ek_' + 'z'.repeat(64);
77
+ expect(validateApiKeyFormat(badKey)).toBe(false);
78
+ });
79
+ });
80
+
81
+ describe('createApiKeyPair', () => {
82
+ it('should return both plain key and hash', () => {
83
+ const pair = createApiKeyPair();
84
+ expect(pair.plainKey).toBeDefined();
85
+ expect(pair.hashedKey).toBeDefined();
86
+ expect(pair.plainKey).not.toBe(pair.hashedKey);
87
+ });
88
+
89
+ it('should return hash that matches hashing the plain key', () => {
90
+ const pair = createApiKeyPair();
91
+ expect(hashApiKey(pair.plainKey)).toBe(pair.hashedKey);
92
+ });
93
+ });
94
+ });
@@ -0,0 +1,47 @@
1
+ import { vi } from 'vitest';
2
+
3
+ /**
4
+ * Creates a mock Express Request object.
5
+ */
6
+ export function createMockReq(overrides: Record<string, unknown> = {}) {
7
+ return {
8
+ headers: {},
9
+ authUser: undefined,
10
+ apiKey: undefined,
11
+ ...overrides,
12
+ } as any;
13
+ }
14
+
15
+ /**
16
+ * Creates a mock Express Response object with chainable methods.
17
+ */
18
+ export function createMockRes() {
19
+ const res: any = {
20
+ statusCode: 200,
21
+ _json: null as unknown,
22
+ };
23
+
24
+ res.status = vi.fn((code: number) => {
25
+ res.statusCode = code;
26
+ return res;
27
+ });
28
+
29
+ res.json = vi.fn((data: unknown) => {
30
+ res._json = data;
31
+ return res;
32
+ });
33
+
34
+ res.send = vi.fn((data: unknown) => {
35
+ res._json = data;
36
+ return res;
37
+ });
38
+
39
+ return res;
40
+ }
41
+
42
+ /**
43
+ * Creates a mock Express next function.
44
+ */
45
+ export function createMockNext() {
46
+ return vi.fn();
47
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * In-memory Redis mock for testing session store.
3
+ * Implements the subset of ioredis methods used by session.ts.
4
+ */
5
+ export class RedisMock {
6
+ private store = new Map<string, string>();
7
+ private sets = new Map<string, Set<string>>();
8
+ private ttls = new Map<string, number>();
9
+
10
+ async get(key: string): Promise<string | null> {
11
+ return this.store.get(key) ?? null;
12
+ }
13
+
14
+ async setex(key: string, ttl: number, value: string): Promise<'OK'> {
15
+ this.store.set(key, value);
16
+ this.ttls.set(key, ttl);
17
+ return 'OK';
18
+ }
19
+
20
+ async del(...keys: string[]): Promise<number> {
21
+ let count = 0;
22
+ for (const key of keys) {
23
+ if (this.store.delete(key)) count++;
24
+ this.sets.delete(key);
25
+ this.ttls.delete(key);
26
+ }
27
+ return count;
28
+ }
29
+
30
+ async sadd(key: string, ...members: string[]): Promise<number> {
31
+ if (!this.sets.has(key)) this.sets.set(key, new Set());
32
+ const set = this.sets.get(key)!;
33
+ let added = 0;
34
+ for (const m of members) {
35
+ if (!set.has(m)) { set.add(m); added++; }
36
+ }
37
+ return added;
38
+ }
39
+
40
+ async srem(key: string, ...members: string[]): Promise<number> {
41
+ const set = this.sets.get(key);
42
+ if (!set) return 0;
43
+ let removed = 0;
44
+ for (const m of members) {
45
+ if (set.delete(m)) removed++;
46
+ }
47
+ return removed;
48
+ }
49
+
50
+ async smembers(key: string): Promise<string[]> {
51
+ const set = this.sets.get(key);
52
+ return set ? Array.from(set) : [];
53
+ }
54
+
55
+ async expire(_key: string, _seconds: number): Promise<number> {
56
+ return 1;
57
+ }
58
+
59
+ /** Helper: clear all data between tests */
60
+ clear(): void {
61
+ this.store.clear();
62
+ this.sets.clear();
63
+ this.ttls.clear();
64
+ }
65
+ }
@@ -0,0 +1,122 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import jwt from 'jsonwebtoken';
3
+ import {
4
+ signAccessToken,
5
+ verifyAccessToken,
6
+ signRefreshToken,
7
+ verifyRefreshToken,
8
+ signRememberMeToken,
9
+ } from '../jwt';
10
+ import { AuthConfig, JwtPayload } from '../types';
11
+
12
+ const TEST_CONFIG: AuthConfig = {
13
+ jwtSecret: 'test-access-secret-key-for-testing',
14
+ jwtRefreshSecret: 'test-refresh-secret-key-for-testing',
15
+ };
16
+
17
+ const TEST_PAYLOAD: JwtPayload = {
18
+ userId: 'user-123',
19
+ email: 'test@example.com',
20
+ role: 'user',
21
+ subscriptionTier: 'pro',
22
+ };
23
+
24
+ describe('jwt', () => {
25
+ describe('signAccessToken', () => {
26
+ it('should sign a valid JWT with correct expiry', () => {
27
+ const token = signAccessToken(TEST_PAYLOAD, TEST_CONFIG);
28
+
29
+ expect(token).toBeDefined();
30
+ expect(typeof token).toBe('string');
31
+
32
+ const decoded = jwt.decode(token) as jwt.JwtPayload;
33
+ expect(decoded.exp).toBeDefined();
34
+ // Default expiry is 15 minutes (900s)
35
+ const expectedExpiry = decoded.iat! + 900;
36
+ expect(decoded.exp).toBe(expectedExpiry);
37
+ });
38
+
39
+ it('should include userId, email, role in payload', () => {
40
+ const token = signAccessToken(TEST_PAYLOAD, TEST_CONFIG);
41
+ const decoded = jwt.decode(token) as jwt.JwtPayload;
42
+
43
+ expect(decoded.userId).toBe('user-123');
44
+ expect(decoded.email).toBe('test@example.com');
45
+ expect(decoded.role).toBe('user');
46
+ expect(decoded.subscriptionTier).toBe('pro');
47
+ });
48
+
49
+ it('should use custom expiry when configured', () => {
50
+ const config: AuthConfig = {
51
+ ...TEST_CONFIG,
52
+ jwtExpiresIn: '1h',
53
+ };
54
+
55
+ const token = signAccessToken(TEST_PAYLOAD, config);
56
+ const decoded = jwt.decode(token) as jwt.JwtPayload;
57
+
58
+ // 1 hour = 3600 seconds
59
+ const expectedExpiry = decoded.iat! + 3600;
60
+ expect(decoded.exp).toBe(expectedExpiry);
61
+ });
62
+ });
63
+
64
+ describe('verifyAccessToken', () => {
65
+ it('should verify and return payload for valid token', () => {
66
+ const token = signAccessToken(TEST_PAYLOAD, TEST_CONFIG);
67
+ const result = verifyAccessToken(token, TEST_CONFIG);
68
+
69
+ expect(result.userId).toBe('user-123');
70
+ expect(result.email).toBe('test@example.com');
71
+ expect(result.role).toBe('user');
72
+ expect(result.subscriptionTier).toBe('pro');
73
+ });
74
+
75
+ it('should throw for expired token', () => {
76
+ const expiredToken = jwt.sign(TEST_PAYLOAD, TEST_CONFIG.jwtSecret, {
77
+ expiresIn: -10, // already expired
78
+ });
79
+
80
+ expect(() => verifyAccessToken(expiredToken, TEST_CONFIG)).toThrow();
81
+ });
82
+
83
+ it('should throw for invalid signature', () => {
84
+ const token = jwt.sign(TEST_PAYLOAD, 'wrong-secret', {
85
+ expiresIn: 900,
86
+ });
87
+
88
+ expect(() => verifyAccessToken(token, TEST_CONFIG)).toThrow();
89
+ });
90
+
91
+ it('should throw for malformed token', () => {
92
+ expect(() => verifyAccessToken('not-a-jwt', TEST_CONFIG)).toThrow();
93
+ });
94
+ });
95
+
96
+ describe('signRefreshToken / verifyRefreshToken', () => {
97
+ it('should sign with refresh secret', () => {
98
+ const token = signRefreshToken(TEST_PAYLOAD, TEST_CONFIG);
99
+ const result = verifyRefreshToken(token, TEST_CONFIG);
100
+
101
+ expect(result.userId).toBe('user-123');
102
+ expect(result.email).toBe('test@example.com');
103
+ });
104
+
105
+ it('should reject access token verified with refresh secret', () => {
106
+ const accessToken = signAccessToken(TEST_PAYLOAD, TEST_CONFIG);
107
+
108
+ expect(() => verifyRefreshToken(accessToken, TEST_CONFIG)).toThrow();
109
+ });
110
+ });
111
+
112
+ describe('signRememberMeToken', () => {
113
+ it('should have 30d expiry', () => {
114
+ const token = signRememberMeToken(TEST_PAYLOAD, TEST_CONFIG);
115
+ const decoded = jwt.decode(token) as jwt.JwtPayload;
116
+
117
+ // 30 days = 2592000 seconds
118
+ const expectedExpiry = decoded.iat! + 30 * 24 * 60 * 60;
119
+ expect(decoded.exp).toBe(expectedExpiry);
120
+ });
121
+ });
122
+ });