@learncard/lca-api-service 1.4.0 → 1.5.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 (188) hide show
  1. package/.env.example +73 -0
  2. package/AGENTS.md +106 -10
  3. package/CHANGELOG.md +43 -0
  4. package/bootstrapLambda.test.ts +76 -0
  5. package/config/config.dev.json +23 -0
  6. package/config/config.example.json +22 -0
  7. package/config/config.json +1 -0
  8. package/config/config.production.json +12 -0
  9. package/config/config.scouts.dev.json +11 -0
  10. package/config/config.scouts.production.json +11 -0
  11. package/dist/app.d.ts +67 -3
  12. package/dist/cache/getDel.d.ts +2 -0
  13. package/dist/cache/getDel.test.d.ts +1 -0
  14. package/dist/cache/login-tickets.d.ts +12 -0
  15. package/dist/config/applyDockerStageConfig.d.ts +1 -0
  16. package/dist/config/cacheEnvironment.d.ts +16 -0
  17. package/dist/config/environment.d.ts +101 -0
  18. package/dist/config/escrowBundleMode.test.d.ts +1 -0
  19. package/dist/config/escrowDeployment.test.d.ts +1 -0
  20. package/dist/config/oidcEnvironment.d.ts +74 -0
  21. package/dist/config/serverlessFunctionEnv.test.d.ts +1 -0
  22. package/dist/config/stageConfig.d.ts +62 -0
  23. package/dist/config/stageConfig.test.d.ts +1 -0
  24. package/dist/didkit_wasm_bg.wasm +0 -0
  25. package/dist/docker-entry.d.ts +1 -1
  26. package/dist/helpers/auth.helpers.d.ts +1 -1
  27. package/dist/helpers/escrowCancelLink.d.ts +7 -0
  28. package/dist/helpers/escrowCancelLink.test.d.ts +1 -0
  29. package/dist/helpers/escrowCancelToken.d.ts +4 -0
  30. package/dist/helpers/escrowCancelToken.test.d.ts +1 -0
  31. package/dist/helpers/keycloak.helpers.d.ts +23 -0
  32. package/dist/helpers/login-ticket.helpers.d.ts +31 -0
  33. package/dist/helpers/login-ticket.helpers.test.d.ts +1 -0
  34. package/dist/helpers/oidc.helpers.d.ts +47 -0
  35. package/dist/helpers/rate-limit.helpers.d.ts +6 -0
  36. package/dist/helpers/rate-limit.helpers.test.d.ts +1 -0
  37. package/dist/helpers/social-token.helpers.d.ts +19 -0
  38. package/dist/index.d.ts +1 -0
  39. package/dist/index.js +2 -2
  40. package/dist/jobs/escrowBlobRewrap.d.ts +9 -0
  41. package/dist/jobs/escrowHoldReminders.d.ts +12 -0
  42. package/dist/models/AuthSubject.d.ts +20 -0
  43. package/dist/models/EscrowHold.d.ts +79 -1
  44. package/dist/models/Notification.d.ts +5 -0
  45. package/dist/models/UserKey.d.ts +11 -1
  46. package/dist/models/index.d.ts +1 -0
  47. package/dist/oidc.d.ts +5 -0
  48. package/dist/routes/auth.d.ts +31 -0
  49. package/dist/routes/escrow.d.ts +18 -4
  50. package/dist/routes/keys.d.ts +1 -0
  51. package/dist/routes/notifications.d.ts +5 -0
  52. package/dist/routes/signing-authority.d.ts +14 -0
  53. package/dist/services/escrow-enclave/blobIdentity.d.ts +10 -0
  54. package/dist/services/escrow-enclave/blobIdentity.test.d.ts +1 -0
  55. package/dist/services/escrow-enclave/cancellation.d.ts +3 -0
  56. package/dist/services/escrow-enclave/enclaveTrustAnchors.d.ts +1 -0
  57. package/dist/services/escrow-enclave/index.d.ts +16 -0
  58. package/dist/services/escrow-enclave/notifications.d.ts +19 -4
  59. package/dist/services/escrow-enclave/notifications.test.d.ts +1 -0
  60. package/dist/services/escrow-enclave/production.test.d.ts +1 -0
  61. package/dist/services/escrow-enclave/remoteEnclave.contract.test.d.ts +1 -0
  62. package/dist/services/escrow-enclave/remoteEnclave.d.ts +20 -0
  63. package/dist/services/escrow-enclave/remoteEnclave.test.d.ts +1 -0
  64. package/dist/services/escrow-enclave/remoteEnclave.tls.test.d.ts +1 -0
  65. package/dist/services/escrow-enclave/softwareEnclave.d.ts +12 -2
  66. package/dist/services/escrow-enclave/types.d.ts +52 -8
  67. package/dist/types/notifications.d.ts +5 -0
  68. package/esbuildPlugins.cjs +1 -0
  69. package/lambda.ts +27 -117
  70. package/lambdaApp.ts +146 -0
  71. package/oidcLambda.test.ts +38 -0
  72. package/oidcLambda.ts +18 -0
  73. package/oidcLambdaApp.ts +11 -0
  74. package/package.json +23 -13
  75. package/scripts/README.md +88 -0
  76. package/scripts/check-seed-encryption-infrastructure.mjs +18 -2
  77. package/scripts/escrow-blob-mode-report.test.ts +227 -0
  78. package/scripts/escrow-blob-mode-report.ts +465 -0
  79. package/scripts/keycloak-admin.ts +108 -0
  80. package/scripts/keycloak-provider-links.ts +147 -0
  81. package/scripts/print-seed-rollout-flags.ts +21 -0
  82. package/scripts/provision-keycloak-users.ts +419 -0
  83. package/seedMigrationApp.test.ts +145 -0
  84. package/seedMigrationApp.ts +50 -0
  85. package/seedMigrationLambda.test.ts +30 -135
  86. package/seedMigrationLambda.ts +15 -46
  87. package/serverless-local.yml +25 -1
  88. package/serverless.function-env.cjs +58 -0
  89. package/serverless.yml +172 -37
  90. package/src/accesslayer/notifications/index.ts +9 -2
  91. package/src/accesslayer/signing-authority/index.ts +4 -1
  92. package/src/app.ts +2 -0
  93. package/src/cache/getDel.test.ts +34 -0
  94. package/src/cache/getDel.ts +32 -0
  95. package/src/cache/index.ts +1 -1
  96. package/src/cache/login-tickets.ts +26 -0
  97. package/src/config/applyDockerStageConfig.ts +6 -0
  98. package/src/config/cacheEnvironment.ts +34 -0
  99. package/src/config/environment.test.ts +45 -2
  100. package/src/config/environment.ts +90 -23
  101. package/src/config/escrowBundleMode.test.ts +52 -0
  102. package/src/config/escrowDeployment.test.ts +114 -0
  103. package/src/config/oidcEnvironment.ts +59 -0
  104. package/src/config/serverlessFunctionEnv.test.ts +98 -0
  105. package/src/config/stageConfig.test.ts +26 -0
  106. package/src/config/stageConfig.ts +24 -0
  107. package/src/docker-entry.ts +9 -0
  108. package/src/helpers/auth.helpers.ts +68 -30
  109. package/src/helpers/escrowCancelLink.test.ts +60 -0
  110. package/src/helpers/escrowCancelLink.ts +38 -0
  111. package/src/helpers/escrowCancelToken.test.ts +105 -0
  112. package/src/helpers/escrowCancelToken.ts +47 -0
  113. package/src/helpers/keycloak.helpers.ts +120 -0
  114. package/src/helpers/login-ticket.helpers.test.ts +45 -0
  115. package/src/helpers/login-ticket.helpers.ts +91 -0
  116. package/src/helpers/oidc.helpers.ts +354 -0
  117. package/src/helpers/rate-limit.helpers.test.ts +61 -0
  118. package/src/helpers/rate-limit.helpers.ts +73 -0
  119. package/src/helpers/social-token.helpers.ts +144 -0
  120. package/src/index.ts +1 -0
  121. package/src/jobs/escrowBlobRewrap.ts +121 -0
  122. package/src/jobs/escrowHoldReminders.ts +177 -0
  123. package/src/models/AuthSubject.ts +147 -0
  124. package/src/models/EscrowHold.ts +321 -5
  125. package/src/models/UserKey.ts +91 -6
  126. package/src/models/index.ts +1 -0
  127. package/src/oidc.ts +371 -0
  128. package/src/routes/auth.ts +173 -0
  129. package/src/routes/escrow.ts +281 -34
  130. package/src/routes/keys.ts +5 -0
  131. package/src/routes/signing-authority.ts +39 -1
  132. package/src/services/delivery/adapters/postmark.adapter.ts +43 -1
  133. package/src/services/escrow-enclave/README.md +81 -10
  134. package/src/services/escrow-enclave/__fixtures__/tls/pinned-ca.pem +11 -0
  135. package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.key +5 -0
  136. package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.pem +12 -0
  137. package/src/services/escrow-enclave/__fixtures__/tls/rogue-ca.pem +11 -0
  138. package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.key +5 -0
  139. package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.pem +12 -0
  140. package/src/services/escrow-enclave/blobIdentity.test.ts +42 -0
  141. package/src/services/escrow-enclave/blobIdentity.ts +55 -0
  142. package/src/services/escrow-enclave/cancellation.ts +59 -0
  143. package/src/services/escrow-enclave/enclaveTrustAnchors.ts +34 -0
  144. package/src/services/escrow-enclave/index.ts +145 -7
  145. package/src/services/escrow-enclave/notifications.test.ts +377 -0
  146. package/src/services/escrow-enclave/notifications.ts +340 -6
  147. package/src/services/escrow-enclave/production.test.ts +34 -0
  148. package/src/services/escrow-enclave/remoteEnclave.contract.test.ts +344 -0
  149. package/src/services/escrow-enclave/remoteEnclave.test.ts +490 -0
  150. package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +62 -0
  151. package/src/services/escrow-enclave/remoteEnclave.ts +263 -0
  152. package/src/services/escrow-enclave/softwareEnclave.test.ts +231 -30
  153. package/src/services/escrow-enclave/softwareEnclave.ts +97 -11
  154. package/src/services/escrow-enclave/types.ts +63 -17
  155. package/test/auth-tickets.spec.ts +480 -0
  156. package/test/helpers/keycloak-broker.ts +181 -0
  157. package/test/helpers/live-mongo.ts +9 -0
  158. package/test/jobs/escrowBlobRewrap.spec.ts +262 -0
  159. package/test/jobs/escrowHoldReminders.spec.ts +319 -0
  160. package/test/keycloak-admin.spec.ts +58 -0
  161. package/test/keycloak-broker-roundtrip.integration.spec.ts +40 -0
  162. package/test/keycloak-migration.integration.spec.ts +266 -0
  163. package/test/keycloak-provider-links.spec.ts +201 -0
  164. package/test/keycloak-social-broker.integration.spec.ts +228 -0
  165. package/test/keycloak-verify.integration.spec.ts +100 -0
  166. package/test/keycloak-verify.spec.ts +307 -0
  167. package/test/keys-escrow.spec.ts +963 -24
  168. package/test/models/authSubjectIndexes.spec.ts +132 -0
  169. package/test/models/escrow.spec.ts +375 -13
  170. package/test/notifications.spec.ts +4 -0
  171. package/test/oidc-e2e/broker.spec.ts +116 -0
  172. package/test/oidc-e2e/compose.yaml +33 -0
  173. package/test/oidc-e2e/fixtures.ts +245 -0
  174. package/test/oidc-e2e/lock.test.mjs +73 -0
  175. package/test/oidc-e2e/playwright.config.ts +21 -0
  176. package/test/oidc-e2e/run.mjs +312 -0
  177. package/test/oidc-e2e/tsconfig.json +10 -0
  178. package/test/oidc-e2e/types.ts +21 -0
  179. package/test/oidc.integration.spec.ts +54 -0
  180. package/test/oidc.spec.ts +697 -0
  181. package/test/oidcLambdaApp.boot.spec.ts +105 -0
  182. package/test/provision-keycloak-users.spec.ts +299 -0
  183. package/test/signing-authority.spec.ts +50 -0
  184. package/tsconfig.build.json +1 -1
  185. package/tsconfig.json +1 -0
  186. package/vitest.config.ts +24 -1
  187. package/vitest.integration.config.ts +20 -3
  188. package/serverless.escrow-env.cjs +0 -20
@@ -0,0 +1,98 @@
1
+ import { createRequire } from 'node:module';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ const require = createRequire(import.meta.url);
5
+ const functions: {
6
+ api: () => Record<string, string>;
7
+ oidc: () => Record<string, string>;
8
+ provider: (context?: {
9
+ options: { stage: string; httpPort?: string };
10
+ }) => Record<string, unknown>;
11
+ } = require('../../serverless.function-env.cjs');
12
+
13
+ beforeEach(() => {
14
+ vi.stubEnv('RUNTIME_SECRETS_ID', '');
15
+ vi.stubEnv('SEED', 'seed');
16
+ vi.stubEnv('GOOGLE_APPLICATION_CREDENTIAL', 'credential');
17
+ vi.stubEnv('OIDC_CLIENT_SECRET', 'broker-secret');
18
+ vi.stubEnv('OIDC_REDIRECT_URIS', 'https://example.com/callback');
19
+ });
20
+ afterEach(() => vi.unstubAllEnvs());
21
+
22
+ describe('lca-api serverless function environments', () => {
23
+ it('omits all fallback credentials in bundle mode', () => {
24
+ vi.stubEnv('RUNTIME_SECRETS_ID', 'lca-api/dev/runtime-secrets');
25
+ expect(functions.api()).toEqual({ RUNTIME_SECRETS_ID: 'lca-api/dev/runtime-secrets' });
26
+ expect(Object.keys(functions.provider()).sort()).toEqual([
27
+ 'CONFIG_TENANT',
28
+ 'LAMBDA_STAGE',
29
+ 'PORT',
30
+ 'REDIS_HOST',
31
+ 'REDIS_PORT',
32
+ 'SA_SEED_KMS_KEY_ARN',
33
+ ]);
34
+ });
35
+
36
+ it('keeps the non-secret escrow toggle in bundle mode', () => {
37
+ vi.stubEnv('RUNTIME_SECRETS_ID', 'lca-api/dev/runtime-secrets');
38
+ vi.stubEnv('ESCROW_ENCLAVE_MODE', 'software');
39
+ vi.stubEnv('ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON', '{"k":"secret"}');
40
+ expect(functions.api()).toEqual({
41
+ RUNTIME_SECRETS_ID: 'lca-api/dev/runtime-secrets',
42
+ ESCROW_ENCLAVE_MODE: 'software',
43
+ });
44
+ });
45
+
46
+ it('bakes the tenant selector into every function, defaulting to learncard', () => {
47
+ expect(functions.provider().CONFIG_TENANT).toBe('learncard');
48
+ vi.stubEnv('CONFIG_TENANT', 'scouts');
49
+ expect(functions.provider().CONFIG_TENANT).toBe('scouts');
50
+ });
51
+
52
+ it('retains the existing provider and API credentials only in fallback mode', () => {
53
+ expect(functions.provider().SEED).toBe('seed');
54
+ expect(functions.api().GOOGLE_APPLICATION_CREDENTIAL).toBe('credential');
55
+ expect(functions.api().RUNTIME_SECRETS_ID).toBeUndefined();
56
+ });
57
+
58
+ it('keeps the OIDC exception scoped to its separate role', () => {
59
+ vi.stubEnv('RUNTIME_SECRETS_ID', 'bundle');
60
+ expect(functions.oidc()).toEqual({
61
+ OIDC_CLIENT_SECRET: 'broker-secret',
62
+ OIDC_REDIRECT_URIS: 'https://example.com/callback',
63
+ });
64
+ expect(functions.provider().OIDC_CLIENT_SECRET).toBeUndefined();
65
+ expect(functions.api().OIDC_CLIENT_SECRET).toBeUndefined();
66
+ });
67
+
68
+ it.each(['', 'bundle'])(
69
+ 'resolves the full provider environment through Serverless v3 (%s)',
70
+ async bundle => {
71
+ const source = require('serverless/lib/configuration/variables/sources/file');
72
+ vi.stubEnv('RUNTIME_SECRETS_ID', bundle);
73
+ const options = { stage: 'production', httpPort: '5100' };
74
+ for (const name of ['provider', 'api', 'oidc'] as const) {
75
+ const result = await source.resolve({
76
+ serviceDir: new URL('../../', import.meta.url).pathname,
77
+ params: [
78
+ new URL('../../serverless.function-env.cjs', import.meta.url).pathname,
79
+ ],
80
+ address: name,
81
+ options,
82
+ resolveConfigurationProperty: vi.fn(),
83
+ resolveVariable: vi.fn(),
84
+ });
85
+ const expected =
86
+ name === 'provider' ? functions.provider({ options }) : functions[name]();
87
+ expect(result.value).toEqual(expected);
88
+ if (name === 'provider') {
89
+ expect(result.value.LAMBDA_STAGE).toBe('production');
90
+ expect(result.value.PORT).toBe('5100');
91
+ expect(result.value.SA_SEED_KMS_KEY_ARN).toEqual({
92
+ 'Fn::GetAtt': ['SigningAuthoritySeedKey', 'Arn'],
93
+ });
94
+ }
95
+ }
96
+ }
97
+ );
98
+ });
@@ -0,0 +1,26 @@
1
+ import { afterEach, expect, it, vi } from 'vitest';
2
+
3
+ // Model a non-empty .env value arriving before the stage defaults are applied.
4
+ vi.mock('dotenv/config', () => {
5
+ vi.stubEnv('SENTRY_ENV', 'self-hosted');
6
+ return {};
7
+ });
8
+
9
+ afterEach(() => vi.unstubAllEnvs());
10
+
11
+ it('loads local env before applying statically imported stage defaults', async () => {
12
+ const { applyLcaApiStageConfig, stages } = await import('./stageConfig');
13
+ for (const key of Object.keys(stages.dev)) {
14
+ if (key !== 'SENTRY_ENV') vi.stubEnv(key, '');
15
+ }
16
+ applyLcaApiStageConfig('dev');
17
+ expect(process.env.SENTRY_ENV).toBe('self-hosted');
18
+ expect(process.env.DOMAIN_NAME).toBe(stages.dev.DOMAIN_NAME);
19
+ });
20
+
21
+ it('does not select a deployed stage for an unknown local stage', async () => {
22
+ const { applyLcaApiStageConfig } = await import('./stageConfig');
23
+ vi.stubEnv('DOMAIN_NAME', '');
24
+ applyLcaApiStageConfig('unregistered-self-hosted-stage');
25
+ expect(process.env.DOMAIN_NAME).toBe('');
26
+ });
@@ -0,0 +1,24 @@
1
+ import 'dotenv/config';
2
+ import { applyStageConfig } from '@learncard/service-config';
3
+
4
+ import baseConfig from '../../config/config.json';
5
+ import devConfig from '../../config/config.dev.json';
6
+ import productionConfig from '../../config/config.production.json';
7
+ import scoutsDevConfig from '../../config/config.scouts.dev.json';
8
+ import scoutsProductionConfig from '../../config/config.scouts.production.json';
9
+
10
+ export const base: Record<string, string> = baseConfig;
11
+
12
+ export const stages = {
13
+ dev: devConfig,
14
+ production: productionConfig,
15
+ // ScoutPass deploys the same services and stage names; CONFIG_TENANT=scouts selects these.
16
+ 'scouts.dev': scoutsDevConfig,
17
+ 'scouts.production': scoutsProductionConfig,
18
+ } satisfies Record<string, Record<string, string>>;
19
+
20
+ // Must run before any module that reads configuration on import (e.g. @environment):
21
+ // merges base + the selected stage into process.env; explicit non-empty env still wins.
22
+ export const applyLcaApiStageConfig = (stage: string | undefined): void => {
23
+ applyStageConfig({ base, stages, stage });
24
+ };
@@ -1,3 +1,5 @@
1
+ import './config/applyDockerStageConfig';
2
+
1
3
  import { environment } from '@environment';
2
4
  import path from 'path';
3
5
 
@@ -10,6 +12,8 @@ import { appRouter, type AppRouter, createContext } from './app';
10
12
  import { openApiDocument } from './openapi';
11
13
  import { didFastifyPlugin } from './dids';
12
14
  import { ensureUserKeysIndexes, createEscrowHoldsIndexes } from './models';
15
+ import { oidcFastifyPlugin } from './oidc';
16
+ import { ensureAuthSubjectIndexes } from './models/AuthSubject';
13
17
 
14
18
  const server = Fastify({ maxParamLength: 5000 });
15
19
 
@@ -102,9 +106,14 @@ server.register(fastifyStatic, {
102
106
  });
103
107
 
104
108
  server.register(didFastifyPlugin);
109
+ server.register(oidcFastifyPlugin);
105
110
 
106
111
  (async () => {
107
112
  try {
113
+ await ensureAuthSubjectIndexes().catch(error => {
114
+ // Login requests retry index creation and must succeed before writing subjects.
115
+ console.error('AuthSubject indexes unavailable at startup; login will retry:', error);
116
+ });
108
117
  console.log('Server starting on port ', environment.PORT || 3000);
109
118
  await ensureUserKeysIndexes();
110
119
  await createEscrowHoldsIndexes();
@@ -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
+ };