firebase-admin-edge 1.1.0 → 1.1.1

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 (53) hide show
  1. package/README.md +27 -171
  2. package/dist/auth/auth-error-codes.d.ts +12 -0
  3. package/dist/auth/auth-error-codes.d.ts.map +1 -1
  4. package/dist/auth/auth-error-codes.js +12 -0
  5. package/dist/auth/auth-error-codes.js.map +1 -1
  6. package/dist/auth/email-action-request.d.ts +40 -0
  7. package/dist/auth/email-action-request.d.ts.map +1 -0
  8. package/dist/auth/email-action-request.js +80 -0
  9. package/dist/auth/email-action-request.js.map +1 -0
  10. package/dist/auth/email-action-request.test.d.ts +2 -0
  11. package/dist/auth/email-action-request.test.d.ts.map +1 -0
  12. package/dist/auth/email-action-request.test.js +128 -0
  13. package/dist/auth/email-action-request.test.js.map +1 -0
  14. package/dist/auth/firebase-admin-auth.d.ts +31 -8
  15. package/dist/auth/firebase-admin-auth.d.ts.map +1 -1
  16. package/dist/auth/firebase-admin-auth.js +272 -14
  17. package/dist/auth/firebase-admin-auth.js.map +1 -1
  18. package/dist/auth/firebase-admin-auth.test.js +625 -6
  19. package/dist/auth/firebase-admin-auth.test.js.map +1 -1
  20. package/dist/auth/firebase-auth-endpoints.d.ts +31 -0
  21. package/dist/auth/firebase-auth-endpoints.d.ts.map +1 -1
  22. package/dist/auth/firebase-auth-endpoints.js +55 -0
  23. package/dist/auth/firebase-auth-endpoints.js.map +1 -1
  24. package/dist/auth/firebase-auth-endpoints.test.js +126 -1
  25. package/dist/auth/firebase-auth-endpoints.test.js.map +1 -1
  26. package/dist/auth/user-batch.d.ts +21 -0
  27. package/dist/auth/user-batch.d.ts.map +1 -0
  28. package/dist/auth/user-batch.js +49 -0
  29. package/dist/auth/user-batch.js.map +1 -0
  30. package/dist/auth/user-batch.test.d.ts +2 -0
  31. package/dist/auth/user-batch.test.d.ts.map +1 -0
  32. package/dist/auth/user-batch.test.js +73 -0
  33. package/dist/auth/user-batch.test.js.map +1 -0
  34. package/dist/auth/user-import.d.ts +55 -0
  35. package/dist/auth/user-import.d.ts.map +1 -0
  36. package/dist/auth/user-import.js +208 -0
  37. package/dist/auth/user-import.js.map +1 -0
  38. package/dist/auth/user-import.test.d.ts +2 -0
  39. package/dist/auth/user-import.test.d.ts.map +1 -0
  40. package/dist/auth/user-import.test.js +217 -0
  41. package/dist/auth/user-import.test.js.map +1 -0
  42. package/dist/auth/user-request.d.ts +10 -0
  43. package/dist/auth/user-request.d.ts.map +1 -1
  44. package/dist/auth/user-request.js +56 -0
  45. package/dist/auth/user-request.js.map +1 -1
  46. package/dist/auth/user-request.test.js +59 -1
  47. package/dist/auth/user-request.test.js.map +1 -1
  48. package/dist/firebase-edge-server.d.ts.map +1 -1
  49. package/dist/firebase-edge-server.js +6 -2
  50. package/dist/firebase-edge-server.js.map +1 -1
  51. package/dist/firebase-edge-server.test.js +29 -0
  52. package/dist/firebase-edge-server.test.js.map +1 -1
  53. package/package.json +1 -1
@@ -1,10 +1,13 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
2
  import { FirebaseAdminAuth } from './firebase-admin-auth.js';
3
- import { getAccountsInfo, createAccountAdmin, updateAccountAdmin, deleteAccountAdmin, downloadAccount, getAccountInfo, createSessionCookie as createSessionCookieEndpoint } from './firebase-auth-endpoints.js';
3
+ import { generateEmailActionLink, deleteAccountsAdmin, importAccountsAdmin, getAccountsInfo, createAccountAdmin, updateAccountAdmin, deleteAccountAdmin, downloadAccount, getAccountInfo, createSessionCookie as createSessionCookieEndpoint } from './firebase-auth-endpoints.js';
4
4
  import { getToken } from './google-oauth.js';
5
5
  import { signJWTCustomToken, verifyJWT, verifySessionJWT } from './firebase-jwt.js';
6
6
  import { FirebaseEdgeError, FirebaseAdminAuthErrorInfo, FirebaseEndpointErrorInfo, JWTErrorInfo } from './errors.js';
7
7
  vi.mock('./firebase-auth-endpoints.js', () => ({
8
+ generateEmailActionLink: vi.fn(),
9
+ deleteAccountsAdmin: vi.fn(),
10
+ importAccountsAdmin: vi.fn(),
8
11
  getAccountsInfo: vi.fn(),
9
12
  createAccountAdmin: vi.fn(),
10
13
  updateAccountAdmin: vi.fn(),
@@ -81,6 +84,399 @@ describe('FirebaseAdminAuth', () => {
81
84
  afterEach(() => {
82
85
  vi.resetAllMocks();
83
86
  });
87
+ describe('getUserByProviderUid', () => {
88
+ it.each([
89
+ [
90
+ 'google.com',
91
+ 'google-123',
92
+ {
93
+ federatedUserId: [
94
+ { providerId: 'google.com', rawId: 'google-123' }
95
+ ]
96
+ }
97
+ ],
98
+ ['email', 'test@example.com', { email: ['test@example.com'] }],
99
+ ['phone', '+15551234567', { phoneNumber: ['+15551234567'] }]
100
+ ])('looks up %s using the existing batch helper', async (providerId, uid, request) => {
101
+ const fetchFn = vi.fn();
102
+ const admin = new FirebaseAdminAuth(serviceAccountKey, 'tenant', fetchFn);
103
+ mockedGetToken.mockResolvedValue({
104
+ data: mockGoogleTokenResponse,
105
+ error: null
106
+ });
107
+ vi.mocked(getAccountsInfo).mockResolvedValue({
108
+ data: {
109
+ users: [
110
+ {
111
+ ...mockUserRecord,
112
+ phoneNumber: '+15551234567',
113
+ providerUserInfo: [
114
+ {
115
+ providerId: 'google.com',
116
+ rawId: 'google-123'
117
+ }
118
+ ]
119
+ }
120
+ ]
121
+ },
122
+ error: null
123
+ });
124
+ const result = await admin.getUserByProviderUid(providerId, uid);
125
+ expect(result.error).toBeNull();
126
+ expect(result.data?.uid).toBe('uid-1');
127
+ expect(result.data?.toJSON()).toMatchObject({ uid: 'uid-1' });
128
+ expect(getAccountsInfo).toHaveBeenCalledWith(request, 'test-access-token', 'test-project', 'tenant', fetchFn);
129
+ });
130
+ it('returns user-not-found for an unmatched provider UID', async () => {
131
+ vi.spyOn(auth, 'getUsers').mockResolvedValue({
132
+ data: {
133
+ users: [],
134
+ notFound: [
135
+ { providerId: 'google.com', providerUid: 'missing' }
136
+ ]
137
+ },
138
+ error: null
139
+ });
140
+ expect((await auth.getUserByProviderUid('google.com', 'missing')).error
141
+ ?.code).toBe(FirebaseAdminAuthErrorInfo.ADMIN_USER_NOT_FOUND.code);
142
+ });
143
+ it.each([
144
+ ['', 'uid'],
145
+ ['google.com', ''],
146
+ [null, 'uid'],
147
+ ['google.com', null],
148
+ ['email', 'invalid'],
149
+ ['phone', 'invalid']
150
+ ])('rejects invalid identifiers %s/%s before authentication', async (providerId, uid) => {
151
+ expect((await auth.getUserByProviderUid(providerId, uid)).error).toBeInstanceOf(FirebaseEdgeError);
152
+ expect(mockedGetToken).not.toHaveBeenCalled();
153
+ });
154
+ it('preserves lookup failures', async () => {
155
+ const error = new FirebaseEdgeError(FirebaseAdminAuthErrorInfo.ADMIN_USER_LOOKUP_FAILED);
156
+ vi.spyOn(auth, 'getUsers').mockResolvedValue({ data: null, error });
157
+ expect(await auth.getUserByProviderUid('google.com', 'uid')).toEqual({ data: null, error });
158
+ });
159
+ });
160
+ describe('generateVerifyAndChangeEmailLink', () => {
161
+ it.each([undefined, { url: 'https://example.com/account' }])('generates a link with settings %j', async (settings) => {
162
+ const fetchFn = vi.fn();
163
+ const admin = new FirebaseAdminAuth(serviceAccountKey, 'tenant', fetchFn);
164
+ mockedGetToken.mockResolvedValue({
165
+ data: mockGoogleTokenResponse,
166
+ error: null
167
+ });
168
+ vi.mocked(generateEmailActionLink).mockResolvedValue({
169
+ data: 'https://example.com/action',
170
+ error: null
171
+ });
172
+ expect(await admin.generateVerifyAndChangeEmailLink('old@example.com', 'new@example.com', settings)).toEqual({ data: 'https://example.com/action', error: null });
173
+ expect(generateEmailActionLink).toHaveBeenCalledWith('test-project', {
174
+ requestType: 'VERIFY_AND_CHANGE_EMAIL',
175
+ email: 'old@example.com',
176
+ newEmail: 'new@example.com',
177
+ returnOobLink: true,
178
+ ...(settings
179
+ ? {
180
+ continueUrl: settings.url,
181
+ canHandleCodeInApp: false
182
+ }
183
+ : {})
184
+ }, 'test-access-token', fetchFn, 'tenant');
185
+ });
186
+ it.each([
187
+ ['invalid', 'new@example.com'],
188
+ ['old@example.com', 'invalid'],
189
+ ['old@example.com', undefined]
190
+ ])('rejects invalid addresses %s/%s', async (email, newEmail) => {
191
+ const result = await auth.generateVerifyAndChangeEmailLink(email, newEmail);
192
+ expect(result.error?.code).toBe(FirebaseAdminAuthErrorInfo.ADMIN_API_INVALID_ARGUMENT.code);
193
+ expect(mockedGetToken).not.toHaveBeenCalled();
194
+ });
195
+ it('returns credential errors without requesting a link', async () => {
196
+ mockedGetToken.mockResolvedValue({
197
+ data: null,
198
+ error: new Error('credentials')
199
+ });
200
+ expect((await auth.generateVerifyAndChangeEmailLink('old@example.com', 'new@example.com')).error?.code).toBe(FirebaseAdminAuthErrorInfo.ADMIN_SERVICE_ACCOUNT_TOKEN_FAILED
201
+ .code);
202
+ expect(generateEmailActionLink).not.toHaveBeenCalled();
203
+ });
204
+ it('wraps endpoint failures with their cause', async () => {
205
+ const cause = new Error('email already exists');
206
+ mockedGetToken.mockResolvedValue({
207
+ data: mockGoogleTokenResponse,
208
+ error: null
209
+ });
210
+ vi.mocked(generateEmailActionLink).mockResolvedValue({
211
+ data: null,
212
+ error: cause
213
+ });
214
+ const result = await auth.generateVerifyAndChangeEmailLink('old@example.com', 'new@example.com');
215
+ expect(result.error?.code).toBe(FirebaseAdminAuthErrorInfo.ADMIN_EMAIL_ACTION_LINK_FAILED.code);
216
+ expect(result.error?.cause).toBe(cause);
217
+ });
218
+ });
219
+ describe('setCustomUserClaims', () => {
220
+ beforeEach(() => {
221
+ mockedGetToken.mockResolvedValue({
222
+ data: mockGoogleTokenResponse,
223
+ error: null
224
+ });
225
+ vi.mocked(updateAccountAdmin).mockResolvedValue({
226
+ data: { localId: 'uid' },
227
+ error: null
228
+ });
229
+ });
230
+ it('uses the existing update helper with tenant and custom fetch', async () => {
231
+ const customFetch = vi.fn();
232
+ const instance = new FirebaseAdminAuth(serviceAccountKey, 'tenant', customFetch);
233
+ expect(await instance.setCustomUserClaims('uid', { role: 'editor' })).toEqual({ data: undefined, error: null });
234
+ expect(updateAccountAdmin).toHaveBeenCalledExactlyOnceWith('test-project', 'uid', { customAttributes: '{"role":"editor"}' }, 'test-access-token', customFetch, 'tenant');
235
+ expect(getAccountInfo).not.toHaveBeenCalled();
236
+ });
237
+ it('clears claims with null', async () => {
238
+ expect((await auth.setCustomUserClaims('uid', null)).error).toBeNull();
239
+ expect(updateAccountAdmin).toHaveBeenCalledWith('test-project', 'uid', { customAttributes: '{}' }, 'test-access-token', undefined, undefined);
240
+ });
241
+ it('validates UID and claims before authentication', async () => {
242
+ expect((await auth.setCustomUserClaims('', {})).error).not.toBeNull();
243
+ expect((await auth.setCustomUserClaims('a'.repeat(129), {})).error).not.toBeNull();
244
+ expect((await auth.setCustomUserClaims('uid', { sub: 'reserved' }))
245
+ .error).not.toBeNull();
246
+ expect(mockedGetToken).not.toHaveBeenCalled();
247
+ expect(updateAccountAdmin).not.toHaveBeenCalled();
248
+ });
249
+ it('uses cached credentials', async () => {
250
+ const cache = {
251
+ getCache: vi.fn().mockResolvedValue(mockGoogleTokenResponse),
252
+ setCache: vi.fn()
253
+ };
254
+ const instance = new FirebaseAdminAuth(serviceAccountKey, undefined, undefined, cache);
255
+ expect((await instance.setCustomUserClaims('uid', {})).error).toBeNull();
256
+ expect(mockedGetToken).not.toHaveBeenCalled();
257
+ });
258
+ it('returns credential failures without updating', async () => {
259
+ const error = new FirebaseEdgeError(FirebaseAdminAuthErrorInfo.ADMIN_SERVICE_ACCOUNT_TOKEN_FAILED);
260
+ mockedGetToken.mockResolvedValue({ data: null, error });
261
+ expect(await auth.setCustomUserClaims('uid', {})).toEqual({
262
+ data: null,
263
+ error
264
+ });
265
+ expect(updateAccountAdmin).not.toHaveBeenCalled();
266
+ });
267
+ it('guards against missing access tokens', async () => {
268
+ mockedGetToken.mockResolvedValue({
269
+ data: {},
270
+ error: null
271
+ });
272
+ expect((await auth.setCustomUserClaims('uid', {})).error?.code).toBe('auth/admin-no-token-returned');
273
+ expect(updateAccountAdmin).not.toHaveBeenCalled();
274
+ });
275
+ it('preserves endpoint errors including user-not-found', async () => {
276
+ const cause = new FirebaseEdgeError(FirebaseEndpointErrorInfo.ENDPOINT_USER_NOT_FOUND);
277
+ vi.mocked(updateAccountAdmin).mockResolvedValue({
278
+ data: null,
279
+ error: cause
280
+ });
281
+ const result = await auth.setCustomUserClaims('missing', {});
282
+ expect(result.data).toBeNull();
283
+ expect(result.error?.code).toBe('auth/admin-set-custom-claims-failed');
284
+ expect(result.error?.cause).toBe(cause);
285
+ });
286
+ it('rejects missing confirmation data', async () => {
287
+ vi.mocked(updateAccountAdmin).mockResolvedValue({
288
+ data: { localId: '' },
289
+ error: null
290
+ });
291
+ expect((await auth.setCustomUserClaims('uid', {})).error?.code).toBe('auth/admin-set-custom-claims-failed');
292
+ });
293
+ it('returns network and credential exceptions instead of rejecting', async () => {
294
+ const cause = new Error('offline');
295
+ vi.mocked(updateAccountAdmin).mockRejectedValue(cause);
296
+ expect((await auth.setCustomUserClaims('uid', {})).error?.cause).toBe(cause);
297
+ mockedGetToken.mockRejectedValue('token failure');
298
+ expect((await auth.setCustomUserClaims('uid', {})).error?.cause).toBeInstanceOf(Error);
299
+ });
300
+ });
301
+ describe('phone lookup and bulk user management', () => {
302
+ beforeEach(() => {
303
+ mockedGetToken.mockResolvedValue({
304
+ data: mockGoogleTokenResponse,
305
+ error: null
306
+ });
307
+ mockedGetAccountInfo.mockResolvedValue({
308
+ data: mockUserRecord,
309
+ error: null
310
+ });
311
+ vi.mocked(deleteAccountsAdmin).mockResolvedValue({
312
+ data: {},
313
+ error: null
314
+ });
315
+ vi.mocked(importAccountsAdmin).mockResolvedValue({
316
+ data: {},
317
+ error: null
318
+ });
319
+ });
320
+ it('looks up a phone number and returns a converted user record', async () => {
321
+ const customFetch = vi.fn();
322
+ const instance = new FirebaseAdminAuth(serviceAccountKey, 'tenant', customFetch);
323
+ const result = await instance.getUserByPhoneNumber('+15555550100');
324
+ expect(result.error).toBeNull();
325
+ expect(result.data?.uid).toBe('uid-1');
326
+ expect(result.data?.toJSON()).toMatchObject({ uid: 'uid-1' });
327
+ expect(getAccountInfo).toHaveBeenCalledWith({ phoneNumber: '+15555550100' }, 'test-access-token', 'test-project', 'tenant', customFetch);
328
+ });
329
+ it('returns user-not-found when no phone match exists', async () => {
330
+ mockedGetAccountInfo.mockResolvedValue({ data: null, error: null });
331
+ expect((await auth.getUserByPhoneNumber('+15555550100')).error?.code).toBe('auth/admin-user-not-found');
332
+ });
333
+ it.each(['', '555', null, 1])('rejects invalid phone number %s before authentication', async (phone) => {
334
+ expect((await auth.getUserByPhoneNumber(phone)).error).toBeInstanceOf(FirebaseEdgeError);
335
+ expect(mockedGetToken).not.toHaveBeenCalled();
336
+ });
337
+ it('deletes a batch with tenant, custom fetch, counts, and indexed errors', async () => {
338
+ const customFetch = vi.fn();
339
+ const instance = new FirebaseAdminAuth(serviceAccountKey, 'tenant', customFetch);
340
+ vi.mocked(deleteAccountsAdmin).mockResolvedValue({
341
+ data: { errors: [{ index: 1, message: 'failure' }] },
342
+ error: null
343
+ });
344
+ const result = await instance.deleteUsers([
345
+ 'one',
346
+ 'two',
347
+ 'missing'
348
+ ]);
349
+ expect(result.error).toBeNull();
350
+ expect(result.data).toMatchObject({
351
+ successCount: 2,
352
+ failureCount: 1,
353
+ errors: [{ index: 1, error: { message: 'failure' } }]
354
+ });
355
+ expect(deleteAccountsAdmin).toHaveBeenCalledWith('test-project', ['one', 'two', 'missing'], 'test-access-token', customFetch, 'tenant');
356
+ });
357
+ it('returns empty delete and import results without network requests', async () => {
358
+ const result = {
359
+ data: { successCount: 0, failureCount: 0, errors: [] },
360
+ error: null
361
+ };
362
+ expect(await auth.deleteUsers([])).toEqual(result);
363
+ expect(await auth.importUsers([])).toEqual(result);
364
+ expect(mockedGetToken).not.toHaveBeenCalled();
365
+ });
366
+ it('rejects invalid delete batches and oversized imports before authentication', async () => {
367
+ expect((await auth.deleteUsers(['one', ''])).error).not.toBeNull();
368
+ expect((await auth.deleteUsers(Array(1001).fill('uid'))).error).not.toBeNull();
369
+ expect((await auth.importUsers(Array(1001).fill({ uid: 'uid' }))).error).not.toBeNull();
370
+ expect(mockedGetToken).not.toHaveBeenCalled();
371
+ });
372
+ it('merges local and server import failures using original indices', async () => {
373
+ const customFetch = vi.fn();
374
+ const instance = new FirebaseAdminAuth(serviceAccountKey, 'tenant', customFetch);
375
+ vi.mocked(importAccountsAdmin).mockResolvedValue({
376
+ data: { error: [{ index: 1, message: 'DUPLICATE_LOCAL_ID' }] },
377
+ error: null
378
+ });
379
+ const result = await instance.importUsers([
380
+ { uid: '' },
381
+ { uid: 'one' },
382
+ { uid: 'two' }
383
+ ]);
384
+ expect(result.error).toBeNull();
385
+ expect(result.data).toMatchObject({
386
+ successCount: 1,
387
+ failureCount: 2,
388
+ errors: [{ index: 0 }, { index: 2 }]
389
+ });
390
+ expect(importAccountsAdmin).toHaveBeenCalledWith('test-project', { users: [{ localId: 'one' }, { localId: 'two' }] }, 'test-access-token', customFetch, 'tenant');
391
+ });
392
+ it('returns all-invalid imports locally and rejects missing password settings', async () => {
393
+ expect(await auth.importUsers([{ uid: '' }])).toMatchObject({
394
+ data: { successCount: 0, failureCount: 1 },
395
+ error: null
396
+ });
397
+ expect((await auth.importUsers([
398
+ { uid: 'uid', passwordHash: new Uint8Array([1]) }
399
+ ])).error).not.toBeNull();
400
+ expect(mockedGetToken).not.toHaveBeenCalled();
401
+ expect(importAccountsAdmin).not.toHaveBeenCalled();
402
+ });
403
+ it('sends the selected hashing configuration with password imports', async () => {
404
+ await auth.importUsers([{ uid: 'uid', passwordHash: new Uint8Array([1]) }], { hash: { algorithm: 'BCRYPT' } });
405
+ expect(importAccountsAdmin).toHaveBeenCalledWith('test-project', {
406
+ users: [{ localId: 'uid', passwordHash: 'AQ==' }],
407
+ hashAlgorithm: 'BCRYPT'
408
+ }, 'test-access-token', undefined, undefined);
409
+ });
410
+ it('uses cached credentials for all three methods', async () => {
411
+ const cache = {
412
+ getCache: vi.fn().mockResolvedValue(mockGoogleTokenResponse),
413
+ setCache: vi.fn()
414
+ };
415
+ const instance = new FirebaseAdminAuth(serviceAccountKey, undefined, undefined, cache);
416
+ expect((await instance.getUserByPhoneNumber('+15555550100')).error).toBeNull();
417
+ expect((await instance.deleteUsers(['uid'])).error).toBeNull();
418
+ expect((await instance.importUsers([{ uid: 'uid' }])).error).toBeNull();
419
+ expect(mockedGetToken).not.toHaveBeenCalled();
420
+ });
421
+ const operations = [
422
+ {
423
+ name: 'phone',
424
+ run: () => auth.getUserByPhoneNumber('+15555550100'),
425
+ endpoint: vi.mocked(getAccountInfo)
426
+ },
427
+ {
428
+ name: 'delete',
429
+ run: () => auth.deleteUsers(['uid']),
430
+ endpoint: vi.mocked(deleteAccountsAdmin)
431
+ },
432
+ {
433
+ name: 'import',
434
+ run: () => auth.importUsers([{ uid: 'uid' }]),
435
+ endpoint: vi.mocked(importAccountsAdmin)
436
+ }
437
+ ];
438
+ describe.each(operations)('$name failures', ({ run, endpoint }) => {
439
+ it('propagates token failures without calling the endpoint', async () => {
440
+ const error = new FirebaseEdgeError(FirebaseAdminAuthErrorInfo.ADMIN_SERVICE_ACCOUNT_TOKEN_FAILED);
441
+ mockedGetToken.mockResolvedValue({ data: null, error });
442
+ expect(await run()).toEqual({ data: null, error });
443
+ expect(endpoint).not.toHaveBeenCalled();
444
+ });
445
+ it('guards against a missing access token', async () => {
446
+ mockedGetToken.mockResolvedValue({
447
+ data: {},
448
+ error: null
449
+ });
450
+ expect((await run()).error?.code).toBe('auth/admin-no-token-returned');
451
+ expect(endpoint).not.toHaveBeenCalled();
452
+ });
453
+ it('returns endpoint errors and network exceptions', async () => {
454
+ const error = new FirebaseEdgeError(FirebaseEndpointErrorInfo.ENDPOINT_PERMISSION_DENIED);
455
+ endpoint.mockResolvedValue({ data: null, error });
456
+ expect(await run()).toMatchObject({
457
+ data: null,
458
+ error: expect.any(FirebaseEdgeError)
459
+ });
460
+ endpoint.mockRejectedValue(new Error('offline'));
461
+ expect(await run()).toMatchObject({
462
+ data: null,
463
+ error: expect.any(FirebaseEdgeError)
464
+ });
465
+ });
466
+ });
467
+ it('returns errors for corrupt bulk responses instead of misleading counts', async () => {
468
+ vi.mocked(deleteAccountsAdmin).mockResolvedValue({
469
+ data: { errors: [{ index: 5 }] },
470
+ error: null
471
+ });
472
+ vi.mocked(importAccountsAdmin).mockResolvedValue({
473
+ data: { error: [{ index: 5 }] },
474
+ error: null
475
+ });
476
+ expect((await auth.deleteUsers(['uid'])).error).not.toBeNull();
477
+ expect((await auth.importUsers([{ uid: 'uid' }])).error).not.toBeNull();
478
+ });
479
+ });
84
480
  describe('getUsers', () => {
85
481
  beforeEach(() => {
86
482
  mockedGetToken.mockResolvedValue({
@@ -688,13 +1084,186 @@ describe('FirebaseAdminAuth', () => {
688
1084
  expect(result.error).toBeNull();
689
1085
  });
690
1086
  });
1087
+ describe.each([
1088
+ ['generatePasswordResetLink', 'PASSWORD_RESET'],
1089
+ ['generateEmailVerificationLink', 'VERIFY_EMAIL'],
1090
+ ['generateSignInWithEmailLink', 'EMAIL_SIGNIN']
1091
+ ])('%s', (method, requestType) => {
1092
+ const settings = {
1093
+ url: 'https://example.com/finish',
1094
+ handleCodeInApp: true
1095
+ };
1096
+ it('returns the generated link and forwards credentials, settings, tenant and fetch', async () => {
1097
+ const fetchFn = vi.fn();
1098
+ const admin = new FirebaseAdminAuth(serviceAccountKey, 'tenant', fetchFn);
1099
+ mockedGetToken.mockResolvedValue({
1100
+ data: mockGoogleTokenResponse,
1101
+ error: null
1102
+ });
1103
+ vi.mocked(generateEmailActionLink).mockResolvedValue({
1104
+ data: 'https://example.com/action',
1105
+ error: null
1106
+ });
1107
+ expect(await admin[method]('person@example.com', settings)).toEqual({ data: 'https://example.com/action', error: null });
1108
+ expect(generateEmailActionLink).toHaveBeenCalledWith('test-project', {
1109
+ requestType,
1110
+ email: 'person@example.com',
1111
+ returnOobLink: true,
1112
+ continueUrl: settings.url,
1113
+ canHandleCodeInApp: true
1114
+ }, 'test-access-token', fetchFn, 'tenant');
1115
+ });
1116
+ it('rejects invalid input before obtaining credentials', async () => {
1117
+ expect((await auth[method]('invalid', settings)).error?.code).toBe(FirebaseAdminAuthErrorInfo.ADMIN_API_INVALID_ARGUMENT.code);
1118
+ expect(mockedGetToken).not.toHaveBeenCalled();
1119
+ expect(generateEmailActionLink).not.toHaveBeenCalled();
1120
+ });
1121
+ it('returns authentication errors without calling the endpoint', async () => {
1122
+ mockedGetToken.mockResolvedValue({
1123
+ data: null,
1124
+ error: new Error('credentials')
1125
+ });
1126
+ expect((await auth[method]('person@example.com', settings)).error?.code).toBe(FirebaseAdminAuthErrorInfo.ADMIN_SERVICE_ACCOUNT_TOKEN_FAILED
1127
+ .code);
1128
+ expect(generateEmailActionLink).not.toHaveBeenCalled();
1129
+ });
1130
+ it('wraps endpoint errors and preserves the cause', async () => {
1131
+ const cause = new Error('email not found');
1132
+ mockedGetToken.mockResolvedValue({
1133
+ data: mockGoogleTokenResponse,
1134
+ error: null
1135
+ });
1136
+ vi.mocked(generateEmailActionLink).mockResolvedValue({
1137
+ data: null,
1138
+ error: cause
1139
+ });
1140
+ const result = await auth[method]('person@example.com', settings);
1141
+ expect(result.error?.code).toBe(FirebaseAdminAuthErrorInfo.ADMIN_EMAIL_ACTION_LINK_FAILED.code);
1142
+ expect(result.error?.cause).toBe(cause);
1143
+ });
1144
+ it('returns unexpected failures using the package error convention', async () => {
1145
+ mockedGetToken.mockResolvedValue({
1146
+ data: mockGoogleTokenResponse,
1147
+ error: null
1148
+ });
1149
+ vi.mocked(generateEmailActionLink).mockRejectedValue(new Error('network'));
1150
+ expect((await auth[method]('person@example.com', settings)).error?.code).toBe(FirebaseAdminAuthErrorInfo.ADMIN_EMAIL_ACTION_LINK_FAILED.code);
1151
+ });
1152
+ });
1153
+ it.each([
1154
+ 'generatePasswordResetLink',
1155
+ 'generateEmailVerificationLink'
1156
+ ])('%s allows omitted settings', async (method) => {
1157
+ mockedGetToken.mockResolvedValue({
1158
+ data: mockGoogleTokenResponse,
1159
+ error: null
1160
+ });
1161
+ vi.mocked(generateEmailActionLink).mockResolvedValue({
1162
+ data: 'https://example.com/action',
1163
+ error: null
1164
+ });
1165
+ expect((await auth[method]('person@example.com')).error).toBeNull();
1166
+ expect(generateEmailActionLink).toHaveBeenCalledWith('test-project', expect.not.objectContaining({ continueUrl: expect.anything() }), 'test-access-token', undefined, undefined);
1167
+ });
1168
+ it('requires settings when generating an email sign-in link', async () => {
1169
+ const result =
1170
+ // @ts-expect-error Settings are required for email sign-in.
1171
+ await auth.generateSignInWithEmailLink('person@example.com');
1172
+ expect(result.error?.code).toBe(FirebaseAdminAuthErrorInfo.ADMIN_API_INVALID_ARGUMENT.code);
1173
+ expect(mockedGetToken).not.toHaveBeenCalled();
1174
+ });
1175
+ it.each([
1176
+ 'generatePasswordResetLink',
1177
+ 'generateEmailVerificationLink',
1178
+ 'generateSignInWithEmailLink',
1179
+ 'createSessionCookie'
1180
+ ])('%s rejects missing access tokens', async (method) => {
1181
+ mockedGetToken.mockResolvedValue({
1182
+ data: { ...mockGoogleTokenResponse, access_token: '' },
1183
+ error: null
1184
+ });
1185
+ const result = method === 'createSessionCookie'
1186
+ ? await auth.createSessionCookie('id-token', {
1187
+ expiresIn: 300000
1188
+ })
1189
+ : await auth[method]('person@example.com', {
1190
+ url: 'https://example.com'
1191
+ });
1192
+ expect(result.error?.code).toBe(FirebaseAdminAuthErrorInfo.ADMIN_NO_TOKEN_RETURNED.code);
1193
+ expect(generateEmailActionLink).not.toHaveBeenCalled();
1194
+ expect(mockedCreateSessionCookieEndpoint).not.toHaveBeenCalled();
1195
+ });
691
1196
  describe('createSessionCookie', () => {
1197
+ it.each([
1198
+ undefined,
1199
+ null,
1200
+ 3600000,
1201
+ {},
1202
+ { expiresIn: '3600000' },
1203
+ { expiresIn: NaN },
1204
+ { expiresIn: Infinity },
1205
+ { expiresIn: 299999 },
1206
+ { expiresIn: 1209600001 }
1207
+ ])('rejects invalid options %j before authentication', async (options) => {
1208
+ const result = await auth.createSessionCookie('id-token', options);
1209
+ expect(result.error?.code).toBe(FirebaseAdminAuthErrorInfo
1210
+ .ADMIN_SESSION_COOKIE_DURATION_INVALID.code);
1211
+ expect(mockedGetToken).not.toHaveBeenCalled();
1212
+ });
1213
+ it.each(['', null, 1])('rejects invalid ID token %s', async (idToken) => {
1214
+ const result = await auth.createSessionCookie(idToken, { expiresIn: 300000 });
1215
+ expect(result.error?.code).toBe(FirebaseAdminAuthErrorInfo.ADMIN_ID_TOKEN_INVALID.code);
1216
+ expect(mockedGetToken).not.toHaveBeenCalled();
1217
+ });
1218
+ it.each([300000, 1209600000])('accepts boundary lifetime %s and forwards tenant and fetch', async (expiresIn) => {
1219
+ const fetchFn = vi.fn();
1220
+ const admin = new FirebaseAdminAuth(serviceAccountKey, 'tenant', fetchFn);
1221
+ mockedGetToken.mockResolvedValue({
1222
+ data: mockGoogleTokenResponse,
1223
+ error: null
1224
+ });
1225
+ mockedCreateSessionCookieEndpoint.mockResolvedValue({
1226
+ data: 'cookie',
1227
+ error: null
1228
+ });
1229
+ expect(await admin.createSessionCookie('id-token', { expiresIn })).toEqual({ data: 'cookie', error: null });
1230
+ expect(mockedCreateSessionCookieEndpoint).toHaveBeenCalledWith('id-token', 'test-access-token', 'test-project', expiresIn, 'tenant', fetchFn);
1231
+ });
1232
+ it('returns an error for an empty cookie response', async () => {
1233
+ mockedGetToken.mockResolvedValue({
1234
+ data: mockGoogleTokenResponse,
1235
+ error: null
1236
+ });
1237
+ mockedCreateSessionCookieEndpoint.mockResolvedValue({
1238
+ data: null,
1239
+ error: null
1240
+ });
1241
+ const result = await auth.createSessionCookie('id-token', {
1242
+ expiresIn: 300000
1243
+ });
1244
+ expect(result.error?.code).toBe(FirebaseAdminAuthErrorInfo.ADMIN_SESSION_COOKIE_CREATE_FAILED
1245
+ .code);
1246
+ });
1247
+ it('wraps unexpected endpoint failures', async () => {
1248
+ mockedGetToken.mockResolvedValue({
1249
+ data: mockGoogleTokenResponse,
1250
+ error: null
1251
+ });
1252
+ mockedCreateSessionCookieEndpoint.mockRejectedValue(new Error('network'));
1253
+ const result = await auth.createSessionCookie('id-token', {
1254
+ expiresIn: 300000
1255
+ });
1256
+ expect(result.error?.code).toBe(FirebaseAdminAuthErrorInfo.ADMIN_SESSION_COOKIE_CREATE_FAILED
1257
+ .code);
1258
+ });
692
1259
  it('returns error when getToken fails', async () => {
693
1260
  mockedGetToken.mockResolvedValueOnce({
694
1261
  data: null,
695
1262
  error: new Error('unauthorized')
696
1263
  });
697
- const result = await auth.createSessionCookie('id-token', 3600);
1264
+ const result = await auth.createSessionCookie('id-token', {
1265
+ expiresIn: 3600000
1266
+ });
698
1267
  expect(result.data).toBeNull();
699
1268
  expect(result.error).toBeInstanceOf(FirebaseEdgeError);
700
1269
  expect(result.error.code).toBe(FirebaseAdminAuthErrorInfo.ADMIN_SERVICE_ACCOUNT_TOKEN_FAILED
@@ -705,7 +1274,9 @@ describe('FirebaseAdminAuth', () => {
705
1274
  data: null,
706
1275
  error: new FirebaseEdgeError(FirebaseAdminAuthErrorInfo.ADMIN_SERVICE_ACCOUNT_TOKEN_FAILED)
707
1276
  });
708
- const result = await auth.createSessionCookie('id-token', 3600);
1277
+ const result = await auth.createSessionCookie('id-token', {
1278
+ expiresIn: 3600000
1279
+ });
709
1280
  expect(result.data).toBeNull();
710
1281
  expect(result.error).toBeInstanceOf(FirebaseEdgeError);
711
1282
  expect(result.error.code).toBe(FirebaseAdminAuthErrorInfo.ADMIN_SERVICE_ACCOUNT_TOKEN_FAILED
@@ -721,8 +1292,10 @@ describe('FirebaseAdminAuth', () => {
721
1292
  data: null,
722
1293
  error: endpointError
723
1294
  });
724
- const result = await auth.createSessionCookie('id-token', 3600);
725
- expect(mockedCreateSessionCookieEndpoint).toHaveBeenCalledWith('id-token', 'test-access-token', 'test-project', 3600, undefined, undefined);
1295
+ const result = await auth.createSessionCookie('id-token', {
1296
+ expiresIn: 3600000
1297
+ });
1298
+ expect(mockedCreateSessionCookieEndpoint).toHaveBeenCalledWith('id-token', 'test-access-token', 'test-project', 3600000, undefined, undefined);
726
1299
  expect(result.data).toBeNull();
727
1300
  expect(result.error).toBeInstanceOf(FirebaseEdgeError);
728
1301
  expect(result.error.code).toBe(FirebaseAdminAuthErrorInfo.ADMIN_SESSION_COOKIE_CREATE_FAILED
@@ -738,12 +1311,58 @@ describe('FirebaseAdminAuth', () => {
738
1311
  data: cookieData,
739
1312
  error: null
740
1313
  });
741
- const result = await auth.createSessionCookie('id-token', 3600);
1314
+ const result = await auth.createSessionCookie('id-token', {
1315
+ expiresIn: 3600000
1316
+ });
742
1317
  expect(result.data).toEqual(cookieData);
743
1318
  expect(result.error).toBeNull();
744
1319
  });
745
1320
  });
746
1321
  describe('verifySessionCookie', () => {
1322
+ it('rejects disabled users when revocation checking is enabled', async () => {
1323
+ mockedVerifySessionJWT.mockResolvedValue({
1324
+ data: mockFirebasePayload,
1325
+ error: null
1326
+ });
1327
+ vi.spyOn(auth, 'getUser').mockResolvedValue({
1328
+ data: { ...mockUserRecord, disabled: true },
1329
+ error: null
1330
+ });
1331
+ expect((await auth.verifySessionCookie('cookie', true)).error?.code).toBe(FirebaseAdminAuthErrorInfo.ADMIN_USER_DISABLED.code);
1332
+ });
1333
+ it.each([
1334
+ [
1335
+ '1001',
1336
+ FirebaseAdminAuthErrorInfo.ADMIN_SESSION_COOKIE_REVOKED.code
1337
+ ],
1338
+ ['1000', undefined],
1339
+ ['999', undefined],
1340
+ [undefined, undefined]
1341
+ ])('checks auth_time against validSince %s, not issuance time', async (validSince, expectedCode) => {
1342
+ mockedVerifySessionJWT.mockResolvedValue({
1343
+ data: { ...mockFirebasePayload, iat: 1500 },
1344
+ error: null
1345
+ });
1346
+ vi.spyOn(auth, 'getUser').mockResolvedValue({
1347
+ data: { ...mockUserRecord, validSince },
1348
+ error: null
1349
+ });
1350
+ const result = await auth.verifySessionCookie('cookie', true);
1351
+ expect(result.error?.code).toBe(expectedCode);
1352
+ expect(result.data === null).toBe(Boolean(expectedCode));
1353
+ });
1354
+ it('skips the user lookup by default', async () => {
1355
+ mockedVerifySessionJWT.mockResolvedValue({
1356
+ data: mockFirebasePayload,
1357
+ error: null
1358
+ });
1359
+ const lookup = vi.spyOn(auth, 'getUser');
1360
+ expect(await auth.verifySessionCookie('cookie')).toEqual({
1361
+ data: mockFirebasePayload,
1362
+ error: null
1363
+ });
1364
+ expect(lookup).not.toHaveBeenCalled();
1365
+ });
747
1366
  it('returns error when verifySessionJWT fails', async () => {
748
1367
  mockedVerifySessionJWT.mockResolvedValueOnce({
749
1368
  data: null,