@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.
- package/.env.example +73 -0
- package/AGENTS.md +106 -10
- package/CHANGELOG.md +52 -0
- package/bootstrapLambda.test.ts +76 -0
- package/bundle.mjs +9 -1
- 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/publish-metafile.json +1 -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 +22 -13
- package/project.json +8 -1
- 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 +174 -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
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal OIDC provider primitives for lca-api (AD-2 of the Keycloak
|
|
3
|
+
* auth-provider migration).
|
|
4
|
+
*
|
|
5
|
+
* lca-api exposes a minimal OpenID Connect provider that Keycloak trusts as an
|
|
6
|
+
* upstream Identity Provider (alias `lca-api`). This module owns the signing
|
|
7
|
+
* key material, the discovery / JWKS documents, and the mint / verify
|
|
8
|
+
* primitives for the `id_token` returned from the `/oidc/token` endpoint.
|
|
9
|
+
*
|
|
10
|
+
* Design notes (per the design of record):
|
|
11
|
+
* - The signing key is **separate from `SEED`** and rotated independently.
|
|
12
|
+
* Deployed Lambdas fetch a JSON RSA private JWK using `OIDC_SIGNING_KEY_SECRET_ID`.
|
|
13
|
+
* Local/Docker/CI can still set `OIDC_SIGNING_KEY_JWK`. When unset in production the provider fails closed
|
|
14
|
+
* (503) — it never falls back to a forgeable key. In dev/CI an ephemeral
|
|
15
|
+
* RSA keypair is generated once per process (random, never deterministic,
|
|
16
|
+
* never a function of `SEED`) so local Keycloak can round-trip.
|
|
17
|
+
* - RS256 is used because Keycloak's OIDC identity-provider brokering expects
|
|
18
|
+
* RSA signing keys by default.
|
|
19
|
+
* - Nothing here touches Mongo; it is pure crypto + config so it can be
|
|
20
|
+
* unit-tested in isolation (see `test/oidc.spec.ts`).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import crypto from 'node:crypto';
|
|
24
|
+
import { GetSecretValueCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager';
|
|
25
|
+
|
|
26
|
+
import { environment } from '@config/oidcEnvironment';
|
|
27
|
+
import { TRPCError } from '@trpc/server';
|
|
28
|
+
import { SignJWT, calculateJwkThumbprint, exportJWK, importJWK } from 'jose';
|
|
29
|
+
import type { CryptoKey, JWK, JWTPayload } from 'jose';
|
|
30
|
+
|
|
31
|
+
/** The signing algorithm for self-issued OIDC tokens (Keycloak expects RSA). */
|
|
32
|
+
export const OIDC_SIGNING_ALG = 'RS256' as const;
|
|
33
|
+
|
|
34
|
+
/** Default lifetime (seconds) of a minted ID token. */
|
|
35
|
+
export const DEFAULT_ID_TOKEN_TTL_SECONDS = 300;
|
|
36
|
+
|
|
37
|
+
/** Default confidential relying-party client Keycloak brokers through. */
|
|
38
|
+
export const DEFAULT_OIDC_CLIENT_ID = 'keycloak-broker';
|
|
39
|
+
|
|
40
|
+
/** Claims carried by a self-issued OIDC ID token. */
|
|
41
|
+
export interface OidcIdTokenClaims extends JWTPayload {
|
|
42
|
+
sub: string;
|
|
43
|
+
iss: string;
|
|
44
|
+
aud: string | string[];
|
|
45
|
+
email?: string;
|
|
46
|
+
email_verified?: boolean;
|
|
47
|
+
phone_number?: string;
|
|
48
|
+
phone_number_verified?: boolean;
|
|
49
|
+
name?: string;
|
|
50
|
+
picture?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface OidcSigningKey {
|
|
54
|
+
privateKey: CryptoKey;
|
|
55
|
+
publicKey: CryptoKey;
|
|
56
|
+
publicJwk: JWK;
|
|
57
|
+
kid: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let cachedKey: Promise<OidcSigningKey> | undefined;
|
|
61
|
+
let cachedKeyMaterial: string | undefined;
|
|
62
|
+
let ephemeralDevJwk: JWK | undefined;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* True when the service is running as a real deployment. `serverless.yml` does
|
|
66
|
+
* not set `NODE_ENV`, but it always sets `LAMBDA_STAGE`, so either signal counts;
|
|
67
|
+
* an ephemeral per-container key would otherwise be minted on every cold start.
|
|
68
|
+
*/
|
|
69
|
+
const isProduction = (): boolean =>
|
|
70
|
+
(environment.NODE_ENV === 'production' || Boolean(environment.LAMBDA_STAGE)) &&
|
|
71
|
+
!environment.IS_OFFLINE &&
|
|
72
|
+
!environment.IS_E2E_TEST;
|
|
73
|
+
|
|
74
|
+
/** Parse both configured sources without exposing key material in errors. */
|
|
75
|
+
const parseSigningJwk = (configured: string): JWK => {
|
|
76
|
+
let parsed: unknown;
|
|
77
|
+
try {
|
|
78
|
+
parsed = JSON.parse(configured);
|
|
79
|
+
} catch {
|
|
80
|
+
throw new TRPCError({
|
|
81
|
+
code: 'INTERNAL_SERVER_ERROR',
|
|
82
|
+
message: 'OIDC signing key must be a JSON RSA private JWK',
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
const jwk = parsed as JWK;
|
|
86
|
+
if (
|
|
87
|
+
!jwk ||
|
|
88
|
+
jwk.kty !== 'RSA' ||
|
|
89
|
+
typeof jwk.d !== 'string' ||
|
|
90
|
+
!jwk.d ||
|
|
91
|
+
typeof jwk.kid !== 'string' ||
|
|
92
|
+
!jwk.kid ||
|
|
93
|
+
jwk.alg !== 'RS256'
|
|
94
|
+
) {
|
|
95
|
+
throw new TRPCError({
|
|
96
|
+
code: 'INTERNAL_SERVER_ERROR',
|
|
97
|
+
message: 'OIDC signing key must be an RSA private JWK with d, kid and alg=RS256',
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
return jwk;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/** Prefer the local override, then Secrets Manager; deployed instances fail closed. */
|
|
104
|
+
const resolveSigningJwk = async (configured?: string, secretId?: string): Promise<JWK> => {
|
|
105
|
+
if (configured) return parseSigningJwk(configured);
|
|
106
|
+
if (secretId) {
|
|
107
|
+
let secretString: string | undefined;
|
|
108
|
+
try {
|
|
109
|
+
const client = new SecretsManagerClient({});
|
|
110
|
+
const result = await client.send(new GetSecretValueCommand({ SecretId: secretId }));
|
|
111
|
+
secretString = result.SecretString;
|
|
112
|
+
} catch {
|
|
113
|
+
throw new TRPCError({
|
|
114
|
+
code: 'INTERNAL_SERVER_ERROR',
|
|
115
|
+
message: 'OIDC signing key could not be fetched',
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
if (!secretString) {
|
|
119
|
+
throw new TRPCError({
|
|
120
|
+
code: 'INTERNAL_SERVER_ERROR',
|
|
121
|
+
message: 'OIDC signing key secret has no SecretString value',
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return parseSigningJwk(secretString);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (isProduction()) {
|
|
128
|
+
throw new TRPCError({
|
|
129
|
+
code: 'INTERNAL_SERVER_ERROR',
|
|
130
|
+
message: 'OIDC signing key is not configured',
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!ephemeralDevJwk) {
|
|
135
|
+
const { privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
|
|
136
|
+
ephemeralDevJwk = privateKey.export({ format: 'jwk' }) as JWK;
|
|
137
|
+
process.emitWarning('OIDC is using an ephemeral development signing key.');
|
|
138
|
+
}
|
|
139
|
+
return ephemeralDevJwk;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Resolve (and memoize) the OIDC signing key.
|
|
144
|
+
*
|
|
145
|
+
* Cache the entire in-flight load by configuration; failures can be retried.
|
|
146
|
+
* Changing the local JWK or secret id transparently loads a fresh key.
|
|
147
|
+
*/
|
|
148
|
+
export const getOidcSigningKey = async (): Promise<OidcSigningKey> => {
|
|
149
|
+
const configured = environment.OIDC_SIGNING_KEY_JWK?.trim();
|
|
150
|
+
const secretId = environment.OIDC_SIGNING_KEY_SECRET_ID?.trim();
|
|
151
|
+
const material = configured
|
|
152
|
+
? `configured:${configured}`
|
|
153
|
+
: secretId
|
|
154
|
+
? `secret:${secretId}`
|
|
155
|
+
: isProduction()
|
|
156
|
+
? 'unconfigured-production'
|
|
157
|
+
: 'ephemeral-dev';
|
|
158
|
+
if (cachedKey && cachedKeyMaterial === material) return cachedKey;
|
|
159
|
+
|
|
160
|
+
const pending = (async (): Promise<OidcSigningKey> => {
|
|
161
|
+
const jwk = await resolveSigningJwk(configured, secretId);
|
|
162
|
+
try {
|
|
163
|
+
const privateKey = (await importJWK(jwk, OIDC_SIGNING_ALG)) as CryptoKey;
|
|
164
|
+
const publicComponents: JWK = {
|
|
165
|
+
kty: 'RSA',
|
|
166
|
+
n: jwk.n,
|
|
167
|
+
e: jwk.e,
|
|
168
|
+
};
|
|
169
|
+
const publicKey = (await importJWK(publicComponents, OIDC_SIGNING_ALG)) as CryptoKey;
|
|
170
|
+
|
|
171
|
+
const publicJwk = await exportJWK(publicKey);
|
|
172
|
+
const kid = jwk.kid ?? (await calculateJwkThumbprint(publicJwk));
|
|
173
|
+
|
|
174
|
+
publicJwk.kid = kid;
|
|
175
|
+
publicJwk.alg = jwk.alg ?? OIDC_SIGNING_ALG;
|
|
176
|
+
publicJwk.use = 'sig';
|
|
177
|
+
|
|
178
|
+
return { privateKey, publicKey, publicJwk, kid };
|
|
179
|
+
} catch {
|
|
180
|
+
throw new TRPCError({
|
|
181
|
+
code: 'INTERNAL_SERVER_ERROR',
|
|
182
|
+
message: 'OIDC signing key could not be imported',
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
})();
|
|
186
|
+
cachedKey = pending;
|
|
187
|
+
cachedKeyMaterial = material;
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
return await pending;
|
|
191
|
+
} catch (error) {
|
|
192
|
+
if (cachedKey === pending) resetOidcSigningKeyForTests();
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
/** Clear the memoized signing key. Intended for isolated tests only. */
|
|
198
|
+
export const resetOidcSigningKeyForTests = (): void => {
|
|
199
|
+
cachedKey = undefined;
|
|
200
|
+
cachedKeyMaterial = undefined;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The configured issuer identifier for this provider. Required — there is no
|
|
205
|
+
* default, so a misconfigured production deployment fails closed rather than
|
|
206
|
+
* minting tokens under a wrong issuer.
|
|
207
|
+
*/
|
|
208
|
+
export const getOidcIssuer = (): string => {
|
|
209
|
+
const issuer = environment.OIDC_ISSUER;
|
|
210
|
+
if (!issuer) {
|
|
211
|
+
throw new TRPCError({
|
|
212
|
+
code: 'INTERNAL_SERVER_ERROR',
|
|
213
|
+
message: 'OIDC issuer is not configured',
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
return issuer;
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The confidential relying-party client Keycloak uses at the token endpoint.
|
|
221
|
+
* `id_token`s are minted with this client id as their audience so a token can
|
|
222
|
+
* only be consumed by the intended relying party.
|
|
223
|
+
*/
|
|
224
|
+
export const getOidcClientId = (): string => environment.OIDC_CLIENT_ID || DEFAULT_OIDC_CLIENT_ID;
|
|
225
|
+
|
|
226
|
+
const splitCsv = (value: string | undefined): string[] =>
|
|
227
|
+
(value ?? '')
|
|
228
|
+
.split(',')
|
|
229
|
+
.map(entry => entry.trim())
|
|
230
|
+
.filter(Boolean);
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The exact redirect URIs the authorization endpoint will honor. `OIDC_REDIRECT_URIS`
|
|
234
|
+
* overrides; otherwise each `KEYCLOAK_ISSUERS` entry resolves to its single fixed
|
|
235
|
+
* broker callback `${issuer}/broker/lca-api/endpoint`. Prefixes are never accepted.
|
|
236
|
+
*/
|
|
237
|
+
export const getOidcRedirectUris = (): string[] => {
|
|
238
|
+
const explicit = splitCsv(environment.OIDC_REDIRECT_URIS);
|
|
239
|
+
if (explicit.length > 0) return explicit;
|
|
240
|
+
return splitCsv(environment.KEYCLOAK_ISSUERS).map(
|
|
241
|
+
issuer => `${issuer.replace(/\/+$/, '')}/broker/lca-api/endpoint`
|
|
242
|
+
);
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Validate the confidential client's credentials at the token endpoint using a
|
|
247
|
+
* timing-safe comparison. In production `OIDC_CLIENT_SECRET` MUST be set; when
|
|
248
|
+
* it is unset (dev/CI only) the token endpoint refuses client authentication.
|
|
249
|
+
*/
|
|
250
|
+
export const verifyOidcClientCredentials = (clientId: string, clientSecret: string): boolean => {
|
|
251
|
+
const expectedSecret = environment.OIDC_CLIENT_SECRET;
|
|
252
|
+
if (!expectedSecret) return false;
|
|
253
|
+
if (clientId !== getOidcClientId()) return false;
|
|
254
|
+
|
|
255
|
+
const provided = Buffer.from(clientSecret);
|
|
256
|
+
const expected = Buffer.from(expectedSecret);
|
|
257
|
+
if (provided.length !== expected.length) return false;
|
|
258
|
+
return crypto.timingSafeEqual(provided, expected);
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Require exact string equality; an empty allowlist rejects every redirect.
|
|
263
|
+
*/
|
|
264
|
+
export const isAllowedRedirectUri = (redirectUri: string): boolean => {
|
|
265
|
+
let candidate: URL;
|
|
266
|
+
try {
|
|
267
|
+
candidate = new URL(redirectUri);
|
|
268
|
+
} catch {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
if (candidate.protocol !== 'https:' && candidate.protocol !== 'http:') return false;
|
|
272
|
+
|
|
273
|
+
return getOidcRedirectUris().includes(redirectUri);
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
/** The public JWKS document (single active signing key). */
|
|
277
|
+
export const getOidcJwks = async (): Promise<{ keys: JWK[] }> => {
|
|
278
|
+
const { publicJwk } = await getOidcSigningKey();
|
|
279
|
+
return { keys: [publicJwk] };
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
/** The OIDC discovery document served at `/.well-known/openid-configuration`. */
|
|
283
|
+
export const getOidcDiscoveryDocument = (): Record<string, unknown> => {
|
|
284
|
+
const issuer = getOidcIssuer();
|
|
285
|
+
return {
|
|
286
|
+
issuer,
|
|
287
|
+
authorization_endpoint: `${issuer}/oidc/authorize`,
|
|
288
|
+
token_endpoint: `${issuer}/oidc/token`,
|
|
289
|
+
jwks_uri: `${issuer}/oidc/jwks`,
|
|
290
|
+
userinfo_endpoint: `${issuer}/oidc/userinfo`,
|
|
291
|
+
response_types_supported: ['code'],
|
|
292
|
+
subject_types_supported: ['public'],
|
|
293
|
+
id_token_signing_alg_values_supported: [OIDC_SIGNING_ALG],
|
|
294
|
+
scopes_supported: ['openid', 'email', 'phone', 'profile'],
|
|
295
|
+
token_endpoint_auth_methods_supported: ['client_secret_post', 'client_secret_basic'],
|
|
296
|
+
grant_types_supported: ['authorization_code'],
|
|
297
|
+
claims_supported: [
|
|
298
|
+
'sub',
|
|
299
|
+
'iss',
|
|
300
|
+
'aud',
|
|
301
|
+
'exp',
|
|
302
|
+
'iat',
|
|
303
|
+
'email',
|
|
304
|
+
'email_verified',
|
|
305
|
+
'phone_number',
|
|
306
|
+
'phone_number_verified',
|
|
307
|
+
'name',
|
|
308
|
+
'picture',
|
|
309
|
+
],
|
|
310
|
+
};
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
/** Mint a signed OIDC ID token whose audience is the confidential client. */
|
|
314
|
+
export const mintIdToken = async (params: {
|
|
315
|
+
subject: string;
|
|
316
|
+
nonce?: string;
|
|
317
|
+
ttlSeconds?: number;
|
|
318
|
+
email?: string;
|
|
319
|
+
emailVerified?: boolean;
|
|
320
|
+
phoneNumber?: string;
|
|
321
|
+
phoneNumberVerified?: boolean;
|
|
322
|
+
name?: string;
|
|
323
|
+
picture?: string;
|
|
324
|
+
}): Promise<{ token: string; expiresAt: number }> => {
|
|
325
|
+
const { privateKey, kid } = await getOidcSigningKey();
|
|
326
|
+
const issuer = getOidcIssuer();
|
|
327
|
+
const audience = getOidcClientId();
|
|
328
|
+
const now = Math.floor(Date.now() / 1000);
|
|
329
|
+
const ttl = params.ttlSeconds ?? DEFAULT_ID_TOKEN_TTL_SECONDS;
|
|
330
|
+
const expiresAt = now + ttl;
|
|
331
|
+
|
|
332
|
+
const claims: OidcIdTokenClaims = {
|
|
333
|
+
sub: params.subject,
|
|
334
|
+
iss: issuer,
|
|
335
|
+
aud: audience,
|
|
336
|
+
};
|
|
337
|
+
if (params.nonce !== undefined) claims.nonce = params.nonce;
|
|
338
|
+
if (params.email !== undefined) claims.email = params.email;
|
|
339
|
+
if (params.emailVerified !== undefined) claims.email_verified = params.emailVerified;
|
|
340
|
+
if (params.phoneNumber !== undefined) claims.phone_number = params.phoneNumber;
|
|
341
|
+
if (params.phoneNumberVerified !== undefined) {
|
|
342
|
+
claims.phone_number_verified = params.phoneNumberVerified;
|
|
343
|
+
}
|
|
344
|
+
if (params.name !== undefined) claims.name = params.name;
|
|
345
|
+
if (params.picture !== undefined) claims.picture = params.picture;
|
|
346
|
+
|
|
347
|
+
const token = await new SignJWT(claims)
|
|
348
|
+
.setProtectedHeader({ alg: OIDC_SIGNING_ALG, kid, typ: 'JWT' })
|
|
349
|
+
.setIssuedAt(now)
|
|
350
|
+
.setExpirationTime(expiresAt)
|
|
351
|
+
.sign(privateKey);
|
|
352
|
+
|
|
353
|
+
return { token, expiresAt };
|
|
354
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import cache from '@cache';
|
|
3
|
+
import { isRateLimited, recordFailure } from './rate-limit.helpers';
|
|
4
|
+
|
|
5
|
+
vi.mock('@environment', () => ({ environment: {} }));
|
|
6
|
+
|
|
7
|
+
const key = 'oidc:rate:test';
|
|
8
|
+
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
await cache.node.flushall();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
vi.useRealTimers();
|
|
15
|
+
vi.restoreAllMocks();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('failure counter expiry', () => {
|
|
19
|
+
it('increments and expires a new counter atomically', async () => {
|
|
20
|
+
await recordFailure(key, 60);
|
|
21
|
+
expect(await cache.node.get(key)).toBe('1');
|
|
22
|
+
expect(await cache.node.ttl(key)).toBeGreaterThan(0);
|
|
23
|
+
expect(await cache.node.ttl(key)).toBeLessThanOrEqual(60);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('repairs a counter without a TTL when recording a failure', async () => {
|
|
27
|
+
await cache.node.set(key, '4');
|
|
28
|
+
expect(await cache.node.ttl(key)).toBe(-1);
|
|
29
|
+
await recordFailure(key, 60);
|
|
30
|
+
expect(await cache.node.get(key)).toBe('5');
|
|
31
|
+
expect(await cache.node.ttl(key)).toBeGreaterThan(0);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('repairs an already-blocked counter on read and eventually unblocks it', async () => {
|
|
35
|
+
vi.useFakeTimers();
|
|
36
|
+
await cache.node.set(key, '50');
|
|
37
|
+
expect(await isRateLimited(key)).toBe(true);
|
|
38
|
+
expect(await cache.node.ttl(key)).toBe(600);
|
|
39
|
+
vi.advanceTimersByTime(601_000);
|
|
40
|
+
expect(await isRateLimited(key)).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('does not extend a live window on reads or writes', async () => {
|
|
44
|
+
vi.useFakeTimers();
|
|
45
|
+
await recordFailure(key, 60);
|
|
46
|
+
vi.advanceTimersByTime(10_000);
|
|
47
|
+
await recordFailure(key, 60);
|
|
48
|
+
expect(await isRateLimited(key)).toBe(false);
|
|
49
|
+
expect(await cache.node.ttl(key)).toBe(50);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('does not create a counter for successful traffic', async () => {
|
|
53
|
+
expect(await isRateLimited(key)).toBe(false);
|
|
54
|
+
expect(await cache.node.exists(key)).toBe(0);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('keeps the read-side fail-open behavior when Redis is unavailable', async () => {
|
|
58
|
+
vi.spyOn(cache.node, 'eval').mockRejectedValueOnce(new Error('Redis unavailable'));
|
|
59
|
+
expect(await isRateLimited(key)).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { FastifyRequest } from 'fastify';
|
|
2
|
+
|
|
3
|
+
import cache from '@cache';
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_MAX_FAILED_ATTEMPTS = 50;
|
|
6
|
+
export const DEFAULT_RATE_LIMIT_WINDOW_SECONDS = 10 * 60;
|
|
7
|
+
|
|
8
|
+
// Repair legacy counters during reads too: an already-blocked caller never reaches recordFailure.
|
|
9
|
+
const READ_ATTEMPTS = `
|
|
10
|
+
local attempts = redis.call('GET', KEYS[1])
|
|
11
|
+
if attempts and redis.call('TTL', KEYS[1]) < 0 then
|
|
12
|
+
redis.call('EXPIRE', KEYS[1], ARGV[1])
|
|
13
|
+
end
|
|
14
|
+
return attempts
|
|
15
|
+
`;
|
|
16
|
+
|
|
17
|
+
const RECORD_FAILURE = `
|
|
18
|
+
local attempts = redis.call('INCR', KEYS[1])
|
|
19
|
+
if redis.call('TTL', KEYS[1]) < 0 then
|
|
20
|
+
redis.call('EXPIRE', KEYS[1], ARGV[1])
|
|
21
|
+
end
|
|
22
|
+
return attempts
|
|
23
|
+
`;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Failures-only rate limiting. Callers check `isRateLimited` up front and call
|
|
27
|
+
* `recordFailure` only on rejected attempts, so legitimate traffic behind a
|
|
28
|
+
* shared NAT never counts toward the ceiling. Increment and TTL repair run in
|
|
29
|
+
* one script without extending an existing window.
|
|
30
|
+
*
|
|
31
|
+
* Both halves fail open: if Redis is unreachable, reads return false and
|
|
32
|
+
* `recordFailure` logs and returns
|
|
33
|
+
* rather than turning a rejected login attempt into a 500. Limiting is a
|
|
34
|
+
* brute-force speed bump, not the access control — that is the ticket / code /
|
|
35
|
+
* client secret entropy.
|
|
36
|
+
*/
|
|
37
|
+
export const isRateLimited = async (
|
|
38
|
+
key: string,
|
|
39
|
+
max = DEFAULT_MAX_FAILED_ATTEMPTS
|
|
40
|
+
): Promise<boolean> => {
|
|
41
|
+
const redis = cache.redis ?? cache.node;
|
|
42
|
+
try {
|
|
43
|
+
const attempts =
|
|
44
|
+
Number(await redis.eval(READ_ATTEMPTS, 1, key, DEFAULT_RATE_LIMIT_WINDOW_SECONDS)) || 0;
|
|
45
|
+
return attempts >= max;
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const recordFailure = async (
|
|
52
|
+
key: string,
|
|
53
|
+
windowSeconds = DEFAULT_RATE_LIMIT_WINDOW_SECONDS
|
|
54
|
+
): Promise<void> => {
|
|
55
|
+
const redis = cache.redis ?? cache.node;
|
|
56
|
+
try {
|
|
57
|
+
await redis.eval(RECORD_FAILURE, 1, key, windowSeconds);
|
|
58
|
+
} catch (error) {
|
|
59
|
+
console.error('Rate limit recordFailure error', error);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Client address for rate-limit keys.
|
|
65
|
+
*
|
|
66
|
+
* `request.ip` is the transport-level peer: under `serverless-http` it is API
|
|
67
|
+
* Gateway's `requestContext.http.sourceIp`, which the caller cannot forge. The
|
|
68
|
+
* `x-forwarded-for` header is NOT trusted — API Gateway appends the real source
|
|
69
|
+
* to whatever the client sent, so the first hop is attacker-controlled and would
|
|
70
|
+
* let anyone mint a fresh per-IP bucket per request.
|
|
71
|
+
*/
|
|
72
|
+
export const getRequestClientIp = (request: Pick<FastifyRequest, 'ip'>): string =>
|
|
73
|
+
request.ip || 'unknown';
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native Google / Apple `id_token` verification for the lca-api auth router
|
|
3
|
+
* (AD-2 / AD-3).
|
|
4
|
+
*
|
|
5
|
+
* The native app obtains an Apple/Google `id_token` through the platform SDK
|
|
6
|
+
* and posts it here. We verify it against the provider's published JWKS — exact
|
|
7
|
+
* issuer + audience (client id) allowlists, signature, `exp`/`nbf` — before
|
|
8
|
+
* issuing a login ticket. A test seam (`setSocialJwksResolverForTests`) swaps
|
|
9
|
+
* the remote JWKS for a local key set so the flow is unit-testable without
|
|
10
|
+
* network access.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { environment } from '@environment';
|
|
14
|
+
import { TRPCError } from '@trpc/server';
|
|
15
|
+
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
|
16
|
+
import type { JWTVerifyGetKey } from 'jose';
|
|
17
|
+
import { z } from 'zod';
|
|
18
|
+
|
|
19
|
+
export type SocialProviderId = 'google' | 'apple';
|
|
20
|
+
|
|
21
|
+
export interface SocialProviderConfig {
|
|
22
|
+
issuers: string[];
|
|
23
|
+
audiences: string[];
|
|
24
|
+
jwksUri: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SocialClaims {
|
|
28
|
+
sub: string;
|
|
29
|
+
iss: string;
|
|
30
|
+
email?: string;
|
|
31
|
+
email_verified?: boolean;
|
|
32
|
+
name?: string;
|
|
33
|
+
picture?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const GOOGLE_DEFAULT_ISSUERS = ['https://accounts.google.com', 'accounts.google.com'];
|
|
37
|
+
const GOOGLE_JWKS_URI = 'https://www.googleapis.com/oauth2/v3/certs';
|
|
38
|
+
const APPLE_DEFAULT_ISSUERS = ['https://appleid.apple.com'];
|
|
39
|
+
const APPLE_JWKS_URI = 'https://appleid.apple.com/auth/keys';
|
|
40
|
+
|
|
41
|
+
const claimsSchema = z
|
|
42
|
+
.object({
|
|
43
|
+
sub: z.string().min(1),
|
|
44
|
+
iss: z.string().min(1),
|
|
45
|
+
email: z.string().min(1).optional(),
|
|
46
|
+
email_verified: z.union([z.boolean(), z.string()]).optional(),
|
|
47
|
+
name: z.string().optional(),
|
|
48
|
+
picture: z.string().optional(),
|
|
49
|
+
})
|
|
50
|
+
.passthrough();
|
|
51
|
+
|
|
52
|
+
const splitCsv = (value: string | undefined): string[] =>
|
|
53
|
+
(value ?? '')
|
|
54
|
+
.split(',')
|
|
55
|
+
.map(entry => entry.trim())
|
|
56
|
+
.filter(Boolean);
|
|
57
|
+
|
|
58
|
+
export const getSocialProviderConfig = (
|
|
59
|
+
provider: SocialProviderId
|
|
60
|
+
): SocialProviderConfig | null => {
|
|
61
|
+
if (provider === 'google') {
|
|
62
|
+
const audiences = splitCsv(environment.GOOGLE_OAUTH_CLIENT_IDS);
|
|
63
|
+
if (audiences.length === 0) return null;
|
|
64
|
+
return {
|
|
65
|
+
audiences,
|
|
66
|
+
issuers: GOOGLE_DEFAULT_ISSUERS,
|
|
67
|
+
jwksUri: GOOGLE_JWKS_URI,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const audiences = splitCsv(environment.APPLE_OAUTH_CLIENT_IDS);
|
|
72
|
+
if (audiences.length === 0) return null;
|
|
73
|
+
return {
|
|
74
|
+
audiences,
|
|
75
|
+
issuers: APPLE_DEFAULT_ISSUERS,
|
|
76
|
+
jwksUri: APPLE_JWKS_URI,
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const jwksByUri = new Map<string, JWTVerifyGetKey>();
|
|
81
|
+
let resolverForTests: ((jwksUri: string) => JWTVerifyGetKey) | undefined;
|
|
82
|
+
|
|
83
|
+
export const setSocialJwksResolverForTests = (
|
|
84
|
+
resolver: ((jwksUri: string) => JWTVerifyGetKey) | undefined
|
|
85
|
+
): void => {
|
|
86
|
+
resolverForTests = resolver;
|
|
87
|
+
jwksByUri.clear();
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const getSocialJwks = (jwksUri: string): JWTVerifyGetKey => {
|
|
91
|
+
let jwks = jwksByUri.get(jwksUri);
|
|
92
|
+
if (!jwks) {
|
|
93
|
+
jwks = resolverForTests ? resolverForTests(jwksUri) : createRemoteJWKSet(new URL(jwksUri));
|
|
94
|
+
jwksByUri.set(jwksUri, jwks);
|
|
95
|
+
}
|
|
96
|
+
return jwks;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export const verifySocialIdToken = async (
|
|
100
|
+
provider: SocialProviderId,
|
|
101
|
+
token: string
|
|
102
|
+
): Promise<SocialClaims> => {
|
|
103
|
+
const config = getSocialProviderConfig(provider);
|
|
104
|
+
if (!config) {
|
|
105
|
+
throw new TRPCError({
|
|
106
|
+
code: 'UNAUTHORIZED',
|
|
107
|
+
message: `Sign-in with ${provider} is not configured.`,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let payload: unknown;
|
|
112
|
+
try {
|
|
113
|
+
({ payload } = await jwtVerify(token, getSocialJwks(config.jwksUri), {
|
|
114
|
+
issuer: config.issuers,
|
|
115
|
+
audience: config.audiences,
|
|
116
|
+
algorithms: ['RS256', 'ES256'],
|
|
117
|
+
requiredClaims: ['sub', 'exp', 'email', 'email_verified'],
|
|
118
|
+
}));
|
|
119
|
+
} catch {
|
|
120
|
+
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid social token' });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const result = claimsSchema.safeParse(payload);
|
|
124
|
+
if (!result.success) {
|
|
125
|
+
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid social token' });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const claims = result.data;
|
|
129
|
+
const emailVerified =
|
|
130
|
+
claims.email_verified === true ||
|
|
131
|
+
(provider === 'apple' && claims.email_verified === 'true');
|
|
132
|
+
if (!claims.email || !emailVerified) {
|
|
133
|
+
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid social token' });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
sub: claims.sub,
|
|
138
|
+
iss: claims.iss,
|
|
139
|
+
email: claims.email,
|
|
140
|
+
email_verified: emailVerified,
|
|
141
|
+
name: claims.name,
|
|
142
|
+
picture: claims.picture,
|
|
143
|
+
};
|
|
144
|
+
};
|