@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
package/src/oidc.ts ADDED
@@ -0,0 +1,371 @@
1
+ /**
2
+ * OIDC OAuth surface for the lca-api provider (AD-2), served as a Fastify
3
+ * plugin so it can issue real 302 redirects and read form-encoded bodies —
4
+ * things the tRPC/OpenAPI layer cannot do. Dual-mounted in the standalone
5
+ * Docker entry (`docker-entry.ts`) and the Lambda handler (`lambda.ts`),
6
+ * mirroring `src/dids.ts`.
7
+ *
8
+ * Endpoints (at the issuer root):
9
+ * - GET /.well-known/openid-configuration — discovery
10
+ * - GET /oidc/jwks — public signing keys
11
+ * - GET /oidc/authorize — redeem a `login_hint` login ticket, 302 back to the
12
+ * relying party's `redirect_uri` with a one-time `code`. Invalid /
13
+ * expired ticket → `error=login_required`.
14
+ * - POST /oidc/token — confidential-client-authenticated exchange of a `code`
15
+ * for a signed `id_token` + opaque `access_token`.
16
+ * - GET /oidc/userinfo — resolve an opaque `access_token` to verified claims.
17
+ */
18
+
19
+ import Fastify, { type FastifyPluginAsync, type FastifyReply, type FastifyRequest } from 'fastify';
20
+ import formbody from '@fastify/formbody';
21
+ import fastifyRateLimit from '@fastify/rate-limit';
22
+ import { environment } from '@config/oidcEnvironment';
23
+ import cache from '@cache';
24
+ import { redeemLoginTicket } from '@cache/login-tickets';
25
+
26
+ import {
27
+ getOidcClientId,
28
+ getOidcDiscoveryDocument,
29
+ getOidcJwks,
30
+ getOidcIssuer,
31
+ isAllowedRedirectUri,
32
+ mintIdToken,
33
+ verifyOidcClientCredentials,
34
+ } from '@helpers/oidc.helpers';
35
+ import {
36
+ generateAuthorizationCode,
37
+ storeAuthorizationCode,
38
+ consumeAuthorizationCode,
39
+ generateAccessToken,
40
+ storeAccessToken,
41
+ getAccessToken,
42
+ type AuthorizationCodeData,
43
+ type AccessTokenData,
44
+ } from '@helpers/login-ticket.helpers';
45
+ import {
46
+ DEFAULT_MAX_FAILED_ATTEMPTS,
47
+ DEFAULT_RATE_LIMIT_WINDOW_SECONDS,
48
+ getRequestClientIp,
49
+ isRateLimited,
50
+ recordFailure,
51
+ } from '@helpers/rate-limit.helpers';
52
+
53
+ const RATE_LIMIT_PREFIX = 'oidc-rl:';
54
+ const RATE_LIMIT_MAX_FAILURES = DEFAULT_MAX_FAILED_ATTEMPTS;
55
+ const RATE_LIMIT_WINDOW_SECONDS = DEFAULT_RATE_LIMIT_WINDOW_SECONDS;
56
+
57
+ // Coarse all-requests ceilings enforced by @fastify/rate-limit (per IP, per
58
+ // minute). Discovery/JWKS are polled by Keycloak, so the global default is
59
+ // generous. `/oidc/authorize` is hit by end-user browsers, so it gets a tight
60
+ // per-IP cap. `/oidc/token` and `/oidc/userinfo` are only ever called by the
61
+ // broker (Keycloak) server-side, so every legitimate request shares one NAT
62
+ // egress IP: a per-IP cap there is a global login ceiling, not a per-user
63
+ // control. They therefore get a high sanity ceiling only; the client secret /
64
+ // bearer token and the failures-only counter are the real access controls.
65
+ const GLOBAL_REQUESTS_PER_MINUTE = 300;
66
+ const BROWSER_ROUTE_REQUESTS_PER_MINUTE = 60;
67
+ const SERVER_ROUTE_REQUESTS_PER_MINUTE = 3000;
68
+ const BROWSER_ROUTE_RATE_LIMIT = {
69
+ config: { rateLimit: { max: BROWSER_ROUTE_REQUESTS_PER_MINUTE, timeWindow: '1 minute' } },
70
+ };
71
+ const SERVER_ROUTE_RATE_LIMIT = {
72
+ config: { rateLimit: { max: SERVER_ROUTE_REQUESTS_PER_MINUTE, timeWindow: '1 minute' } },
73
+ };
74
+
75
+ // @fastify/rate-limit throws whatever errorResponseBuilder returns; the plugin's
76
+ // error handler must recognise it so the 429 is not flattened into a 500.
77
+ interface RateLimitExceededError {
78
+ statusCode: 429;
79
+ error: 'temporarily_unavailable';
80
+ }
81
+
82
+ const isRateLimitExceeded = (error: unknown): error is RateLimitExceededError =>
83
+ typeof error === 'object' &&
84
+ error !== null &&
85
+ (error as { statusCode?: unknown }).statusCode === 429 &&
86
+ (error as { error?: unknown }).error === 'temporarily_unavailable';
87
+
88
+ const rateLimitKey = (route: 'authorize' | 'token', request: FastifyRequest): string =>
89
+ `${RATE_LIMIT_PREFIX}${route}:${getRequestClientIp(request)}`;
90
+
91
+ const sendRateLimited = (reply: FastifyReply): FastifyReply =>
92
+ reply
93
+ .header('Retry-After', String(RATE_LIMIT_WINDOW_SECONDS))
94
+ .status(429)
95
+ .send({ error: 'temporarily_unavailable' });
96
+
97
+ const appendParams = (redirectUri: string, params: Record<string, string>): string => {
98
+ const url = new URL(redirectUri);
99
+ for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
100
+ return url.toString();
101
+ };
102
+
103
+ interface ClientCredentials {
104
+ clientId: string;
105
+ clientSecret: string;
106
+ }
107
+
108
+ const tryFormUrlDecode = (value: string): string | undefined => {
109
+ try {
110
+ return decodeURIComponent(value.replace(/\+/g, ' '));
111
+ } catch {
112
+ return undefined;
113
+ }
114
+ };
115
+
116
+ /**
117
+ * Candidate credentials from an HTTP Basic header, raw form first.
118
+ *
119
+ * RFC 6749 §2.3.1 says the client id/secret are form-url-encoded before being
120
+ * base64'd, but Keycloak's broker (`SimpleHttp.authBasic`) sends them raw. A
121
+ * secret containing `+` or `%` would otherwise be mangled by decoding, so the
122
+ * raw pair is tried first and the decoded pair only as a fallback.
123
+ */
124
+ const parseBasicCredentials = (authorization: string): ClientCredentials[] => {
125
+ if (!/^Basic /i.test(authorization)) return [];
126
+
127
+ const decoded = Buffer.from(authorization.slice(6), 'base64').toString('utf8');
128
+ const separator = decoded.indexOf(':');
129
+ if (separator < 0) return [];
130
+
131
+ const raw: ClientCredentials = {
132
+ clientId: decoded.slice(0, separator),
133
+ clientSecret: decoded.slice(separator + 1),
134
+ };
135
+ const candidates = [raw];
136
+
137
+ const formClientId = tryFormUrlDecode(raw.clientId);
138
+ const formClientSecret = tryFormUrlDecode(raw.clientSecret);
139
+ if (
140
+ formClientId !== undefined &&
141
+ formClientSecret !== undefined &&
142
+ (formClientId !== raw.clientId || formClientSecret !== raw.clientSecret)
143
+ ) {
144
+ candidates.push({ clientId: formClientId, clientSecret: formClientSecret });
145
+ }
146
+
147
+ return candidates;
148
+ };
149
+
150
+ export const oidcFastifyPlugin: FastifyPluginAsync = async fastify => {
151
+ await fastify.register(formbody);
152
+ await fastify.register(fastifyRateLimit, {
153
+ max: GLOBAL_REQUESTS_PER_MINUTE,
154
+ timeWindow: '1 minute',
155
+ keyGenerator: request => getRequestClientIp(request),
156
+ ...(cache.redis ? { redis: cache.redis } : {}),
157
+ errorResponseBuilder: (): RateLimitExceededError => ({
158
+ statusCode: 429,
159
+ error: 'temporarily_unavailable',
160
+ }),
161
+ });
162
+ fastify.addHook('onRequest', async (_request, reply) => {
163
+ reply.header('Cache-Control', 'no-store');
164
+ reply.header('Pragma', 'no-cache');
165
+ try {
166
+ getOidcIssuer();
167
+ await getOidcJwks();
168
+ } catch (error) {
169
+ console.error('OIDC provider is not configured:', error);
170
+ return reply.status(503).send({ error: 'server_error' });
171
+ }
172
+ });
173
+ fastify.setErrorHandler((error: unknown, request, reply) => {
174
+ if (isRateLimitExceeded(error)) {
175
+ return reply.status(429).send({ error: error.error });
176
+ }
177
+ console.error(`OIDC request failed (${request.method} ${request.url}):`, error);
178
+ return reply.status(500).send({ error: 'server_error' });
179
+ });
180
+
181
+ fastify.get('/.well-known/openid-configuration', async (_request, reply) => {
182
+ return reply.send(getOidcDiscoveryDocument());
183
+ });
184
+
185
+ fastify.get('/oidc/jwks', async (_request, reply) => {
186
+ reply.header('Cache-Control', 'public, max-age=300');
187
+ return reply.send(await getOidcJwks());
188
+ });
189
+
190
+ fastify.get('/oidc/authorize', BROWSER_ROUTE_RATE_LIMIT, async (request, reply) => {
191
+ const query = request.query as Record<string, string | undefined>;
192
+ const redirectUri = query.redirect_uri;
193
+ const state = query.state;
194
+ const responseType = query.response_type;
195
+ const clientId = query.client_id;
196
+ const loginHint = query.login_hint;
197
+ const nonce = query.nonce;
198
+
199
+ const limitKey = rateLimitKey('authorize', request);
200
+ const limited = await isRateLimited(limitKey, RATE_LIMIT_MAX_FAILURES);
201
+
202
+ if (clientId !== getOidcClientId() || !redirectUri || !isAllowedRedirectUri(redirectUri)) {
203
+ if (limited) return sendRateLimited(reply);
204
+ await recordFailure(limitKey, RATE_LIMIT_WINDOW_SECONDS);
205
+ return reply.status(400).send({ error: 'invalid_request' });
206
+ }
207
+
208
+ const failRedirect = (error: string): string =>
209
+ appendParams(redirectUri, state ? { error, state } : { error });
210
+
211
+ // The redirect URI is now trusted, so a limited client gets the OAuth
212
+ // error via redirect and its (possibly valid) ticket is left unconsumed.
213
+ if (limited) return reply.redirect(failRedirect('temporarily_unavailable'));
214
+
215
+ if (responseType !== 'code') {
216
+ return reply.redirect(failRedirect('unsupported_response_type'));
217
+ }
218
+
219
+ if (!query.scope?.split(/\s+/).includes('openid')) {
220
+ return reply.redirect(failRedirect('invalid_scope'));
221
+ }
222
+
223
+ const ticket = loginHint ? await redeemLoginTicket(loginHint) : null;
224
+ if (!ticket) {
225
+ await recordFailure(limitKey, RATE_LIMIT_WINDOW_SECONDS);
226
+ return reply.redirect(failRedirect('login_required'));
227
+ }
228
+
229
+ const code = generateAuthorizationCode();
230
+ const codeData: AuthorizationCodeData = {
231
+ code,
232
+ scope: query.scope,
233
+ clientId,
234
+ redirectUri,
235
+ nonce,
236
+ state,
237
+ subject: ticket.subject,
238
+ email: ticket.email,
239
+ emailVerified: ticket.emailVerified,
240
+ phoneNumber: ticket.phoneNumber,
241
+ phoneNumberVerified: ticket.phoneNumberVerified,
242
+ name: ticket.name,
243
+ picture: ticket.picture,
244
+ createdAt: Date.now(),
245
+ };
246
+ await storeAuthorizationCode(codeData);
247
+
248
+ return reply.redirect(appendParams(redirectUri, state ? { code, state } : { code }));
249
+ });
250
+
251
+ fastify.post('/oidc/token', SERVER_ROUTE_RATE_LIMIT, async (request, reply) => {
252
+ const limitKey = rateLimitKey('token', request);
253
+ if (await isRateLimited(limitKey, RATE_LIMIT_MAX_FAILURES)) {
254
+ return sendRateLimited(reply);
255
+ }
256
+ if (!environment.OIDC_CLIENT_SECRET) {
257
+ return reply.status(503).send({ error: 'server_error' });
258
+ }
259
+ const body = (request.body ?? {}) as Record<string, string | undefined>;
260
+
261
+ const authorization = request.headers.authorization;
262
+ let candidates: ClientCredentials[] = [];
263
+ if (authorization) {
264
+ candidates = parseBasicCredentials(authorization);
265
+ } else if (typeof body.client_id === 'string' && typeof body.client_secret === 'string') {
266
+ candidates = [{ clientId: body.client_id, clientSecret: body.client_secret }];
267
+ }
268
+
269
+ const authenticated = candidates.find(credentials =>
270
+ verifyOidcClientCredentials(credentials.clientId, credentials.clientSecret)
271
+ );
272
+ if (!authenticated) {
273
+ await recordFailure(limitKey, RATE_LIMIT_WINDOW_SECONDS);
274
+ return reply
275
+ .header('WWW-Authenticate', 'Basic realm="oidc"')
276
+ .status(401)
277
+ .send({ error: 'invalid_client' });
278
+ }
279
+ const clientId = authenticated.clientId;
280
+
281
+ if (body.grant_type !== 'authorization_code') {
282
+ return reply.status(400).send({ error: 'unsupported_grant_type' });
283
+ }
284
+ if (typeof body.code !== 'string' || !body.code || typeof body.redirect_uri !== 'string') {
285
+ return reply.status(400).send({ error: 'invalid_request' });
286
+ }
287
+
288
+ // Past this point the caller has proven the client secret, i.e. it is the
289
+ // broker. `invalid_grant` is deliberately NOT counted against the IP:
290
+ // the broker forwards whatever `code` a browser hands it, so an attacker
291
+ // (or ordinary expired/replayed codes) could otherwise trip the shared
292
+ // NAT IP's failure counter and lock every user out of login. Codes are
293
+ // 256-bit random and single-use, so guessing is not a realistic threat.
294
+ const codeData = await consumeAuthorizationCode(body.code);
295
+ if (
296
+ !codeData ||
297
+ codeData.clientId !== clientId ||
298
+ body.redirect_uri !== codeData.redirectUri
299
+ ) {
300
+ return reply.status(400).send({ error: 'invalid_grant' });
301
+ }
302
+
303
+ const { token, expiresAt } = await mintIdToken({
304
+ subject: codeData.subject,
305
+ nonce: codeData.nonce,
306
+ email: codeData.email,
307
+ emailVerified: codeData.emailVerified,
308
+ phoneNumber: codeData.phoneNumber,
309
+ phoneNumberVerified: codeData.phoneNumberVerified,
310
+ name: codeData.name,
311
+ picture: codeData.picture,
312
+ });
313
+
314
+ const accessToken = generateAccessToken();
315
+ const accessData: AccessTokenData = {
316
+ subject: codeData.subject,
317
+ email: codeData.email,
318
+ emailVerified: codeData.emailVerified,
319
+ phoneNumber: codeData.phoneNumber,
320
+ phoneNumberVerified: codeData.phoneNumberVerified,
321
+ name: codeData.name,
322
+ picture: codeData.picture,
323
+ createdAt: Date.now(),
324
+ };
325
+ await storeAccessToken(accessToken, accessData);
326
+
327
+ reply.header('Cache-Control', 'no-store');
328
+ reply.header('Pragma', 'no-cache');
329
+ return reply.send({
330
+ access_token: accessToken,
331
+ id_token: token,
332
+ token_type: 'Bearer',
333
+ scope: codeData.scope,
334
+ expires_in: Math.max(0, expiresAt - Math.floor(Date.now() / 1000)),
335
+ });
336
+ });
337
+
338
+ fastify.get('/oidc/userinfo', SERVER_ROUTE_RATE_LIMIT, async (request, reply) => {
339
+ const authorization = request.headers.authorization;
340
+ if (!authorization?.startsWith('Bearer ')) {
341
+ return reply
342
+ .header('WWW-Authenticate', 'Bearer error="invalid_token"')
343
+ .status(401)
344
+ .send({ error: 'invalid_token' });
345
+ }
346
+
347
+ const accessToken = authorization.slice('Bearer '.length).trim();
348
+ const data = await getAccessToken(accessToken);
349
+ if (!data) {
350
+ return reply
351
+ .header('WWW-Authenticate', 'Bearer error="invalid_token"')
352
+ .status(401)
353
+ .send({ error: 'invalid_token' });
354
+ }
355
+
356
+ const claims: Record<string, unknown> = { sub: data.subject };
357
+ if (data.email !== undefined) claims.email = data.email;
358
+ if (data.emailVerified !== undefined) claims.email_verified = data.emailVerified;
359
+ if (data.phoneNumber !== undefined) claims.phone_number = data.phoneNumber;
360
+ if (data.phoneNumberVerified !== undefined) {
361
+ claims.phone_number_verified = data.phoneNumberVerified;
362
+ }
363
+ if (data.name !== undefined) claims.name = data.name;
364
+ if (data.picture !== undefined) claims.picture = data.picture;
365
+
366
+ return reply.send(claims);
367
+ });
368
+ };
369
+
370
+ export const app = Fastify();
371
+ app.register(oidcFastifyPlugin);
@@ -0,0 +1,173 @@
1
+ import { z } from 'zod';
2
+ import { TRPCError } from '@trpc/server';
3
+ import { t, openRoute } from '@routes';
4
+ import { consumeMatchingCode } from '@cache/getDel';
5
+ import { issueLoginTicket } from '@cache/login-tickets';
6
+ import {
7
+ findAuthSubjectByIdentityKey,
8
+ getOrCreateAuthSubject,
9
+ getOrCreateAuthSubjectLinkedTo,
10
+ type AuthSubjectAttributes,
11
+ type MongoAuthSubjectType,
12
+ } from '../models/AuthSubject';
13
+ import {
14
+ getSocialProviderConfig,
15
+ verifySocialIdToken,
16
+ type SocialClaims,
17
+ type SocialProviderId,
18
+ } from '@helpers/social-token.helpers';
19
+ import { isRateLimited, recordFailure } from '@helpers/rate-limit.helpers';
20
+
21
+ const resultSchema = z.object({
22
+ success: z.boolean(),
23
+ ticket: z.string().optional(),
24
+ error: z.string().optional(),
25
+ });
26
+
27
+ const RATE_PREFIX = 'oidc:rate:';
28
+ const ATTEMPT_TTL_SECONDS = 10 * 60;
29
+ // Only failed attempts count. The per-IP ceiling is a backstop for shared NATs
30
+ // (schools, offices); the per-email limit is what actually blocks code guessing.
31
+ const MAX_FAILED_ATTEMPTS_PER_IP = 50;
32
+ const MAX_FAILED_ATTEMPTS_PER_EMAIL = 5;
33
+
34
+ const INVALID_CODE_ERROR = 'Invalid or expired code.';
35
+ const INVALID_SOCIAL_ERROR = 'Invalid or expired sign-in. Please try again.';
36
+ const SERVER_ERROR = 'Something went wrong. Please request a new code.';
37
+ const RATE_LIMIT_ERROR = 'Too many login attempts. Please try again later.';
38
+
39
+ const rateKey = (scope: string, id: string): string => `${RATE_PREFIX}${scope}:${id}`;
40
+
41
+ const assertUnderLimit = async (key: string, max: number): Promise<void> => {
42
+ if (await isRateLimited(key, max)) {
43
+ throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: RATE_LIMIT_ERROR });
44
+ }
45
+ };
46
+
47
+ const recordFailedAttempt = async (...keys: string[]): Promise<void> => {
48
+ for (const key of keys) await recordFailure(key, ATTEMPT_TTL_SECONDS);
49
+ };
50
+
51
+ const isUnauthorized = (error: unknown): boolean =>
52
+ error instanceof TRPCError && error.code === 'UNAUTHORIZED';
53
+
54
+ /**
55
+ * Share one subject across email-code and verified native social sign-ins, regardless
56
+ * of sign-in order. Existing subjects are permanent; conflicting legacy identities
57
+ * are reported without automatically merging accounts.
58
+ */
59
+ const resolveSocialAuthSubject = async (
60
+ provider: SocialProviderId,
61
+ claims: SocialClaims
62
+ ): Promise<MongoAuthSubjectType> => {
63
+ const identityKey = `${provider}:${claims.sub}`;
64
+ const attrs: AuthSubjectAttributes = {
65
+ email: claims.email,
66
+ emailVerified: true,
67
+ displayName: claims.name,
68
+ pictureUrl: claims.picture,
69
+ };
70
+ if (claims.email && claims.email_verified) {
71
+ const email = claims.email.trim().toLowerCase();
72
+ const emailIdentityKey = `email:${email}`;
73
+ const emailAttrs = { email, emailVerified: true };
74
+ const existing = await findAuthSubjectByIdentityKey(identityKey);
75
+ if (existing) {
76
+ const emailRecord = await getOrCreateAuthSubjectLinkedTo(
77
+ emailIdentityKey,
78
+ existing.subject,
79
+ emailAttrs
80
+ );
81
+ if (emailRecord.subject !== existing.subject) {
82
+ console.warn(
83
+ 'Social and email authentication subjects conflict; preserving both subjects.'
84
+ );
85
+ }
86
+ return getOrCreateAuthSubject(identityKey, attrs);
87
+ }
88
+ // Create the shared email identity first so concurrent providers adopt its winner.
89
+ const emailRecord = await getOrCreateAuthSubject(emailIdentityKey, emailAttrs);
90
+ return getOrCreateAuthSubjectLinkedTo(identityKey, emailRecord.subject, attrs);
91
+ }
92
+ return getOrCreateAuthSubject(identityKey, attrs);
93
+ };
94
+
95
+ export const authRouter = t.router({
96
+ requestLoginTicket: openRoute
97
+ .meta({ openapi: { method: 'POST', path: '/auth/login-ticket', tags: ['Auth'] } })
98
+ .input(z.object({ email: z.string().trim().email(), code: z.string().length(6) }))
99
+ .output(resultSchema)
100
+ .mutation(async ({ input, ctx }) => {
101
+ const email = input.email.trim().toLowerCase();
102
+ const ipKey = rateKey('email-login-ticket', ctx.clientIp ?? 'unknown');
103
+ const emailKey = rateKey('email', email);
104
+ await assertUnderLimit(ipKey, MAX_FAILED_ATTEMPTS_PER_IP);
105
+ await assertUnderLimit(emailKey, MAX_FAILED_ATTEMPTS_PER_EMAIL);
106
+
107
+ // Match the existing email-code issuance key exactly, before identity normalization.
108
+ // Compare and consume atomically; a wrong code must not delete the valid one.
109
+ let consumed: boolean;
110
+ try {
111
+ consumed = await consumeMatchingCode(`login-code:${input.email}`, input.code);
112
+ } catch (error) {
113
+ console.error('Error consuming login code:', error);
114
+ return { success: false, error: SERVER_ERROR };
115
+ }
116
+ if (!consumed) {
117
+ await recordFailedAttempt(ipKey, emailKey);
118
+ return { success: false, error: INVALID_CODE_ERROR };
119
+ }
120
+
121
+ try {
122
+ const identityKey = `email:${email}`;
123
+ const record = await getOrCreateAuthSubject(identityKey, {
124
+ email,
125
+ emailVerified: true,
126
+ });
127
+ const ticket = await issueLoginTicket({
128
+ subject: record.subject,
129
+ email,
130
+ emailVerified: true,
131
+ identityKey,
132
+ });
133
+ return { success: true, ticket };
134
+ } catch (error) {
135
+ console.error('Error issuing login ticket after consuming code:', error);
136
+ return { success: false, error: SERVER_ERROR };
137
+ }
138
+ }),
139
+ requestSocialLoginTicket: openRoute
140
+ .meta({ openapi: { method: 'POST', path: '/auth/social/native', tags: ['Auth'] } })
141
+ .input(z.object({ provider: z.enum(['google', 'apple']), idToken: z.string() }))
142
+ .output(resultSchema)
143
+ .mutation(async ({ input, ctx }) => {
144
+ const ipKey = rateKey('social-login-ticket', ctx.clientIp ?? 'unknown');
145
+ await assertUnderLimit(ipKey, MAX_FAILED_ATTEMPTS_PER_IP);
146
+ if (!getSocialProviderConfig(input.provider)) {
147
+ return {
148
+ success: false,
149
+ error: `Sign-in with ${input.provider} is not configured.`,
150
+ };
151
+ }
152
+ try {
153
+ const claims = await verifySocialIdToken(input.provider, input.idToken);
154
+ const record = await resolveSocialAuthSubject(input.provider, claims);
155
+ const ticket = await issueLoginTicket({
156
+ subject: record.subject,
157
+ identityKey: `${input.provider}:${claims.sub}`,
158
+ email: claims.email,
159
+ emailVerified: true,
160
+ name: claims.name,
161
+ picture: claims.picture,
162
+ });
163
+ return { success: true, ticket };
164
+ } catch (error) {
165
+ if (isUnauthorized(error)) {
166
+ await recordFailedAttempt(ipKey);
167
+ return { success: false, error: INVALID_SOCIAL_ERROR };
168
+ }
169
+ console.error('Error issuing social login ticket:', error);
170
+ return { success: false, error: INVALID_SOCIAL_ERROR };
171
+ }
172
+ }),
173
+ });