@learncard/lca-api-service 1.4.0 → 1.5.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 (191) hide show
  1. package/.env.example +73 -0
  2. package/AGENTS.md +106 -10
  3. package/CHANGELOG.md +52 -0
  4. package/bootstrapLambda.test.ts +76 -0
  5. package/bundle.mjs +9 -1
  6. package/config/config.dev.json +23 -0
  7. package/config/config.example.json +22 -0
  8. package/config/config.json +1 -0
  9. package/config/config.production.json +12 -0
  10. package/config/config.scouts.dev.json +11 -0
  11. package/config/config.scouts.production.json +11 -0
  12. package/dist/app.d.ts +67 -3
  13. package/dist/cache/getDel.d.ts +2 -0
  14. package/dist/cache/getDel.test.d.ts +1 -0
  15. package/dist/cache/login-tickets.d.ts +12 -0
  16. package/dist/config/applyDockerStageConfig.d.ts +1 -0
  17. package/dist/config/cacheEnvironment.d.ts +16 -0
  18. package/dist/config/environment.d.ts +101 -0
  19. package/dist/config/escrowBundleMode.test.d.ts +1 -0
  20. package/dist/config/escrowDeployment.test.d.ts +1 -0
  21. package/dist/config/oidcEnvironment.d.ts +74 -0
  22. package/dist/config/serverlessFunctionEnv.test.d.ts +1 -0
  23. package/dist/config/stageConfig.d.ts +62 -0
  24. package/dist/config/stageConfig.test.d.ts +1 -0
  25. package/dist/didkit_wasm_bg.wasm +0 -0
  26. package/dist/docker-entry.d.ts +1 -1
  27. package/dist/helpers/auth.helpers.d.ts +1 -1
  28. package/dist/helpers/escrowCancelLink.d.ts +7 -0
  29. package/dist/helpers/escrowCancelLink.test.d.ts +1 -0
  30. package/dist/helpers/escrowCancelToken.d.ts +4 -0
  31. package/dist/helpers/escrowCancelToken.test.d.ts +1 -0
  32. package/dist/helpers/keycloak.helpers.d.ts +23 -0
  33. package/dist/helpers/login-ticket.helpers.d.ts +31 -0
  34. package/dist/helpers/login-ticket.helpers.test.d.ts +1 -0
  35. package/dist/helpers/oidc.helpers.d.ts +47 -0
  36. package/dist/helpers/rate-limit.helpers.d.ts +6 -0
  37. package/dist/helpers/rate-limit.helpers.test.d.ts +1 -0
  38. package/dist/helpers/social-token.helpers.d.ts +19 -0
  39. package/dist/index.d.ts +1 -0
  40. package/dist/index.js +2 -2
  41. package/dist/jobs/escrowBlobRewrap.d.ts +9 -0
  42. package/dist/jobs/escrowHoldReminders.d.ts +12 -0
  43. package/dist/models/AuthSubject.d.ts +20 -0
  44. package/dist/models/EscrowHold.d.ts +79 -1
  45. package/dist/models/Notification.d.ts +5 -0
  46. package/dist/models/UserKey.d.ts +11 -1
  47. package/dist/models/index.d.ts +1 -0
  48. package/dist/oidc.d.ts +5 -0
  49. package/dist/publish-metafile.json +1 -0
  50. package/dist/routes/auth.d.ts +31 -0
  51. package/dist/routes/escrow.d.ts +18 -4
  52. package/dist/routes/keys.d.ts +1 -0
  53. package/dist/routes/notifications.d.ts +5 -0
  54. package/dist/routes/signing-authority.d.ts +14 -0
  55. package/dist/services/escrow-enclave/blobIdentity.d.ts +10 -0
  56. package/dist/services/escrow-enclave/blobIdentity.test.d.ts +1 -0
  57. package/dist/services/escrow-enclave/cancellation.d.ts +3 -0
  58. package/dist/services/escrow-enclave/enclaveTrustAnchors.d.ts +1 -0
  59. package/dist/services/escrow-enclave/index.d.ts +16 -0
  60. package/dist/services/escrow-enclave/notifications.d.ts +19 -4
  61. package/dist/services/escrow-enclave/notifications.test.d.ts +1 -0
  62. package/dist/services/escrow-enclave/production.test.d.ts +1 -0
  63. package/dist/services/escrow-enclave/remoteEnclave.contract.test.d.ts +1 -0
  64. package/dist/services/escrow-enclave/remoteEnclave.d.ts +20 -0
  65. package/dist/services/escrow-enclave/remoteEnclave.test.d.ts +1 -0
  66. package/dist/services/escrow-enclave/remoteEnclave.tls.test.d.ts +1 -0
  67. package/dist/services/escrow-enclave/softwareEnclave.d.ts +12 -2
  68. package/dist/services/escrow-enclave/types.d.ts +52 -8
  69. package/dist/types/notifications.d.ts +5 -0
  70. package/esbuildPlugins.cjs +1 -0
  71. package/lambda.ts +27 -117
  72. package/lambdaApp.ts +146 -0
  73. package/oidcLambda.test.ts +38 -0
  74. package/oidcLambda.ts +18 -0
  75. package/oidcLambdaApp.ts +11 -0
  76. package/package.json +22 -13
  77. package/project.json +8 -1
  78. package/scripts/README.md +88 -0
  79. package/scripts/check-seed-encryption-infrastructure.mjs +18 -2
  80. package/scripts/escrow-blob-mode-report.test.ts +227 -0
  81. package/scripts/escrow-blob-mode-report.ts +465 -0
  82. package/scripts/keycloak-admin.ts +108 -0
  83. package/scripts/keycloak-provider-links.ts +147 -0
  84. package/scripts/print-seed-rollout-flags.ts +21 -0
  85. package/scripts/provision-keycloak-users.ts +419 -0
  86. package/seedMigrationApp.test.ts +145 -0
  87. package/seedMigrationApp.ts +50 -0
  88. package/seedMigrationLambda.test.ts +30 -135
  89. package/seedMigrationLambda.ts +15 -46
  90. package/serverless-local.yml +25 -1
  91. package/serverless.function-env.cjs +58 -0
  92. package/serverless.yml +174 -37
  93. package/src/accesslayer/notifications/index.ts +9 -2
  94. package/src/accesslayer/signing-authority/index.ts +4 -1
  95. package/src/app.ts +2 -0
  96. package/src/cache/getDel.test.ts +34 -0
  97. package/src/cache/getDel.ts +32 -0
  98. package/src/cache/index.ts +1 -1
  99. package/src/cache/login-tickets.ts +26 -0
  100. package/src/config/applyDockerStageConfig.ts +6 -0
  101. package/src/config/cacheEnvironment.ts +34 -0
  102. package/src/config/environment.test.ts +45 -2
  103. package/src/config/environment.ts +90 -23
  104. package/src/config/escrowBundleMode.test.ts +52 -0
  105. package/src/config/escrowDeployment.test.ts +114 -0
  106. package/src/config/oidcEnvironment.ts +59 -0
  107. package/src/config/serverlessFunctionEnv.test.ts +98 -0
  108. package/src/config/stageConfig.test.ts +26 -0
  109. package/src/config/stageConfig.ts +24 -0
  110. package/src/docker-entry.ts +9 -0
  111. package/src/helpers/auth.helpers.ts +68 -30
  112. package/src/helpers/escrowCancelLink.test.ts +60 -0
  113. package/src/helpers/escrowCancelLink.ts +38 -0
  114. package/src/helpers/escrowCancelToken.test.ts +105 -0
  115. package/src/helpers/escrowCancelToken.ts +47 -0
  116. package/src/helpers/keycloak.helpers.ts +120 -0
  117. package/src/helpers/login-ticket.helpers.test.ts +45 -0
  118. package/src/helpers/login-ticket.helpers.ts +91 -0
  119. package/src/helpers/oidc.helpers.ts +354 -0
  120. package/src/helpers/rate-limit.helpers.test.ts +61 -0
  121. package/src/helpers/rate-limit.helpers.ts +73 -0
  122. package/src/helpers/social-token.helpers.ts +144 -0
  123. package/src/index.ts +1 -0
  124. package/src/jobs/escrowBlobRewrap.ts +121 -0
  125. package/src/jobs/escrowHoldReminders.ts +177 -0
  126. package/src/models/AuthSubject.ts +147 -0
  127. package/src/models/EscrowHold.ts +321 -5
  128. package/src/models/UserKey.ts +91 -6
  129. package/src/models/index.ts +1 -0
  130. package/src/oidc.ts +371 -0
  131. package/src/routes/auth.ts +173 -0
  132. package/src/routes/escrow.ts +281 -34
  133. package/src/routes/keys.ts +5 -0
  134. package/src/routes/signing-authority.ts +39 -1
  135. package/src/services/delivery/adapters/postmark.adapter.ts +43 -1
  136. package/src/services/escrow-enclave/README.md +81 -10
  137. package/src/services/escrow-enclave/__fixtures__/tls/pinned-ca.pem +11 -0
  138. package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.key +5 -0
  139. package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.pem +12 -0
  140. package/src/services/escrow-enclave/__fixtures__/tls/rogue-ca.pem +11 -0
  141. package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.key +5 -0
  142. package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.pem +12 -0
  143. package/src/services/escrow-enclave/blobIdentity.test.ts +42 -0
  144. package/src/services/escrow-enclave/blobIdentity.ts +55 -0
  145. package/src/services/escrow-enclave/cancellation.ts +59 -0
  146. package/src/services/escrow-enclave/enclaveTrustAnchors.ts +34 -0
  147. package/src/services/escrow-enclave/index.ts +145 -7
  148. package/src/services/escrow-enclave/notifications.test.ts +377 -0
  149. package/src/services/escrow-enclave/notifications.ts +340 -6
  150. package/src/services/escrow-enclave/production.test.ts +34 -0
  151. package/src/services/escrow-enclave/remoteEnclave.contract.test.ts +344 -0
  152. package/src/services/escrow-enclave/remoteEnclave.test.ts +490 -0
  153. package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +62 -0
  154. package/src/services/escrow-enclave/remoteEnclave.ts +263 -0
  155. package/src/services/escrow-enclave/softwareEnclave.test.ts +231 -30
  156. package/src/services/escrow-enclave/softwareEnclave.ts +97 -11
  157. package/src/services/escrow-enclave/types.ts +63 -17
  158. package/test/auth-tickets.spec.ts +480 -0
  159. package/test/helpers/keycloak-broker.ts +181 -0
  160. package/test/helpers/live-mongo.ts +9 -0
  161. package/test/jobs/escrowBlobRewrap.spec.ts +262 -0
  162. package/test/jobs/escrowHoldReminders.spec.ts +319 -0
  163. package/test/keycloak-admin.spec.ts +58 -0
  164. package/test/keycloak-broker-roundtrip.integration.spec.ts +40 -0
  165. package/test/keycloak-migration.integration.spec.ts +266 -0
  166. package/test/keycloak-provider-links.spec.ts +201 -0
  167. package/test/keycloak-social-broker.integration.spec.ts +228 -0
  168. package/test/keycloak-verify.integration.spec.ts +100 -0
  169. package/test/keycloak-verify.spec.ts +307 -0
  170. package/test/keys-escrow.spec.ts +963 -24
  171. package/test/models/authSubjectIndexes.spec.ts +132 -0
  172. package/test/models/escrow.spec.ts +375 -13
  173. package/test/notifications.spec.ts +4 -0
  174. package/test/oidc-e2e/broker.spec.ts +116 -0
  175. package/test/oidc-e2e/compose.yaml +33 -0
  176. package/test/oidc-e2e/fixtures.ts +245 -0
  177. package/test/oidc-e2e/lock.test.mjs +73 -0
  178. package/test/oidc-e2e/playwright.config.ts +21 -0
  179. package/test/oidc-e2e/run.mjs +312 -0
  180. package/test/oidc-e2e/tsconfig.json +10 -0
  181. package/test/oidc-e2e/types.ts +21 -0
  182. package/test/oidc.integration.spec.ts +54 -0
  183. package/test/oidc.spec.ts +697 -0
  184. package/test/oidcLambdaApp.boot.spec.ts +105 -0
  185. package/test/provision-keycloak-users.spec.ts +299 -0
  186. package/test/signing-authority.spec.ts +50 -0
  187. package/tsconfig.build.json +1 -1
  188. package/tsconfig.json +1 -0
  189. package/vitest.config.ts +24 -1
  190. package/vitest.integration.config.ts +20 -3
  191. package/serverless.escrow-env.cjs +0 -20
@@ -5,6 +5,16 @@
5
5
  import { environment } from '@environment';
6
6
  import { TRPCError } from '@trpc/server';
7
7
  import admin from 'firebase-admin';
8
+ import { z } from 'zod';
9
+ import { getKeycloakVerifyOptionsFromEnv, verifyKeycloakJwt } from './keycloak.helpers';
10
+
11
+ const offlineFirebaseClaimsSchema = z.object({
12
+ sub: z.string().optional(),
13
+ uid: z.string().optional(),
14
+ user_id: z.string().optional(),
15
+ email: z.string().nullish(),
16
+ phone_number: z.string().nullish(),
17
+ });
8
18
 
9
19
  export interface VerifiedUser {
10
20
  id: string;
@@ -15,31 +25,35 @@ export interface VerifiedUser {
15
25
 
16
26
  export type AuthProviderType = 'firebase' | 'supertokens' | 'keycloak' | 'oidc';
17
27
 
28
+ const parseOfflineFirebaseClaims = (
29
+ token: string
30
+ ): z.infer<typeof offlineFirebaseClaimsSchema> | undefined => {
31
+ const payloadPart = token.split('.')[1];
32
+ if (!payloadPart) return undefined;
33
+ try {
34
+ const decoded: unknown = JSON.parse(Buffer.from(payloadPart, 'base64url').toString());
35
+ return offlineFirebaseClaimsSchema.parse(decoded);
36
+ } catch {
37
+ return undefined;
38
+ }
39
+ };
40
+
18
41
  export async function verifyFirebaseToken(token: string): Promise<VerifiedUser> {
19
42
  // E2E test or offline bypass - parse JWT without Firebase Admin verification
20
43
  if (environment.IS_E2E_TEST || environment.IS_OFFLINE) {
21
- try {
22
- const parts = token.split('.');
23
- const payloadPart = parts[1];
24
- if (parts.length >= 2 && payloadPart) {
25
- // Try base64url first, then regular base64
26
- let payload;
27
- try {
28
- payload = JSON.parse(Buffer.from(payloadPart, 'base64url').toString());
29
- } catch {
30
- payload = JSON.parse(Buffer.from(payloadPart, 'base64').toString());
31
- }
32
- return {
33
- id: payload.sub || payload.uid || payload.user_id || 'offline-user',
34
- email: payload.email,
35
- phone: payload.phone_number,
36
- providerType: 'firebase',
37
- };
38
- }
39
- } catch (e) {
40
- console.warn('Failed to parse JWT in offline mode:', e);
41
- // Fall through to normal verification
44
+ const payload = parseOfflineFirebaseClaims(token);
45
+ if (payload) {
46
+ return {
47
+ id: payload.sub || payload.uid || payload.user_id || 'offline-user',
48
+ email: payload.email ?? undefined,
49
+ phone: payload.phone_number ?? undefined,
50
+ providerType: 'firebase',
51
+ };
42
52
  }
53
+ throw new TRPCError({
54
+ code: 'UNAUTHORIZED',
55
+ message: 'Could not decode Firebase token in offline/E2E mode',
56
+ });
43
57
  }
44
58
 
45
59
  try {
@@ -52,7 +66,7 @@ export async function verifyFirebaseToken(token: string): Promise<VerifiedUser>
52
66
  providerType: 'firebase',
53
67
  };
54
68
  } catch (error) {
55
- console.error('Firebase token verification failed:', error);
69
+ console.warn('Firebase token verification failed:', error);
56
70
  throw new TRPCError({
57
71
  code: 'UNAUTHORIZED',
58
72
  message: 'Invalid Firebase token',
@@ -70,14 +84,38 @@ export async function verifySuperTokensToken(_token: string): Promise<VerifiedUs
70
84
  });
71
85
  }
72
86
 
73
- export async function verifyKeycloakToken(_token: string): Promise<VerifiedUser> {
74
- // Keycloak verification would go here
75
- // This would use the existing verifyKeycloakToken from firebase.helpers.ts
76
- // but return a VerifiedUser instead of creating a Firebase user
77
- throw new TRPCError({
78
- code: 'NOT_IMPLEMENTED',
79
- message: 'Keycloak verification not yet implemented for SSS',
80
- });
87
+ export async function verifyKeycloakToken(token: string): Promise<VerifiedUser> {
88
+ const options = getKeycloakVerifyOptionsFromEnv();
89
+ if (!options) {
90
+ throw new TRPCError({
91
+ code: 'UNAUTHORIZED',
92
+ message: 'Keycloak verification is not configured',
93
+ });
94
+ }
95
+ let claims;
96
+ try {
97
+ claims = await verifyKeycloakJwt(token, options);
98
+ } catch {
99
+ throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid Keycloak token' });
100
+ }
101
+ if (claims.email !== undefined && claims.email_verified !== true) {
102
+ throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Email address is not verified' });
103
+ }
104
+ if (claims.phone_number !== undefined && claims.phone_number_verified !== true) {
105
+ throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Phone number is not verified' });
106
+ }
107
+ if (!claims.email && !claims.phone_number) {
108
+ throw new TRPCError({
109
+ code: 'UNAUTHORIZED',
110
+ message: 'Token has no verified contact method',
111
+ });
112
+ }
113
+ return {
114
+ id: claims.sub,
115
+ email: claims.email,
116
+ phone: claims.phone_number,
117
+ providerType: 'keycloak',
118
+ };
81
119
  }
82
120
 
83
121
  export async function verifyOidcToken(_token: string): Promise<VerifiedUser> {
@@ -0,0 +1,60 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { buildEscrowCancelUrl, resolveEscrowAppBaseUrl } from './escrowCancelLink';
4
+
5
+ describe('buildEscrowCancelUrl', () => {
6
+ const holdId = '5b1f6e2e-6e2a-4e8a-9e2a-6e2a4e8a9e2a';
7
+ const token = 'a'.repeat(64);
8
+
9
+ it('builds the expected path and query', () => {
10
+ expect(buildEscrowCancelUrl({ baseUrl: 'https://learncard.app', holdId, token })).toBe(
11
+ `https://learncard.app/recovery/cancel?holdId=${holdId}&token=${token}`
12
+ );
13
+ });
14
+
15
+ it('never doubles the slash when baseUrl has a trailing slash', () => {
16
+ expect(buildEscrowCancelUrl({ baseUrl: 'https://learncard.app/', holdId, token })).toBe(
17
+ `https://learncard.app/recovery/cancel?holdId=${holdId}&token=${token}`
18
+ );
19
+ });
20
+
21
+ it('percent-encodes query values that need it', () => {
22
+ const url = buildEscrowCancelUrl({
23
+ baseUrl: 'https://learncard.app',
24
+ holdId: 'weird id/with&chars',
25
+ token,
26
+ });
27
+ expect(url).toBe(
28
+ `https://learncard.app/recovery/cancel?holdId=weird+id%2Fwith%26chars&token=${token}`
29
+ );
30
+ expect(new URL(url).searchParams.get('holdId')).toBe('weird id/with&chars');
31
+ });
32
+
33
+ it('ignores any path segment already present on baseUrl (host + protocol only, matching every tenant appUrl in the registry)', () => {
34
+ expect(buildEscrowCancelUrl({ baseUrl: 'https://vetpass.app', holdId, token })).toBe(
35
+ `https://vetpass.app/recovery/cancel?holdId=${holdId}&token=${token}`
36
+ );
37
+ });
38
+ });
39
+
40
+ describe('resolveEscrowAppBaseUrl', () => {
41
+ it('falls back to the LearnCard default when the tenant has no appUrl override', () => {
42
+ expect(
43
+ resolveEscrowAppBaseUrl({
44
+ tenant: { id: 'learncard', emailBranding: {}, resolvedVia: 'default' },
45
+ })
46
+ ).toBe('https://learncard.app');
47
+ });
48
+
49
+ it('prefers the resolved tenant appUrl', () => {
50
+ expect(
51
+ resolveEscrowAppBaseUrl({
52
+ tenant: {
53
+ id: 'vetpass',
54
+ emailBranding: { appUrl: 'https://vetpass.app' },
55
+ resolvedVia: 'origin',
56
+ },
57
+ })
58
+ ).toBe('https://vetpass.app');
59
+ });
60
+ });
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Escrow recovery cancel-link deep-link builder.
3
+ *
4
+ * Mirrors the existing tenant-branded app-link pattern used elsewhere in the
5
+ * monorepo (brain-service's `generateClaimUrl` / `generateGuardianApprovalUrl`,
6
+ * `inbox.helpers.ts`'s `new URL('/notifications', appUrl)`): prefer the
7
+ * resolved tenant's `TenantBranding.appUrl`, which already falls back to the
8
+ * LearnCard default (`https://learncard.app`) via `resolveBranding()` when a
9
+ * tenant has no override.
10
+ */
11
+
12
+ import { resolveBranding } from '@learncard/email-templates';
13
+ import type { Context } from '@routes';
14
+
15
+ /** Base app URL (e.g. `https://vetpass.app`) for constructing escrow deep links. */
16
+ export const resolveEscrowAppBaseUrl = (ctx: Pick<Context, 'tenant'>): string =>
17
+ resolveBranding(ctx.tenant?.emailBranding).appUrl;
18
+
19
+ /**
20
+ * Builds the single-use "cancel this recovery" link sent in the hold-started
21
+ * email. Uses `URL`/`URLSearchParams` rather than string concatenation so
22
+ * `holdId`/`token` are correctly percent-encoded and a trailing slash on
23
+ * `baseUrl` never produces a doubled `//recovery/cancel`.
24
+ */
25
+ export const buildEscrowCancelUrl = ({
26
+ baseUrl,
27
+ holdId,
28
+ token,
29
+ }: {
30
+ baseUrl: string;
31
+ holdId: string;
32
+ token: string;
33
+ }): string => {
34
+ const url = new URL('/recovery/cancel', baseUrl);
35
+ url.searchParams.set('holdId', holdId);
36
+ url.searchParams.set('token', token);
37
+ return url.toString();
38
+ };
@@ -0,0 +1,105 @@
1
+ import { describe, it, expect } from 'vitest';
2
+
3
+ import {
4
+ generateEscrowCancelToken,
5
+ hashEscrowCancelToken,
6
+ escrowCancelTokenMatches,
7
+ } from './escrowCancelToken';
8
+
9
+ import type { EscrowHold } from '@models';
10
+
11
+ const baseHold = (overrides: Partial<EscrowHold> = {}): EscrowHold => {
12
+ const now = new Date();
13
+ return {
14
+ holdRecord: {
15
+ hold: {
16
+ holdId: 'hold-id',
17
+ did: 'did:key:test',
18
+ shareVersion: 1,
19
+ blobHash: 'ab'.repeat(32),
20
+ enrollmentEpoch: 1,
21
+ releasePolicy: 'hold',
22
+ clientEphemeralPublicKey: 'public-key',
23
+ createdLo: 0,
24
+ createdHi: 0,
25
+ policyVersion: 1,
26
+ signature: 'test-signature',
27
+ },
28
+ holdDurationMs: 0,
29
+ ledgerSeq: 0,
30
+ },
31
+ _id: 'hold-id',
32
+ authProvider: { type: 'firebase', id: 'user-1' },
33
+ primaryDid: 'did:key:test',
34
+ shareVersion: 1,
35
+ status: 'pending',
36
+ identityProofType: 'auth-token',
37
+ requestedAt: now,
38
+ releaseAfter: now,
39
+ releasePolicy: 'hold',
40
+ clientEphemeralPublicKey: 'public-key',
41
+ resumeTokenHash: 'a'.repeat(64),
42
+ notifications: [],
43
+ createdAt: now,
44
+ updatedAt: now,
45
+ ...overrides,
46
+ };
47
+ };
48
+
49
+ describe('escrowCancelToken helpers', () => {
50
+ it('generates unique, high-entropy hex tokens', () => {
51
+ const token = generateEscrowCancelToken();
52
+
53
+ expect(token).toMatch(/^[0-9a-f]{64}$/);
54
+ expect(generateEscrowCancelToken()).not.toBe(token);
55
+ });
56
+
57
+ it('hashes deterministically without ever equaling the plaintext', () => {
58
+ const token = generateEscrowCancelToken();
59
+ const hash = hashEscrowCancelToken(token);
60
+
61
+ expect(hash).toMatch(/^[0-9a-f]{64}$/);
62
+ expect(hash).not.toBe(token);
63
+ expect(hashEscrowCancelToken(token)).toBe(hash);
64
+ });
65
+
66
+ it('matches the correct token against a pending, unused hold', () => {
67
+ const token = generateEscrowCancelToken();
68
+ const hold = baseHold({ cancelTokenHash: hashEscrowCancelToken(token) });
69
+
70
+ expect(escrowCancelTokenMatches(hold, token)).toBe(true);
71
+ });
72
+
73
+ it('rejects a wrong token', () => {
74
+ const token = generateEscrowCancelToken();
75
+ const hold = baseHold({ cancelTokenHash: hashEscrowCancelToken(token) });
76
+
77
+ expect(escrowCancelTokenMatches(hold, generateEscrowCancelToken())).toBe(false);
78
+ });
79
+
80
+ it('rejects an already-used token', () => {
81
+ const token = generateEscrowCancelToken();
82
+ const hold = baseHold({
83
+ cancelTokenHash: hashEscrowCancelToken(token),
84
+ cancelTokenUsedAt: new Date(),
85
+ });
86
+
87
+ expect(escrowCancelTokenMatches(hold, token)).toBe(false);
88
+ });
89
+
90
+ it('rejects a non-pending hold even with a correct, unused token', () => {
91
+ const token = generateEscrowCancelToken();
92
+
93
+ for (const status of ['cancelled', 'completed', 'expired'] as const) {
94
+ const hold = baseHold({ status, cancelTokenHash: hashEscrowCancelToken(token) });
95
+
96
+ expect(escrowCancelTokenMatches(hold, token)).toBe(false);
97
+ }
98
+ });
99
+
100
+ it('rejects when no cancel token was ever issued on the hold', () => {
101
+ const hold = baseHold();
102
+
103
+ expect(escrowCancelTokenMatches(hold, generateEscrowCancelToken())).toBe(false);
104
+ });
105
+ });
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Escrow recovery cancel-link token.
3
+ *
4
+ * Mirrors the resume-token primitive in `models/EscrowHold.ts`: a random,
5
+ * high-entropy token whose SHA-256 hash is stored on the hold so the
6
+ * plaintext never needs to be persisted. There is no HMAC key involved —
7
+ * the token's own entropy is the secret, exactly like `resumeTokenHash`.
8
+ *
9
+ * The token is single-use (burned via `cancelTokenUsedAt`) and its
10
+ * effective TTL is the hold's own lifetime: it stops matching the moment
11
+ * the hold leaves the `pending` status (cancelled, completed, or expired),
12
+ * so no separate expiry timestamp is needed.
13
+ *
14
+ * Never log the plaintext token.
15
+ */
16
+
17
+ import { createHash, randomBytes, timingSafeEqual } from 'crypto';
18
+
19
+ import type { EscrowHold } from '@models';
20
+
21
+ /** 256 bits of entropy, hex-encoded — same shape as `generateEscrowResumeToken`. */
22
+ export const generateEscrowCancelToken = (): string => randomBytes(32).toString('hex');
23
+
24
+ /** Plain SHA-256, matching `hashEscrowResumeToken` — the token's own entropy is the secret. */
25
+ export const hashEscrowCancelToken = (token: string): string =>
26
+ createHash('sha256').update(token).digest('hex');
27
+
28
+ /**
29
+ * Timing-safe cancel-token check.
30
+ *
31
+ * Returns `false` (never throws) when the hold has no cancel token, the
32
+ * token was already used, or the hold is no longer `pending` — a cancel
33
+ * link's effective TTL is the hold's own lifetime, not a separate clock.
34
+ */
35
+ export const escrowCancelTokenMatches = (hold: EscrowHold, token: string): boolean => {
36
+ if (hold.status !== 'pending' || hold.cancelTokenUsedAt) return false;
37
+ const actual = Buffer.from(hashEscrowCancelToken(token), 'hex');
38
+ const hashes = [
39
+ ...(hold.cancelTokenHashes ?? []),
40
+ ...(hold.cancelTokenHash ? [hold.cancelTokenHash] : []),
41
+ ];
42
+ // Evaluate every hash, including after a match, rather than short-circuiting.
43
+ return hashes.reduce((matches, hash) => {
44
+ const expected = Buffer.from(hash, 'hex');
45
+ return (actual.length === expected.length && timingSafeEqual(actual, expected)) || matches;
46
+ }, false);
47
+ };
@@ -0,0 +1,120 @@
1
+ import { environment } from '@environment';
2
+ import { TRPCError } from '@trpc/server';
3
+ import { createRemoteJWKSet, decodeJwt, decodeProtectedHeader, jwtVerify } from 'jose';
4
+ import type { JWTVerifyGetKey } from 'jose';
5
+ import { z } from 'zod';
6
+
7
+ export interface KeycloakVerifyOptions {
8
+ issuers: string[];
9
+ audiences: string[];
10
+ }
11
+
12
+ export interface KeycloakClaims {
13
+ sub: string;
14
+ iss: string;
15
+ typ?: string;
16
+ azp?: string;
17
+ aud?: string | string[];
18
+ email?: string;
19
+ email_verified?: boolean;
20
+ phone_number?: string;
21
+ phone_number_verified?: boolean;
22
+ name?: string;
23
+ picture?: string;
24
+ }
25
+
26
+ const claimsSchema = z.object({
27
+ sub: z.string().min(1),
28
+ iss: z.string(),
29
+ // The payload typ is a Keycloak extension, not a required OIDC claim.
30
+ typ: z.enum(['ID', 'Bearer']).optional(),
31
+ azp: z.string().optional(),
32
+ aud: z.union([z.string(), z.array(z.string())]).optional(),
33
+ email: z.string().min(1).optional(),
34
+ email_verified: z.boolean().optional(),
35
+ phone_number: z.string().min(1).optional(),
36
+ phone_number_verified: z.boolean().optional(),
37
+ name: z.string().optional(),
38
+ picture: z.string().optional(),
39
+ });
40
+
41
+ const jwksByIssuer = new Map<string, { url: string; resolver: JWTVerifyGetKey }>();
42
+ let resolverForTests: ((issuer: string) => JWTVerifyGetKey) | undefined;
43
+
44
+ /** Replace JWKS resolution in isolated tests; resetting also clears memoized resolvers. */
45
+ export const setKeycloakJwksResolverForTests = (
46
+ resolver: ((issuer: string) => JWTVerifyGetKey) | undefined
47
+ ): void => {
48
+ resolverForTests = resolver;
49
+ jwksByIssuer.clear();
50
+ };
51
+
52
+ /** Resolve an issuer's JWKS endpoint, optionally using a configured internal hostname. */
53
+ export const getKeycloakIssuerJwksUrl = (issuer: string): string => {
54
+ for (const entry of (environment.KEYCLOAK_JWKS_URL_OVERRIDES ?? '').split(',')) {
55
+ const separator = entry.indexOf('=');
56
+ if (separator < 0) continue;
57
+ const configuredIssuer = entry.slice(0, separator).trim();
58
+ const url = entry.slice(separator + 1).trim();
59
+ if (configuredIssuer === issuer && url) return url;
60
+ }
61
+ return `${issuer}/protocol/openid-connect/certs`;
62
+ };
63
+
64
+ /** Reuse remote key caches and rotation handling per trusted issuer. */
65
+ export const getKeycloakJwks = (issuer: string): JWTVerifyGetKey => {
66
+ const url = getKeycloakIssuerJwksUrl(issuer);
67
+ const cached = jwksByIssuer.get(issuer);
68
+ if (cached?.url === url) return cached.resolver;
69
+ const resolver = resolverForTests ? resolverForTests(issuer) : createRemoteJWKSet(new URL(url));
70
+ jwksByIssuer.set(issuer, { url, resolver });
71
+ return resolver;
72
+ };
73
+
74
+ /** Verify signature and claims, checking the issuer allowlist before resolving keys. */
75
+ export const verifyKeycloakJwt = async (
76
+ token: string,
77
+ opts: KeycloakVerifyOptions
78
+ ): Promise<KeycloakClaims> => {
79
+ decodeProtectedHeader(token);
80
+ const { iss } = decodeJwt(token);
81
+ if (typeof iss !== 'string' || !opts.issuers.includes(iss)) {
82
+ throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid Keycloak token' });
83
+ }
84
+
85
+ const { payload } = await jwtVerify(token, getKeycloakJwks(iss), {
86
+ issuer: iss,
87
+ algorithms: ['RS256', 'ES256'],
88
+ clockTolerance: 30,
89
+ });
90
+ const result = claimsSchema.safeParse(payload);
91
+ if (!result.success) {
92
+ throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid Keycloak token' });
93
+ }
94
+ const claims = result.data;
95
+ const audiences = typeof claims.aud === 'string' ? [claims.aud] : (claims.aud ?? []);
96
+ if (claims.azp) audiences.push(claims.azp);
97
+ if (!audiences.some(audience => opts.audiences.includes(audience))) {
98
+ throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid Keycloak token' });
99
+ }
100
+ return claims;
101
+ };
102
+
103
+ /** Read comma-separated issuer and client allowlists from the service configuration. */
104
+ export const getKeycloakVerifyOptionsFromEnv = (): KeycloakVerifyOptions | null => {
105
+ const split = (value: string | undefined): string[] =>
106
+ (value ?? '')
107
+ .split(',')
108
+ .map(entry => entry.trim())
109
+ .filter(Boolean);
110
+ const issuers = split(environment.KEYCLOAK_ISSUERS);
111
+ if (!issuers.length) return null;
112
+ const audiences = split(environment.KEYCLOAK_AUDIENCES);
113
+ if (!audiences.length) {
114
+ throw new TRPCError({
115
+ code: 'INTERNAL_SERVER_ERROR',
116
+ message: 'KEYCLOAK_AUDIENCES is required when KEYCLOAK_ISSUERS is configured',
117
+ });
118
+ }
119
+ return { issuers, audiences };
120
+ };
@@ -0,0 +1,45 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import cache from '@cache';
3
+ import { issueLoginTicket } from '@cache/login-tickets';
4
+ import { storeAccessToken, storeAuthorizationCode } from './login-ticket.helpers';
5
+
6
+ vi.mock('@environment', () => ({ environment: {} }));
7
+
8
+ afterEach(() => vi.restoreAllMocks());
9
+
10
+ describe('authentication artifact persistence', () => {
11
+ const writers = [
12
+ {
13
+ name: 'login ticket',
14
+ write: () =>
15
+ issueLoginTicket({ subject: 'subject', identityKey: 'email:test@example.com' }),
16
+ },
17
+ {
18
+ name: 'authorization code',
19
+ write: () =>
20
+ storeAuthorizationCode({
21
+ subject: 'subject',
22
+ code: 'code',
23
+ scope: 'openid',
24
+ clientId: 'broker',
25
+ redirectUri: 'https://broker.test/callback',
26
+ createdAt: Date.now(),
27
+ }),
28
+ },
29
+ {
30
+ name: 'access token',
31
+ write: () =>
32
+ storeAccessToken('access-token', { subject: 'subject', createdAt: Date.now() }),
33
+ },
34
+ ];
35
+
36
+ it.each(writers)('rejects a swallowed $name write failure', async ({ name, write }) => {
37
+ vi.spyOn(cache, 'set').mockResolvedValueOnce(undefined);
38
+ await expect(write()).rejects.toThrow(`Failed to persist ${name}`);
39
+ });
40
+
41
+ it.each(writers)('propagates a rejected $name write', async ({ write }) => {
42
+ vi.spyOn(cache, 'set').mockRejectedValueOnce(new Error('Redis unavailable'));
43
+ await expect(write()).rejects.toThrow('Redis unavailable');
44
+ });
45
+ });
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Login tickets, authorization codes, and opaque access tokens for the lca-api
3
+ * OIDC provider (AD-2).
4
+ *
5
+ * Three short-lived, one-time-use artifacts back the "silent hop" login:
6
+ * - A **login ticket** is minted after lca-api proves ownership of a contact
7
+ * method / federated identity (email code, native Google/Apple). It carries
8
+ * the verified claims and the stable subject id.
9
+ * - An **authorization code** is minted by `/oidc/authorize` when it redeems a
10
+ * login ticket (passed by Keycloak as `login_hint`). Keycloak exchanges the
11
+ * code at `/oidc/token` for the signed `id_token`. The code is bound to the
12
+ * client + redirect URI it was issued for.
13
+ * - An **opaque access token** is minted alongside the id_token so Keycloak's
14
+ * broker can call `/oidc/userinfo`. It maps to the same verified claims.
15
+ *
16
+ * All live in the cache (Redis in prod, in-memory mock in tests) with short
17
+ * TTLs. Tickets and codes are consumed (read-then-delete) so neither can be
18
+ * replayed; the access token is read-only for its short life.
19
+ */
20
+
21
+ import crypto from 'node:crypto';
22
+
23
+ import cache from '@cache';
24
+ import { getDel } from '@cache/getDel';
25
+
26
+ export const AUTH_CODE_PREFIX = 'oidc-code:';
27
+ export const ACCESS_TOKEN_PREFIX = 'oidc:access:';
28
+
29
+ export const AUTH_CODE_TTL_SECONDS = 60;
30
+ export const ACCESS_TOKEN_TTL_SECONDS = 300;
31
+
32
+ export interface SubjectClaims {
33
+ subject: string;
34
+ email?: string;
35
+ emailVerified?: boolean;
36
+ phoneNumber?: string;
37
+ phoneNumberVerified?: boolean;
38
+ name?: string;
39
+ picture?: string;
40
+ }
41
+
42
+ export interface AuthorizationCodeData extends SubjectClaims {
43
+ scope: string;
44
+ code: string;
45
+ clientId: string;
46
+ redirectUri: string;
47
+ nonce?: string;
48
+ state?: string;
49
+ createdAt: number;
50
+ }
51
+
52
+ export interface AccessTokenData extends SubjectClaims {
53
+ createdAt: number;
54
+ }
55
+
56
+ const codeKey = (code: string): string => `${AUTH_CODE_PREFIX}${code}`;
57
+ const accessKey = (accessToken: string): string => `${ACCESS_TOKEN_PREFIX}${accessToken}`;
58
+
59
+ export const generateAuthorizationCode = (): string => crypto.randomBytes(32).toString('base64url');
60
+ export const generateAccessToken = (): string => crypto.randomBytes(32).toString('base64url');
61
+
62
+ export const storeAuthorizationCode = async (
63
+ data: AuthorizationCodeData,
64
+ ttl = AUTH_CODE_TTL_SECONDS
65
+ ): Promise<void> => {
66
+ const result = await cache.set(codeKey(data.code), JSON.stringify(data), ttl);
67
+ if (result !== 'OK') throw new Error('Failed to persist authorization code');
68
+ };
69
+
70
+ export const consumeAuthorizationCode = async (
71
+ code: string
72
+ ): Promise<AuthorizationCodeData | null> => {
73
+ const raw = await getDel(codeKey(code));
74
+ if (!raw) return null;
75
+ return JSON.parse(raw) as AuthorizationCodeData;
76
+ };
77
+
78
+ export const storeAccessToken = async (
79
+ accessToken: string,
80
+ data: AccessTokenData,
81
+ ttl = ACCESS_TOKEN_TTL_SECONDS
82
+ ): Promise<void> => {
83
+ const result = await cache.set(accessKey(accessToken), JSON.stringify(data), ttl);
84
+ if (result !== 'OK') throw new Error('Failed to persist access token');
85
+ };
86
+
87
+ export const getAccessToken = async (accessToken: string): Promise<AccessTokenData | null> => {
88
+ const raw = await cache.get(accessKey(accessToken));
89
+ if (!raw) return null;
90
+ return JSON.parse(raw) as AccessTokenData;
91
+ };