AtAuth
1import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2import {
3 decodeToken,
4 isTokenExpired,
5 getTokenRemainingSeconds,
6 getTokenAgeSeconds,
7 shouldRefreshToken,
8 getDisplayName,
9 isValidDid,
10 isValidHandle,
11} from './token';
12
13// Helper to create a valid token payload
14function createPayload(overrides = {}) {
15 const now = Math.floor(Date.now() / 1000);
16 return {
17 did: 'did:plc:abc123',
18 handle: 'alice.bsky.social',
19 user_id: 1,
20 app_id: 'testapp',
21 iat: now,
22 exp: now + 3600, // 1 hour
23 nonce: 'test-nonce',
24 ...overrides,
25 };
26}
27
28// Helper to encode a payload as a token (without signature)
29function encodePayload(payload: object): string {
30 const json = JSON.stringify(payload);
31 const b64 = Buffer.from(json).toString('base64url');
32 return `${b64}.fake-signature`;
33}
34
35describe('decodeToken', () => {
36 it('decodes a valid token', () => {
37 const payload = createPayload();
38 const token = encodePayload(payload);
39 const decoded = decodeToken(token);
40
41 expect(decoded).not.toBeNull();
42 expect(decoded?.did).toBe(payload.did);
43 expect(decoded?.handle).toBe(payload.handle);
44 });
45
46 it('returns null for invalid format (no dot)', () => {
47 expect(decodeToken('invalid-token')).toBeNull();
48 });
49
50 it('returns null for invalid format (too many dots)', () => {
51 expect(decodeToken('a.b.c')).toBeNull();
52 });
53
54 it('returns null for invalid base64', () => {
55 expect(decodeToken('!!!invalid!!!.signature')).toBeNull();
56 });
57
58 it('returns null for invalid JSON', () => {
59 const invalidJson = Buffer.from('not json').toString('base64url');
60 expect(decodeToken(`${invalidJson}.signature`)).toBeNull();
61 });
62});
63
64describe('isTokenExpired', () => {
65 beforeEach(() => {
66 vi.useFakeTimers();
67 });
68
69 afterEach(() => {
70 vi.useRealTimers();
71 });
72
73 it('returns false for non-expired token', () => {
74 const now = 1700000000000;
75 vi.setSystemTime(now);
76
77 const payload = createPayload({
78 iat: 1700000000,
79 exp: 1700003600, // 1 hour from now
80 });
81
82 expect(isTokenExpired(payload)).toBe(false);
83 });
84
85 it('returns true for expired token', () => {
86 const now = 1700010000000; // Well past exp
87 vi.setSystemTime(now);
88
89 const payload = createPayload({
90 iat: 1700000000,
91 exp: 1700003600,
92 });
93
94 expect(isTokenExpired(payload)).toBe(true);
95 });
96
97 it('respects clock skew tolerance', () => {
98 const now = 1700003610000; // 10 seconds past exp
99 vi.setSystemTime(now);
100
101 const payload = createPayload({
102 iat: 1700000000,
103 exp: 1700003600,
104 });
105
106 // With default 30s skew, should not be expired
107 expect(isTokenExpired(payload, 30)).toBe(false);
108
109 // With 0s skew, should be expired
110 expect(isTokenExpired(payload, 0)).toBe(true);
111 });
112});
113
114describe('getTokenRemainingSeconds', () => {
115 beforeEach(() => {
116 vi.useFakeTimers();
117 });
118
119 afterEach(() => {
120 vi.useRealTimers();
121 });
122
123 it('returns correct remaining time', () => {
124 const now = 1700000000000;
125 vi.setSystemTime(now);
126
127 const payload = createPayload({
128 exp: 1700003600, // 3600 seconds from now
129 });
130
131 expect(getTokenRemainingSeconds(payload)).toBe(3600);
132 });
133
134 it('returns 0 for expired token', () => {
135 const now = 1700010000000;
136 vi.setSystemTime(now);
137
138 const payload = createPayload({
139 exp: 1700003600,
140 });
141
142 expect(getTokenRemainingSeconds(payload)).toBe(0);
143 });
144});
145
146describe('getTokenAgeSeconds', () => {
147 beforeEach(() => {
148 vi.useFakeTimers();
149 });
150
151 afterEach(() => {
152 vi.useRealTimers();
153 });
154
155 it('returns correct age', () => {
156 const now = 1700001800000; // 1800 seconds after iat
157 vi.setSystemTime(now);
158
159 const payload = createPayload({
160 iat: 1700000000,
161 });
162
163 expect(getTokenAgeSeconds(payload)).toBe(1800);
164 });
165});
166
167describe('shouldRefreshToken', () => {
168 beforeEach(() => {
169 vi.useFakeTimers();
170 });
171
172 afterEach(() => {
173 vi.useRealTimers();
174 });
175
176 it('returns false when plenty of time remaining', () => {
177 const now = 1700000000000;
178 vi.setSystemTime(now);
179
180 const payload = createPayload({
181 exp: 1700003600, // 3600 seconds remaining
182 });
183
184 expect(shouldRefreshToken(payload, 300)).toBe(false);
185 });
186
187 it('returns true when below threshold', () => {
188 const now = 1700003400000; // 200 seconds remaining
189 vi.setSystemTime(now);
190
191 const payload = createPayload({
192 exp: 1700003600,
193 });
194
195 expect(shouldRefreshToken(payload, 300)).toBe(true);
196 });
197});
198
199describe('getDisplayName', () => {
200 it('extracts username from handle', () => {
201 expect(getDisplayName('alice.bsky.social')).toBe('alice');
202 });
203
204 it('handles single-part handle', () => {
205 expect(getDisplayName('alice')).toBe('alice');
206 });
207
208 it('handles empty string', () => {
209 expect(getDisplayName('')).toBe('');
210 });
211});
212
213describe('isValidDid', () => {
214 it('accepts valid did:plc', () => {
215 expect(isValidDid('did:plc:abc123')).toBe(true);
216 });
217
218 it('accepts valid did:web', () => {
219 expect(isValidDid('did:web:example.com')).toBe(true);
220 });
221
222 it('rejects empty string', () => {
223 expect(isValidDid('')).toBe(false);
224 });
225
226 it('rejects non-did string', () => {
227 expect(isValidDid('not-a-did')).toBe(false);
228 });
229
230 it('rejects did with missing parts', () => {
231 expect(isValidDid('did:plc')).toBe(false);
232 expect(isValidDid('did:')).toBe(false);
233 });
234
235 it('rejects oversized did', () => {
236 const longDid = 'did:plc:' + 'a'.repeat(600);
237 expect(isValidDid(longDid)).toBe(false);
238 });
239});
240
241describe('isValidHandle', () => {
242 it('accepts valid handle', () => {
243 expect(isValidHandle('alice.bsky.social')).toBe(true);
244 });
245
246 it('accepts short TLD', () => {
247 expect(isValidHandle('user.co')).toBe(true);
248 });
249
250 it('rejects empty string', () => {
251 expect(isValidHandle('')).toBe(false);
252 });
253
254 it('rejects handle without dot', () => {
255 expect(isValidHandle('alice')).toBe(false);
256 });
257
258 it('rejects single-char TLD', () => {
259 expect(isValidHandle('user.a')).toBe(false);
260 });
261
262 it('rejects oversized handle', () => {
263 const longHandle = 'a'.repeat(200) + '.com';
264 expect(isValidHandle(longHandle)).toBe(false);
265 });
266
267 it('rejects handle with empty segment', () => {
268 expect(isValidHandle('alice..social')).toBe(false);
269 expect(isValidHandle('.bsky.social')).toBe(false);
270 });
271});