@learncard/lca-api-service 1.3.1 → 1.3.2
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.
- package/CHANGELOG.md +16 -0
- package/dist/index.js +2 -2
- package/dist/routes/firebase.test.d.ts +1 -0
- package/package.json +9 -9
- package/src/routes/firebase.test.ts +94 -0
- package/src/routes/firebase.ts +9 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# @welibraryos/lca-api-service
|
|
2
2
|
|
|
3
|
+
## 1.3.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [#1588](https://github.com/learningeconomy/LearnCard/pull/1588) [`02ed52ec7d86b51cc45de29b4f741e596e8d7c0a`](https://github.com/learningeconomy/LearnCard/commit/02ed52ec7d86b51cc45de29b4f741e596e8d7c0a) Thanks [@goblincore](https://github.com/goblincore)! - Fix automatic email verification in local dev stacks
|
|
8
|
+
|
|
9
|
+
- Updated dependencies [[`2991bd32b03e26736239dd8e586e2f720d9bcd45`](https://github.com/learningeconomy/LearnCard/commit/2991bd32b03e26736239dd8e586e2f720d9bcd45), [`0c1bf9a8a33e6392d5fd279479d9ab4fb0449b5e`](https://github.com/learningeconomy/LearnCard/commit/0c1bf9a8a33e6392d5fd279479d9ab4fb0449b5e), [`0c1bf9a8a33e6392d5fd279479d9ab4fb0449b5e`](https://github.com/learningeconomy/LearnCard/commit/0c1bf9a8a33e6392d5fd279479d9ab4fb0449b5e), [`3174c09b97fde6f7b5251390245038cf6b7ea9ba`](https://github.com/learningeconomy/LearnCard/commit/3174c09b97fde6f7b5251390245038cf6b7ea9ba), [`928e587378b3674766cf58a8bbe1cbd4d66d3a9f`](https://github.com/learningeconomy/LearnCard/commit/928e587378b3674766cf58a8bbe1cbd4d66d3a9f), [`928e587378b3674766cf58a8bbe1cbd4d66d3a9f`](https://github.com/learningeconomy/LearnCard/commit/928e587378b3674766cf58a8bbe1cbd4d66d3a9f)]:
|
|
10
|
+
- @learncard/types@5.21.0
|
|
11
|
+
- @learncard/init@2.5.0
|
|
12
|
+
- @learncard/helpers@1.6.0
|
|
13
|
+
- @learncard/email-templates@1.1.0
|
|
14
|
+
- @learncard/core@9.4.36
|
|
15
|
+
- @learncard/did-web-plugin@1.1.36
|
|
16
|
+
- @learncard/didkit-plugin@1.10.1
|
|
17
|
+
- @learncard/didkit-plugin-node@0.3.1
|
|
18
|
+
|
|
3
19
|
## 1.3.1
|
|
4
20
|
|
|
5
21
|
### Patch Changes
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
[diffend] Oversized file quarantined before diffing.
|
|
2
2
|
name: package/dist/index.js
|
|
3
|
-
size:
|
|
4
|
-
sha256:
|
|
3
|
+
size: 120281915 bytes
|
|
4
|
+
sha256: 5ced11b8e911cdb7296d5c41f76d7e67fee6096c20a98f24ba308818feadfd6e
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@learncard/lca-api-service",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.2",
|
|
4
4
|
"description": "",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=24.12 <25"
|
|
@@ -50,14 +50,14 @@
|
|
|
50
50
|
"dependencies": {
|
|
51
51
|
"@fastify/cors": "^11.2.0",
|
|
52
52
|
"@fastify/static": "^10.1.1",
|
|
53
|
-
"@learncard/core": "9.4.
|
|
54
|
-
"@learncard/email-templates": "1.0
|
|
55
|
-
"@learncard/did-web-plugin": "1.1.
|
|
56
|
-
"@learncard/didkit-plugin": "1.10.
|
|
57
|
-
"@learncard/didkit-plugin-node": "0.3.
|
|
58
|
-
"@learncard/init": "2.
|
|
59
|
-
"@learncard/helpers": "1.
|
|
60
|
-
"@learncard/types": "5.
|
|
53
|
+
"@learncard/core": "9.4.36",
|
|
54
|
+
"@learncard/email-templates": "1.1.0",
|
|
55
|
+
"@learncard/did-web-plugin": "1.1.36",
|
|
56
|
+
"@learncard/didkit-plugin": "1.10.1",
|
|
57
|
+
"@learncard/didkit-plugin-node": "0.3.1",
|
|
58
|
+
"@learncard/init": "2.5.0",
|
|
59
|
+
"@learncard/helpers": "1.6.0",
|
|
60
|
+
"@learncard/types": "5.21.0",
|
|
61
61
|
"@sentry/serverless": "^7.61.0",
|
|
62
62
|
"@trpc/server": "11.8.0",
|
|
63
63
|
"@types/jsonwebtoken": "^9.0.8",
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const mocks = vi.hoisted(() => ({
|
|
4
|
+
environment: { IS_OFFLINE: true, IS_E2E_TEST: false },
|
|
5
|
+
verifyIdToken: vi.fn(),
|
|
6
|
+
getDidAuthVp: vi.fn(),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
vi.mock('@environment', () => ({ environment: mocks.environment }));
|
|
10
|
+
vi.mock('@firebase', () => ({ default: undefined }));
|
|
11
|
+
vi.mock('firebase-admin', () => ({
|
|
12
|
+
default: { auth: () => ({ verifyIdToken: mocks.verifyIdToken }) },
|
|
13
|
+
}));
|
|
14
|
+
vi.mock('@routes', async () => {
|
|
15
|
+
const { initTRPC } = await import('@trpc/server');
|
|
16
|
+
const t = initTRPC.create();
|
|
17
|
+
return { t, openRoute: t.procedure, authorizedDidRoute: t.procedure };
|
|
18
|
+
});
|
|
19
|
+
vi.mock('@helpers/learnCard.helpers', () => ({
|
|
20
|
+
getDidWebLearnCard: async () => ({ invoke: { getDidAuthVp: mocks.getDidAuthVp } }),
|
|
21
|
+
getEmptyLearnCard: vi.fn(),
|
|
22
|
+
}));
|
|
23
|
+
vi.mock('@cache', () => ({ default: {} }));
|
|
24
|
+
vi.mock('../services/delivery', () => ({ getDeliveryService: vi.fn(), getFrom: vi.fn() }));
|
|
25
|
+
vi.mock('../helpers/locale.helpers', () => ({ resolveLocaleByEmail: vi.fn() }));
|
|
26
|
+
vi.mock('@helpers/dids.helpers', () => ({ isAuthorizedDID: vi.fn() }));
|
|
27
|
+
|
|
28
|
+
import { firebaseRouter } from './firebase';
|
|
29
|
+
|
|
30
|
+
const tokenFor = (claims: Record<string, string>): string =>
|
|
31
|
+
`e30.${Buffer.from(JSON.stringify({ sub: 'local-user', ...claims })).toString('base64url')}.test`;
|
|
32
|
+
const caller = firebaseRouter.createCaller({} as never);
|
|
33
|
+
|
|
34
|
+
describe('getProofOfLoginVp', () => {
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
vi.restoreAllMocks();
|
|
37
|
+
vi.clearAllMocks();
|
|
38
|
+
mocks.environment.IS_OFFLINE = true;
|
|
39
|
+
mocks.environment.IS_E2E_TEST = false;
|
|
40
|
+
mocks.getDidAuthVp.mockResolvedValue('signed-proof');
|
|
41
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('includes the login email without Firebase Admin credentials in local dev', async () => {
|
|
45
|
+
await expect(
|
|
46
|
+
caller.getProofOfLoginVp({ token: tokenFor({ email: 'billy@example.com' }) })
|
|
47
|
+
).resolves.toEqual({ success: true, vp: 'signed-proof' });
|
|
48
|
+
expect(mocks.getDidAuthVp).toHaveBeenCalledWith({
|
|
49
|
+
proofFormat: 'jwt',
|
|
50
|
+
challenge: 'proof-of-login:email:billy@example.com',
|
|
51
|
+
});
|
|
52
|
+
expect(mocks.verifyIdToken).not.toHaveBeenCalled();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('supports the e2e token path and Firebase phone_number claim', async () => {
|
|
56
|
+
mocks.environment.IS_OFFLINE = false;
|
|
57
|
+
mocks.environment.IS_E2E_TEST = true;
|
|
58
|
+
await caller.getProofOfLoginVp({ token: tokenFor({ phone_number: '+15555550123' }) });
|
|
59
|
+
expect(mocks.getDidAuthVp).toHaveBeenCalledWith({
|
|
60
|
+
proofFormat: 'jwt',
|
|
61
|
+
challenge: 'proof-of-login:phone:+15555550123',
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('does not sign a proof when the token has no contact method', async () => {
|
|
66
|
+
expect(await caller.getProofOfLoginVp({ token: tokenFor({}) })).toMatchObject({
|
|
67
|
+
success: false,
|
|
68
|
+
});
|
|
69
|
+
expect(mocks.getDidAuthVp).not.toHaveBeenCalled();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('requires Firebase verification outside local and e2e environments', async () => {
|
|
73
|
+
mocks.environment.IS_OFFLINE = false;
|
|
74
|
+
mocks.verifyIdToken.mockRejectedValueOnce(new Error('Invalid signature'));
|
|
75
|
+
expect(
|
|
76
|
+
await caller.getProofOfLoginVp({ token: tokenFor({ email: 'forged@example.com' }) })
|
|
77
|
+
).toMatchObject({ success: false });
|
|
78
|
+
expect(mocks.verifyIdToken).toHaveBeenCalledOnce();
|
|
79
|
+
expect(mocks.getDidAuthVp).not.toHaveBeenCalled();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('uses verified claims in production', async () => {
|
|
83
|
+
mocks.environment.IS_OFFLINE = false;
|
|
84
|
+
mocks.verifyIdToken.mockResolvedValueOnce({
|
|
85
|
+
uid: 'real-user',
|
|
86
|
+
email: 'verified@example.com',
|
|
87
|
+
});
|
|
88
|
+
await caller.getProofOfLoginVp({ token: 'firebase-token' });
|
|
89
|
+
expect(mocks.getDidAuthVp).toHaveBeenCalledWith({
|
|
90
|
+
proofFormat: 'jwt',
|
|
91
|
+
challenge: 'proof-of-login:email:verified@example.com',
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
});
|
package/src/routes/firebase.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { resolveLocaleByEmail } from '../helpers/locale.helpers';
|
|
|
20
20
|
import jwtDecode from 'jwt-decode';
|
|
21
21
|
import { getDidWebLearnCard, getEmptyLearnCard } from '@helpers/learnCard.helpers';
|
|
22
22
|
import { isAuthorizedDID } from '@helpers/dids.helpers';
|
|
23
|
+
import { verifyFirebaseToken, getContactMethodFromUser } from '@helpers/auth.helpers';
|
|
23
24
|
|
|
24
25
|
const LOGIN_VERIFICATION_CODE_TEMPLATE_ALIAS =
|
|
25
26
|
environment.POSTMARK_LOGIN_CODE_TEMPLATE_ALIAS || 'login-verification-code';
|
|
@@ -501,16 +502,16 @@ export const firebaseRouter = t.router({
|
|
|
501
502
|
const { token } = input;
|
|
502
503
|
|
|
503
504
|
try {
|
|
504
|
-
|
|
505
|
+
// Match sign-in's local/e2e token handling while retaining Firebase
|
|
506
|
+
// verification outside those environments.
|
|
507
|
+
const user = await verifyFirebaseToken(token);
|
|
508
|
+
const contactMethod = getContactMethodFromUser(user);
|
|
509
|
+
if (!contactMethod) {
|
|
510
|
+
throw new Error('Login token does not contain an email or phone number');
|
|
511
|
+
}
|
|
505
512
|
|
|
506
513
|
const learnCard = await getDidWebLearnCard();
|
|
507
|
-
|
|
508
|
-
let proofOfLoginChallenge = 'proof-of-login:';
|
|
509
|
-
if (decodedToken?.email) {
|
|
510
|
-
proofOfLoginChallenge += 'email:' + decodedToken?.email;
|
|
511
|
-
} else if (decodedToken?.phoneNumber) {
|
|
512
|
-
proofOfLoginChallenge += 'phone:' + decodedToken?.phoneNumber;
|
|
513
|
-
}
|
|
514
|
+
const proofOfLoginChallenge = `proof-of-login:${contactMethod.type}:${contactMethod.value}`;
|
|
514
515
|
|
|
515
516
|
const result = await learnCard.invoke.getDidAuthVp({
|
|
516
517
|
proofFormat: 'jwt',
|