@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.
- package/.env.example +73 -0
- package/AGENTS.md +106 -10
- package/CHANGELOG.md +43 -0
- package/bootstrapLambda.test.ts +76 -0
- package/config/config.dev.json +23 -0
- package/config/config.example.json +22 -0
- package/config/config.json +1 -0
- package/config/config.production.json +12 -0
- package/config/config.scouts.dev.json +11 -0
- package/config/config.scouts.production.json +11 -0
- package/dist/app.d.ts +67 -3
- package/dist/cache/getDel.d.ts +2 -0
- package/dist/cache/getDel.test.d.ts +1 -0
- package/dist/cache/login-tickets.d.ts +12 -0
- package/dist/config/applyDockerStageConfig.d.ts +1 -0
- package/dist/config/cacheEnvironment.d.ts +16 -0
- package/dist/config/environment.d.ts +101 -0
- package/dist/config/escrowBundleMode.test.d.ts +1 -0
- package/dist/config/escrowDeployment.test.d.ts +1 -0
- package/dist/config/oidcEnvironment.d.ts +74 -0
- package/dist/config/serverlessFunctionEnv.test.d.ts +1 -0
- package/dist/config/stageConfig.d.ts +62 -0
- package/dist/config/stageConfig.test.d.ts +1 -0
- package/dist/didkit_wasm_bg.wasm +0 -0
- package/dist/docker-entry.d.ts +1 -1
- package/dist/helpers/auth.helpers.d.ts +1 -1
- package/dist/helpers/escrowCancelLink.d.ts +7 -0
- package/dist/helpers/escrowCancelLink.test.d.ts +1 -0
- package/dist/helpers/escrowCancelToken.d.ts +4 -0
- package/dist/helpers/escrowCancelToken.test.d.ts +1 -0
- package/dist/helpers/keycloak.helpers.d.ts +23 -0
- package/dist/helpers/login-ticket.helpers.d.ts +31 -0
- package/dist/helpers/login-ticket.helpers.test.d.ts +1 -0
- package/dist/helpers/oidc.helpers.d.ts +47 -0
- package/dist/helpers/rate-limit.helpers.d.ts +6 -0
- package/dist/helpers/rate-limit.helpers.test.d.ts +1 -0
- package/dist/helpers/social-token.helpers.d.ts +19 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -2
- package/dist/jobs/escrowBlobRewrap.d.ts +9 -0
- package/dist/jobs/escrowHoldReminders.d.ts +12 -0
- package/dist/models/AuthSubject.d.ts +20 -0
- package/dist/models/EscrowHold.d.ts +79 -1
- package/dist/models/Notification.d.ts +5 -0
- package/dist/models/UserKey.d.ts +11 -1
- package/dist/models/index.d.ts +1 -0
- package/dist/oidc.d.ts +5 -0
- package/dist/routes/auth.d.ts +31 -0
- package/dist/routes/escrow.d.ts +18 -4
- package/dist/routes/keys.d.ts +1 -0
- package/dist/routes/notifications.d.ts +5 -0
- package/dist/routes/signing-authority.d.ts +14 -0
- package/dist/services/escrow-enclave/blobIdentity.d.ts +10 -0
- package/dist/services/escrow-enclave/blobIdentity.test.d.ts +1 -0
- package/dist/services/escrow-enclave/cancellation.d.ts +3 -0
- package/dist/services/escrow-enclave/enclaveTrustAnchors.d.ts +1 -0
- package/dist/services/escrow-enclave/index.d.ts +16 -0
- package/dist/services/escrow-enclave/notifications.d.ts +19 -4
- package/dist/services/escrow-enclave/notifications.test.d.ts +1 -0
- package/dist/services/escrow-enclave/production.test.d.ts +1 -0
- package/dist/services/escrow-enclave/remoteEnclave.contract.test.d.ts +1 -0
- package/dist/services/escrow-enclave/remoteEnclave.d.ts +20 -0
- package/dist/services/escrow-enclave/remoteEnclave.test.d.ts +1 -0
- package/dist/services/escrow-enclave/remoteEnclave.tls.test.d.ts +1 -0
- package/dist/services/escrow-enclave/softwareEnclave.d.ts +12 -2
- package/dist/services/escrow-enclave/types.d.ts +52 -8
- package/dist/types/notifications.d.ts +5 -0
- package/esbuildPlugins.cjs +1 -0
- package/lambda.ts +27 -117
- package/lambdaApp.ts +146 -0
- package/oidcLambda.test.ts +38 -0
- package/oidcLambda.ts +18 -0
- package/oidcLambdaApp.ts +11 -0
- package/package.json +23 -13
- package/scripts/README.md +88 -0
- package/scripts/check-seed-encryption-infrastructure.mjs +18 -2
- package/scripts/escrow-blob-mode-report.test.ts +227 -0
- package/scripts/escrow-blob-mode-report.ts +465 -0
- package/scripts/keycloak-admin.ts +108 -0
- package/scripts/keycloak-provider-links.ts +147 -0
- package/scripts/print-seed-rollout-flags.ts +21 -0
- package/scripts/provision-keycloak-users.ts +419 -0
- package/seedMigrationApp.test.ts +145 -0
- package/seedMigrationApp.ts +50 -0
- package/seedMigrationLambda.test.ts +30 -135
- package/seedMigrationLambda.ts +15 -46
- package/serverless-local.yml +25 -1
- package/serverless.function-env.cjs +58 -0
- package/serverless.yml +172 -37
- package/src/accesslayer/notifications/index.ts +9 -2
- package/src/accesslayer/signing-authority/index.ts +4 -1
- package/src/app.ts +2 -0
- package/src/cache/getDel.test.ts +34 -0
- package/src/cache/getDel.ts +32 -0
- package/src/cache/index.ts +1 -1
- package/src/cache/login-tickets.ts +26 -0
- package/src/config/applyDockerStageConfig.ts +6 -0
- package/src/config/cacheEnvironment.ts +34 -0
- package/src/config/environment.test.ts +45 -2
- package/src/config/environment.ts +90 -23
- package/src/config/escrowBundleMode.test.ts +52 -0
- package/src/config/escrowDeployment.test.ts +114 -0
- package/src/config/oidcEnvironment.ts +59 -0
- package/src/config/serverlessFunctionEnv.test.ts +98 -0
- package/src/config/stageConfig.test.ts +26 -0
- package/src/config/stageConfig.ts +24 -0
- package/src/docker-entry.ts +9 -0
- package/src/helpers/auth.helpers.ts +68 -30
- package/src/helpers/escrowCancelLink.test.ts +60 -0
- package/src/helpers/escrowCancelLink.ts +38 -0
- package/src/helpers/escrowCancelToken.test.ts +105 -0
- package/src/helpers/escrowCancelToken.ts +47 -0
- package/src/helpers/keycloak.helpers.ts +120 -0
- package/src/helpers/login-ticket.helpers.test.ts +45 -0
- package/src/helpers/login-ticket.helpers.ts +91 -0
- package/src/helpers/oidc.helpers.ts +354 -0
- package/src/helpers/rate-limit.helpers.test.ts +61 -0
- package/src/helpers/rate-limit.helpers.ts +73 -0
- package/src/helpers/social-token.helpers.ts +144 -0
- package/src/index.ts +1 -0
- package/src/jobs/escrowBlobRewrap.ts +121 -0
- package/src/jobs/escrowHoldReminders.ts +177 -0
- package/src/models/AuthSubject.ts +147 -0
- package/src/models/EscrowHold.ts +321 -5
- package/src/models/UserKey.ts +91 -6
- package/src/models/index.ts +1 -0
- package/src/oidc.ts +371 -0
- package/src/routes/auth.ts +173 -0
- package/src/routes/escrow.ts +281 -34
- package/src/routes/keys.ts +5 -0
- package/src/routes/signing-authority.ts +39 -1
- package/src/services/delivery/adapters/postmark.adapter.ts +43 -1
- package/src/services/escrow-enclave/README.md +81 -10
- package/src/services/escrow-enclave/__fixtures__/tls/pinned-ca.pem +11 -0
- package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.key +5 -0
- package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.pem +12 -0
- package/src/services/escrow-enclave/__fixtures__/tls/rogue-ca.pem +11 -0
- package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.key +5 -0
- package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.pem +12 -0
- package/src/services/escrow-enclave/blobIdentity.test.ts +42 -0
- package/src/services/escrow-enclave/blobIdentity.ts +55 -0
- package/src/services/escrow-enclave/cancellation.ts +59 -0
- package/src/services/escrow-enclave/enclaveTrustAnchors.ts +34 -0
- package/src/services/escrow-enclave/index.ts +145 -7
- package/src/services/escrow-enclave/notifications.test.ts +377 -0
- package/src/services/escrow-enclave/notifications.ts +340 -6
- package/src/services/escrow-enclave/production.test.ts +34 -0
- package/src/services/escrow-enclave/remoteEnclave.contract.test.ts +344 -0
- package/src/services/escrow-enclave/remoteEnclave.test.ts +490 -0
- package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +62 -0
- package/src/services/escrow-enclave/remoteEnclave.ts +263 -0
- package/src/services/escrow-enclave/softwareEnclave.test.ts +231 -30
- package/src/services/escrow-enclave/softwareEnclave.ts +97 -11
- package/src/services/escrow-enclave/types.ts +63 -17
- package/test/auth-tickets.spec.ts +480 -0
- package/test/helpers/keycloak-broker.ts +181 -0
- package/test/helpers/live-mongo.ts +9 -0
- package/test/jobs/escrowBlobRewrap.spec.ts +262 -0
- package/test/jobs/escrowHoldReminders.spec.ts +319 -0
- package/test/keycloak-admin.spec.ts +58 -0
- package/test/keycloak-broker-roundtrip.integration.spec.ts +40 -0
- package/test/keycloak-migration.integration.spec.ts +266 -0
- package/test/keycloak-provider-links.spec.ts +201 -0
- package/test/keycloak-social-broker.integration.spec.ts +228 -0
- package/test/keycloak-verify.integration.spec.ts +100 -0
- package/test/keycloak-verify.spec.ts +307 -0
- package/test/keys-escrow.spec.ts +963 -24
- package/test/models/authSubjectIndexes.spec.ts +132 -0
- package/test/models/escrow.spec.ts +375 -13
- package/test/notifications.spec.ts +4 -0
- package/test/oidc-e2e/broker.spec.ts +116 -0
- package/test/oidc-e2e/compose.yaml +33 -0
- package/test/oidc-e2e/fixtures.ts +245 -0
- package/test/oidc-e2e/lock.test.mjs +73 -0
- package/test/oidc-e2e/playwright.config.ts +21 -0
- package/test/oidc-e2e/run.mjs +312 -0
- package/test/oidc-e2e/tsconfig.json +10 -0
- package/test/oidc-e2e/types.ts +21 -0
- package/test/oidc.integration.spec.ts +54 -0
- package/test/oidc.spec.ts +697 -0
- package/test/oidcLambdaApp.boot.spec.ts +105 -0
- package/test/provision-keycloak-users.spec.ts +299 -0
- package/test/signing-authority.spec.ts +50 -0
- package/tsconfig.build.json +1 -1
- package/tsconfig.json +1 -0
- package/vitest.config.ts +24 -1
- package/vitest.integration.config.ts +20 -3
- 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
|
+
});
|