@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
|
@@ -7,4 +7,7 @@ export const getSigningAuthoritiesCollection = () => {
|
|
|
7
7
|
|
|
8
8
|
export const SigningAuthorities = getSigningAuthoritiesCollection();
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
// Fire-and-forget at module load; see accesslayer/notifications for why it must not reject.
|
|
11
|
+
SigningAuthorities.createIndex({ ownerDid: 1, name: 1 }, { unique: true }).catch(error => {
|
|
12
|
+
console.error('Signing authority unique index creation failed:', error);
|
|
13
|
+
});
|
package/src/app.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { preferencesRouter } from '@routes/preferences';
|
|
|
12
12
|
import { keysRouter } from '@routes/keys';
|
|
13
13
|
import { escrowRouter } from '@routes/escrow';
|
|
14
14
|
import { qrLoginRouter } from '@routes/qr-login';
|
|
15
|
+
import { authRouter } from '@routes/auth';
|
|
15
16
|
import { testRouter, type TestRouter } from '@routes/test';
|
|
16
17
|
import { environment } from '@environment';
|
|
17
18
|
|
|
@@ -28,6 +29,7 @@ const routes = {
|
|
|
28
29
|
keys: keysRouter,
|
|
29
30
|
escrow: escrowRouter,
|
|
30
31
|
qrLogin: qrLoginRouter,
|
|
32
|
+
auth: authRouter,
|
|
31
33
|
};
|
|
32
34
|
|
|
33
35
|
export const appRouter = t.router<typeof routes & { test?: TestRouter }>({
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import cache from '@cache';
|
|
3
|
+
import { consumeMatchingCode } from './getDel';
|
|
4
|
+
|
|
5
|
+
vi.mock('@cache', async () => {
|
|
6
|
+
const { default: Redis } = await import('ioredis-mock');
|
|
7
|
+
return { default: { node: new Redis() } };
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
afterEach(async () => {
|
|
11
|
+
await cache.node.flushall();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe('consumeMatchingCode', () => {
|
|
15
|
+
it('preserves a valid code after a wrong guess', async () => {
|
|
16
|
+
await cache.node.set('login-code:test@example.com', '123456');
|
|
17
|
+
expect(await consumeMatchingCode('login-code:test@example.com', '000000')).toBe(false);
|
|
18
|
+
expect(await cache.node.get('login-code:test@example.com')).toBe('123456');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('allows exactly one concurrent redemption and rejects replay', async () => {
|
|
22
|
+
await cache.node.set('login-code:test@example.com', '123456');
|
|
23
|
+
const results = await Promise.all([
|
|
24
|
+
consumeMatchingCode('login-code:test@example.com', '123456'),
|
|
25
|
+
consumeMatchingCode('login-code:test@example.com', '123456'),
|
|
26
|
+
]);
|
|
27
|
+
expect(results.filter(Boolean)).toHaveLength(1);
|
|
28
|
+
expect(await consumeMatchingCode('login-code:test@example.com', '123456')).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('rejects missing codes', async () => {
|
|
32
|
+
expect(await consumeMatchingCode('login-code:missing@example.com', '123456')).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import cache from '@cache';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Atomically read-and-delete a key so its value can only ever be consumed once.
|
|
5
|
+
*
|
|
6
|
+
* Redis `GETDEL` is a single command, so concurrent redemptions return the value
|
|
7
|
+
* to exactly one caller — closing the read-then-delete race that `get` + `delete`
|
|
8
|
+
* would leave open. Used for login codes, login tickets and authorization codes.
|
|
9
|
+
*
|
|
10
|
+
* NOTE: `GETDEL` requires Redis >= 6.2. Deployed ElastiCache clusters are on 7.x
|
|
11
|
+
* (see the `ElasticCacheCluster` note in `serverless.yml`); `ioredis-mock`
|
|
12
|
+
* implements it for the no-Redis fallback.
|
|
13
|
+
*/
|
|
14
|
+
export const getDel = async (key: string): Promise<string | null> => {
|
|
15
|
+
const redis = cache.redis ?? cache.node;
|
|
16
|
+
return redis.getdel(key);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** Consume a code only when its value matches, atomically preventing replay. */
|
|
20
|
+
export const consumeMatchingCode = async (key: string, code: string): Promise<boolean> => {
|
|
21
|
+
const redis = cache.redis ?? cache.node;
|
|
22
|
+
const consumed = await redis.eval(
|
|
23
|
+
`if redis.call('GET', KEYS[1]) == ARGV[1] then
|
|
24
|
+
return redis.call('DEL', KEYS[1])
|
|
25
|
+
end
|
|
26
|
+
return 0`,
|
|
27
|
+
1,
|
|
28
|
+
key,
|
|
29
|
+
code
|
|
30
|
+
);
|
|
31
|
+
return consumed === 1;
|
|
32
|
+
};
|
package/src/cache/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/// <reference path="../global.d.ts" />
|
|
2
2
|
|
|
3
|
-
import { environment } from '@
|
|
3
|
+
import { environment } from '@config/cacheEnvironment';
|
|
4
4
|
import Redis, { RedisValue, RedisKey } from 'ioredis';
|
|
5
5
|
import MemoryRedis, { Redis as RedisMockType } from 'ioredis-mock';
|
|
6
6
|
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import cache from '@cache';
|
|
3
|
+
import { getDel } from '@cache/getDel';
|
|
4
|
+
|
|
5
|
+
export interface LoginTicketPayload {
|
|
6
|
+
subject: string;
|
|
7
|
+
email?: string;
|
|
8
|
+
emailVerified?: boolean;
|
|
9
|
+
phoneNumber?: string;
|
|
10
|
+
phoneNumberVerified?: boolean;
|
|
11
|
+
name?: string;
|
|
12
|
+
picture?: string;
|
|
13
|
+
identityKey: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const issueLoginTicket = async (payload: LoginTicketPayload): Promise<string> => {
|
|
17
|
+
const ticket = randomBytes(32).toString('base64url');
|
|
18
|
+
const result = await cache.set(`login-ticket:${ticket}`, JSON.stringify(payload), 60);
|
|
19
|
+
if (result !== 'OK') throw new Error('Failed to persist login ticket');
|
|
20
|
+
return ticket;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const redeemLoginTicket = async (ticket: string): Promise<LoginTicketPayload | null> => {
|
|
24
|
+
const raw = await getDel(`login-ticket:${ticket}`);
|
|
25
|
+
return raw ? (JSON.parse(raw) as LoginTicketPayload) : null;
|
|
26
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { applyLcaApiStageConfig } from './stageConfig';
|
|
2
|
+
|
|
3
|
+
// Side-effect import: runs before @environment is evaluated so Docker/local processes
|
|
4
|
+
// pick up checked-in per-stage config. Keyed on CONFIG_STAGE (Lambda uses LAMBDA_STAGE);
|
|
5
|
+
// explicit non-empty env values from the developer's .env still win.
|
|
6
|
+
applyLcaApiStageConfig(process.env.CONFIG_STAGE);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import 'dotenv/config';
|
|
2
|
+
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import {
|
|
5
|
+
optionalEnvironmentPort,
|
|
6
|
+
optionalEnvironmentString,
|
|
7
|
+
parseEnvironment,
|
|
8
|
+
} from '@learncard/helpers';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Focused environment contract for the Redis-backed cache so `src/cache/index.ts`
|
|
12
|
+
* can boot without the full schema's required SEED/MONGO. The cache reads only
|
|
13
|
+
* REDIS_HOST/REDIS_PORT, mirroring the parsers used by the full schema.
|
|
14
|
+
*/
|
|
15
|
+
export const cacheEnvironmentShape = {
|
|
16
|
+
REDIS_HOST: optionalEnvironmentString,
|
|
17
|
+
REDIS_PORT: optionalEnvironmentPort,
|
|
18
|
+
} satisfies z.ZodRawShape;
|
|
19
|
+
|
|
20
|
+
export const cacheEnvironmentSchema = z.object(cacheEnvironmentShape);
|
|
21
|
+
|
|
22
|
+
export type CacheEnvironment = z.output<typeof cacheEnvironmentSchema>;
|
|
23
|
+
|
|
24
|
+
export const parseCacheEnvironment = (
|
|
25
|
+
raw: Record<string, unknown>,
|
|
26
|
+
source = 'process environment'
|
|
27
|
+
): CacheEnvironment =>
|
|
28
|
+
parseEnvironment(cacheEnvironmentSchema, raw, {
|
|
29
|
+
project: 'lca-api (cache)',
|
|
30
|
+
source,
|
|
31
|
+
examplePath: 'services/learn-card-network/lca-api/.env.example',
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export const environment = parseCacheEnvironment(process.env);
|
|
@@ -1,5 +1,18 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { parseLcaApiEnvironment } from './environment';
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { isEscrowReleaseKillSwitchEnabled, parseLcaApiEnvironment } from './environment';
|
|
3
|
+
|
|
4
|
+
afterEach(() => vi.unstubAllEnvs());
|
|
5
|
+
|
|
6
|
+
describe('live escrow release kill switch', () => {
|
|
7
|
+
it.each(['1', 'true'])('blocks releases for %s', value => {
|
|
8
|
+
vi.stubEnv('ESCROW_RELEASE_KILL_SWITCH', value);
|
|
9
|
+
expect(isEscrowReleaseKillSwitchEnabled()).toBe(true);
|
|
10
|
+
});
|
|
11
|
+
it.each(['0', 'false', ''])('allows releases for %s', value => {
|
|
12
|
+
vi.stubEnv('ESCROW_RELEASE_KILL_SWITCH', value);
|
|
13
|
+
expect(isEscrowReleaseKillSwitchEnabled()).toBe(false);
|
|
14
|
+
});
|
|
15
|
+
});
|
|
3
16
|
|
|
4
17
|
const production = {
|
|
5
18
|
NODE_ENV: 'production',
|
|
@@ -114,3 +127,33 @@ describe('signing-authority seed environment', () => {
|
|
|
114
127
|
).not.toThrow();
|
|
115
128
|
});
|
|
116
129
|
});
|
|
130
|
+
|
|
131
|
+
describe('Keycloak startup configuration', () => {
|
|
132
|
+
it.each([undefined, '', ' ', ' , , '])('rejects missing audiences: %s', audiences => {
|
|
133
|
+
expect(() =>
|
|
134
|
+
parseLcaApiEnvironment({
|
|
135
|
+
NODE_ENV: 'test',
|
|
136
|
+
IS_OFFLINE: 'true',
|
|
137
|
+
KEYCLOAK_ISSUERS: 'https://identity.example/realms/learncard',
|
|
138
|
+
KEYCLOAK_AUDIENCES: audiences,
|
|
139
|
+
})
|
|
140
|
+
).toThrow(/KEYCLOAK_AUDIENCES/);
|
|
141
|
+
});
|
|
142
|
+
it('allows disabled Keycloak and valid issuer/audience configuration', () => {
|
|
143
|
+
expect(() =>
|
|
144
|
+
parseLcaApiEnvironment({
|
|
145
|
+
NODE_ENV: 'test',
|
|
146
|
+
IS_OFFLINE: 'true',
|
|
147
|
+
KEYCLOAK_ISSUERS: ' , ',
|
|
148
|
+
})
|
|
149
|
+
).not.toThrow();
|
|
150
|
+
expect(() =>
|
|
151
|
+
parseLcaApiEnvironment({
|
|
152
|
+
NODE_ENV: 'test',
|
|
153
|
+
IS_OFFLINE: 'true',
|
|
154
|
+
KEYCLOAK_ISSUERS: 'https://identity.example/realms/learncard',
|
|
155
|
+
KEYCLOAK_AUDIENCES: ' , learncard-app, ',
|
|
156
|
+
})
|
|
157
|
+
).not.toThrow();
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -13,6 +13,9 @@ import {
|
|
|
13
13
|
|
|
14
14
|
export const lcaApiEnvironmentShape = {
|
|
15
15
|
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
|
16
|
+
LAMBDA_STAGE: optionalEnvironmentString,
|
|
17
|
+
CONFIG_STAGE: optionalEnvironmentString,
|
|
18
|
+
RUNTIME_SECRETS_ID: optionalEnvironmentString,
|
|
16
19
|
PORT: environmentPort.default(3000),
|
|
17
20
|
SEED: requiredEnvironmentString,
|
|
18
21
|
SA_SEED_KMS_KEY_ARN: optionalEnvironmentString,
|
|
@@ -30,6 +33,17 @@ export const lcaApiEnvironmentShape = {
|
|
|
30
33
|
OPENAI_API_KEY: optionalEnvironmentString,
|
|
31
34
|
METABASE_SECRET_KEY: optionalEnvironmentString,
|
|
32
35
|
SCOUTS_SSO_CLIENT_SECRET: optionalEnvironmentString,
|
|
36
|
+
KEYCLOAK_ISSUERS: optionalEnvironmentString,
|
|
37
|
+
KEYCLOAK_AUDIENCES: optionalEnvironmentString,
|
|
38
|
+
KEYCLOAK_JWKS_URL_OVERRIDES: optionalEnvironmentString,
|
|
39
|
+
OIDC_ISSUER: optionalEnvironmentString,
|
|
40
|
+
OIDC_SIGNING_KEY_JWK: optionalEnvironmentString,
|
|
41
|
+
OIDC_SIGNING_KEY_SECRET_ID: optionalEnvironmentString,
|
|
42
|
+
OIDC_CLIENT_ID: optionalEnvironmentString,
|
|
43
|
+
OIDC_CLIENT_SECRET: optionalEnvironmentString,
|
|
44
|
+
OIDC_REDIRECT_URIS: optionalEnvironmentString,
|
|
45
|
+
GOOGLE_OAUTH_CLIENT_IDS: optionalEnvironmentString,
|
|
46
|
+
APPLE_OAUTH_CLIENT_IDS: optionalEnvironmentString,
|
|
33
47
|
POSTMARK_SERVER_TOKEN: optionalEnvironmentString,
|
|
34
48
|
POSTMARK_FROM_EMAIL: optionalEnvironmentString,
|
|
35
49
|
POSTMARK_BRAND_NAME: optionalEnvironmentString,
|
|
@@ -42,6 +56,16 @@ export const lcaApiEnvironmentShape = {
|
|
|
42
56
|
ESCROW_ENCLAVE_MODE: optionalEnvironmentString.pipe(z.enum(['software', 'remote']).optional()),
|
|
43
57
|
ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON: optionalEnvironmentString,
|
|
44
58
|
ESCROW_ENCLAVE_ACTIVE_KEY_ID: optionalEnvironmentString,
|
|
59
|
+
ESCROW_ENCLAVE_REMOTE_URL: optionalEnvironmentUrl,
|
|
60
|
+
ESCROW_ENCLAVE_REMOTE_TOKEN: optionalEnvironmentString,
|
|
61
|
+
/** Operator-flipped, manual-only kill switch (P7.1). When true, startRecovery/completeRecovery
|
|
62
|
+
* refuse with a friendly message; hold cancellation and notifications keep working. Never
|
|
63
|
+
* auto-flipped by the escrow-ledger-monitor — a compromised monitor must not be able to
|
|
64
|
+
* silently DoS recovery. See services/escrow-ledger-monitor/README.md. */
|
|
65
|
+
ESCROW_RELEASE_KILL_SWITCH: optionalEnvironmentBoolean,
|
|
66
|
+
ESCROW_ENCLAVE_REMOTE_TIMEOUT_MS: optionalEnvironmentString
|
|
67
|
+
.transform(value => (value === undefined ? 10_000 : Number(value)))
|
|
68
|
+
.pipe(z.number().int().positive().max(30_000)),
|
|
45
69
|
ESCROW_HOLD_DURATION_MS: optionalEnvironmentString
|
|
46
70
|
.transform(value => (value === undefined ? 604_800_000 : Number(value)))
|
|
47
71
|
.pipe(z.number().int().positive().max(Number.MAX_SAFE_INTEGER)),
|
|
@@ -74,29 +98,15 @@ export const lcaApiEnvironmentSchema = z
|
|
|
74
98
|
};
|
|
75
99
|
})
|
|
76
100
|
.superRefine((environment, context) => {
|
|
77
|
-
if (
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
context.addIssue({
|
|
87
|
-
code: 'custom',
|
|
88
|
-
path: ['ESCROW_ENCLAVE_ACTIVE_KEY_ID'],
|
|
89
|
-
message: 'Software escrow requires an active key ID present in the key map',
|
|
90
|
-
});
|
|
91
|
-
}
|
|
92
|
-
} catch {
|
|
93
|
-
context.addIssue({
|
|
94
|
-
code: 'custom',
|
|
95
|
-
path: ['ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON'],
|
|
96
|
-
message:
|
|
97
|
-
'Software escrow requires a valid nonempty key ID to private key JSON map',
|
|
98
|
-
});
|
|
99
|
-
}
|
|
101
|
+
if (
|
|
102
|
+
environment.KEYCLOAK_ISSUERS?.split(',').some(value => value.trim()) &&
|
|
103
|
+
!environment.KEYCLOAK_AUDIENCES?.split(',').some(value => value.trim())
|
|
104
|
+
) {
|
|
105
|
+
context.addIssue({
|
|
106
|
+
code: 'custom',
|
|
107
|
+
path: ['KEYCLOAK_AUDIENCES'],
|
|
108
|
+
message: 'Required when KEYCLOAK_ISSUERS is configured',
|
|
109
|
+
});
|
|
100
110
|
}
|
|
101
111
|
if (environment.SA_SEED_KMS_KEY_ARN) {
|
|
102
112
|
if (
|
|
@@ -133,6 +143,56 @@ export const lcaApiEnvironmentSchema = z
|
|
|
133
143
|
message: 'Plaintext writes require the compatibility reader',
|
|
134
144
|
});
|
|
135
145
|
}
|
|
146
|
+
|
|
147
|
+
if (environment.ESCROW_ENCLAVE_MODE === 'software') {
|
|
148
|
+
try {
|
|
149
|
+
const keys = parseEscrowPrivateKeys(
|
|
150
|
+
environment.ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON ?? ''
|
|
151
|
+
);
|
|
152
|
+
if (
|
|
153
|
+
!environment.ESCROW_ENCLAVE_ACTIVE_KEY_ID ||
|
|
154
|
+
!Object.hasOwn(keys, environment.ESCROW_ENCLAVE_ACTIVE_KEY_ID)
|
|
155
|
+
) {
|
|
156
|
+
context.addIssue({
|
|
157
|
+
code: 'custom',
|
|
158
|
+
path: ['ESCROW_ENCLAVE_ACTIVE_KEY_ID'],
|
|
159
|
+
message: 'Software escrow requires an active key ID present in the key map',
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
} catch {
|
|
163
|
+
context.addIssue({
|
|
164
|
+
code: 'custom',
|
|
165
|
+
path: ['ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON'],
|
|
166
|
+
message:
|
|
167
|
+
'Software escrow requires a valid nonempty key ID to private key JSON map',
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// Format-only: absence at 'remote' mode is intentionally NOT a parse-time
|
|
172
|
+
// failure. getEscrowEnclave() fails closed per-request instead, so a
|
|
173
|
+
// misconfigured remote backend degrades that one feature rather than
|
|
174
|
+
// crashing the whole service at boot (see escrow-enclave/index.ts).
|
|
175
|
+
if (environment.ESCROW_ENCLAVE_REMOTE_URL) {
|
|
176
|
+
const { protocol, hostname } = new URL(environment.ESCROW_ENCLAVE_REMOTE_URL);
|
|
177
|
+
const isLocalhost = ['localhost', '127.0.0.1', '::1'].includes(hostname);
|
|
178
|
+
if (protocol !== 'https:' && environment.NODE_ENV !== 'test' && !isLocalhost) {
|
|
179
|
+
context.addIssue({
|
|
180
|
+
code: 'custom',
|
|
181
|
+
path: ['ESCROW_ENCLAVE_REMOTE_URL'],
|
|
182
|
+
message: 'Must use https:// outside tests or localhost',
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (
|
|
187
|
+
environment.ESCROW_ENCLAVE_REMOTE_TOKEN &&
|
|
188
|
+
environment.ESCROW_ENCLAVE_REMOTE_TOKEN.length < 32
|
|
189
|
+
) {
|
|
190
|
+
context.addIssue({
|
|
191
|
+
code: 'custom',
|
|
192
|
+
path: ['ESCROW_ENCLAVE_REMOTE_TOKEN'],
|
|
193
|
+
message: 'Must be at least 32 characters',
|
|
194
|
+
});
|
|
195
|
+
}
|
|
136
196
|
if (
|
|
137
197
|
environment.NODE_ENV === 'production' &&
|
|
138
198
|
!environment.IS_OFFLINE &&
|
|
@@ -185,6 +245,13 @@ export const parseLcaApiEnvironment = (
|
|
|
185
245
|
);
|
|
186
246
|
};
|
|
187
247
|
|
|
248
|
+
// Read live from process.env (not the frozen `environment` snapshot below) so an
|
|
249
|
+
// operator's flip takes effect on the next request without a full restart, and so
|
|
250
|
+
// tests can toggle it per-case. The escrow-ledger-monitor must never set this itself
|
|
251
|
+
// — see services/escrow-ledger-monitor/README.md.
|
|
252
|
+
export const isEscrowReleaseKillSwitchEnabled = (): boolean =>
|
|
253
|
+
optionalEnvironmentBoolean.parse(process.env.ESCROW_RELEASE_KILL_SWITCH) ?? false;
|
|
254
|
+
|
|
188
255
|
/** Parse configuration without exposing secret values in validation errors. */
|
|
189
256
|
export const parseEscrowPrivateKeys = (serialized: string): Record<string, string> => {
|
|
190
257
|
try {
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { mergeRuntimeSecrets, resolveStageDefaults } from '@learncard/service-config';
|
|
4
|
+
|
|
5
|
+
import { parseLcaApiEnvironment } from './environment';
|
|
6
|
+
import { base, stages } from './stageConfig';
|
|
7
|
+
|
|
8
|
+
const require = createRequire(import.meta.url);
|
|
9
|
+
const functions: {
|
|
10
|
+
api: () => Record<string, string>;
|
|
11
|
+
provider: (context?: { options: { stage: string } }) => Record<string, unknown>;
|
|
12
|
+
} = require('../../serverless.function-env.cjs');
|
|
13
|
+
|
|
14
|
+
// Deployed precedence for an api function in bundle mode: real Lambda env > bundle > stage file.
|
|
15
|
+
const deployedEnvironment = (
|
|
16
|
+
bundle: Record<string, string>
|
|
17
|
+
): Record<string, string | undefined> => {
|
|
18
|
+
const lambdaEnv = Object.fromEntries(
|
|
19
|
+
Object.entries({ ...functions.provider({ options: { stage: 'dev' } }), ...functions.api() })
|
|
20
|
+
// CloudFormation intrinsics (KMS ARN, Redis endpoint) resolve only at deploy time.
|
|
21
|
+
.filter((entry): entry is [string, string] => typeof entry[1] === 'string')
|
|
22
|
+
);
|
|
23
|
+
const stageDefaults = resolveStageDefaults({ base, stages, stage: 'dev', env: lambdaEnv });
|
|
24
|
+
const resolvedIntrinsics = {
|
|
25
|
+
SA_SEED_KMS_KEY_ARN: 'arn:aws:kms:us-east-1:000000000000:key/test',
|
|
26
|
+
};
|
|
27
|
+
return mergeRuntimeSecrets({ ...stageDefaults, ...lambdaEnv, ...resolvedIntrinsics }, bundle);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
afterEach(() => vi.unstubAllEnvs());
|
|
31
|
+
|
|
32
|
+
describe('escrow in runtime-secrets bundle mode', () => {
|
|
33
|
+
it('keeps software escrow enabled with keys supplied only by the bundle', () => {
|
|
34
|
+
vi.stubEnv('RUNTIME_SECRETS_ID', 'lca-api/dev/runtime-secrets');
|
|
35
|
+
vi.stubEnv('ESCROW_ENCLAVE_MODE', 'software');
|
|
36
|
+
vi.stubEnv('SEED', '');
|
|
37
|
+
|
|
38
|
+
const activeKeyId = stages.dev.ESCROW_ENCLAVE_ACTIVE_KEY_ID;
|
|
39
|
+
const env = parseLcaApiEnvironment(
|
|
40
|
+
deployedEnvironment({
|
|
41
|
+
SEED: 'a'.repeat(64),
|
|
42
|
+
MONGO_URI: 'mongodb://localhost:27017',
|
|
43
|
+
MONGO_DB_NAME: 'test',
|
|
44
|
+
POSTMARK_SERVER_TOKEN: 'postmark',
|
|
45
|
+
ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON: JSON.stringify({ [activeKeyId]: 'k' }),
|
|
46
|
+
})
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
expect(env.ESCROW_ENCLAVE_MODE).toBe('software');
|
|
50
|
+
expect(env.ESCROW_ENCLAVE_ACTIVE_KEY_ID).toBe(activeKeyId);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { lcaApiEnvironmentShape } from './environment';
|
|
6
|
+
import { stages } from './stageConfig';
|
|
7
|
+
|
|
8
|
+
const require = createRequire(import.meta.url);
|
|
9
|
+
const serverlessRequire = createRequire(require.resolve('serverless/package.json'));
|
|
10
|
+
const yaml: { load: (source: string) => unknown } = serverlessRequire('js-yaml');
|
|
11
|
+
|
|
12
|
+
const functionEnvironment = require('../../serverless.function-env.cjs') as Record<
|
|
13
|
+
'api' | 'escrow',
|
|
14
|
+
() => Record<string, string>
|
|
15
|
+
>;
|
|
16
|
+
|
|
17
|
+
// Every schema ESCROW_* setting has exactly one source under the config model.
|
|
18
|
+
const SOURCES = {
|
|
19
|
+
// Credentials and the endpoints bound to them: runtime bundle, GitHub fallback.
|
|
20
|
+
bundle: [
|
|
21
|
+
'ESCROW_RELAY_URL',
|
|
22
|
+
'ESCROW_RELAY_AUTH_TOKEN',
|
|
23
|
+
'ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON',
|
|
24
|
+
'ESCROW_ENCLAVE_REMOTE_URL',
|
|
25
|
+
'ESCROW_ENCLAVE_REMOTE_TOKEN',
|
|
26
|
+
],
|
|
27
|
+
// Operator toggles: GitHub vars forwarded in every mode.
|
|
28
|
+
toggle: ['ESCROW_ENCLAVE_MODE', 'ESCROW_RELEASE_KILL_SWITCH'],
|
|
29
|
+
// Non-secret tuning: checked-in config.<stage>.json (or schema default).
|
|
30
|
+
stage: [
|
|
31
|
+
'ESCROW_ENCLAVE_ACTIVE_KEY_ID',
|
|
32
|
+
'ESCROW_HOLD_DURATION_MS',
|
|
33
|
+
'ESCROW_HOLD_RESTART_MIN_AGE_MS',
|
|
34
|
+
'ESCROW_ENCLAVE_REMOTE_TIMEOUT_MS',
|
|
35
|
+
],
|
|
36
|
+
};
|
|
37
|
+
const SECRETS = new Set([
|
|
38
|
+
'ESCROW_RELAY_AUTH_TOKEN',
|
|
39
|
+
'ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON',
|
|
40
|
+
'ESCROW_ENCLAVE_REMOTE_TOKEN',
|
|
41
|
+
]);
|
|
42
|
+
// serverless.yml PrivateLink inputs; read at deploy time, never by the Lambda runtime.
|
|
43
|
+
const DEPLOY_TIME = ['ESCROW_ENCLAVE_ENDPOINT_SERVICE_NAME', 'ESCROW_ENCLAVE_HOSTNAME'];
|
|
44
|
+
|
|
45
|
+
const settings = Object.keys(lcaApiEnvironmentShape).filter(name => name.startsWith('ESCROW_'));
|
|
46
|
+
|
|
47
|
+
afterEach(() => vi.unstubAllEnvs());
|
|
48
|
+
|
|
49
|
+
describe('escrow deployment wiring', () => {
|
|
50
|
+
it('classifies every schema ESCROW_* setting exactly once', () => {
|
|
51
|
+
const classified = Object.values(SOURCES).flat();
|
|
52
|
+
expect([...classified].sort()).toEqual([...settings].sort());
|
|
53
|
+
expect(new Set(classified).size).toBe(classified.length);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('passes only bundle fallbacks, toggles and deploy-time inputs through GitHub', () => {
|
|
57
|
+
const workflow = yaml.load(
|
|
58
|
+
readFileSync(
|
|
59
|
+
new URL('../../../../../.github/workflows/deploy.yml', import.meta.url),
|
|
60
|
+
'utf8'
|
|
61
|
+
)
|
|
62
|
+
) as {
|
|
63
|
+
jobs: Record<string, { steps: { name: string; env?: Record<string, string> }[] }>;
|
|
64
|
+
};
|
|
65
|
+
const env = workflow.jobs['deploy-lca-api']!.steps.find(
|
|
66
|
+
step => step.name === 'Deploy LCA API Service Lambda'
|
|
67
|
+
)!.env!;
|
|
68
|
+
|
|
69
|
+
for (const name of [...SOURCES.bundle, ...SOURCES.toggle, ...DEPLOY_TIME]) {
|
|
70
|
+
expect(env[name], name).toBe(
|
|
71
|
+
`\${{ ${SECRETS.has(name) ? 'secrets' : 'vars'}.${name} }}`
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
for (const name of SOURCES.stage) expect(env[name], name).toBeUndefined();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('runs the scheduled escrow jobs with the API environment (bundle + toggles)', () => {
|
|
78
|
+
const serverless = yaml.load(
|
|
79
|
+
readFileSync(new URL('../../serverless.yml', import.meta.url), 'utf8')
|
|
80
|
+
) as { functions: Record<string, { environment?: string; role?: string }> };
|
|
81
|
+
for (const name of ['escrowHoldReminders', 'escrowBlobRewrap']) {
|
|
82
|
+
expect(serverless.functions[name]?.environment, name).toBe(
|
|
83
|
+
'${file(./serverless.function-env.cjs):escrow}'
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
expect(functionEnvironment.escrow).toBe(functionEnvironment.api);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('keeps escrow toggles and drops credential fallbacks in bundle mode', () => {
|
|
90
|
+
for (const name of [...SOURCES.bundle, ...SOURCES.toggle]) vi.stubEnv(name, name);
|
|
91
|
+
vi.stubEnv('RUNTIME_SECRETS_ID', 'lca-api/dev/runtime-secrets');
|
|
92
|
+
expect(functionEnvironment.escrow()).toEqual({
|
|
93
|
+
RUNTIME_SECRETS_ID: 'lca-api/dev/runtime-secrets',
|
|
94
|
+
ESCROW_ENCLAVE_MODE: 'ESCROW_ENCLAVE_MODE',
|
|
95
|
+
ESCROW_RELEASE_KILL_SWITCH: 'ESCROW_RELEASE_KILL_SWITCH',
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
vi.stubEnv('RUNTIME_SECRETS_ID', '');
|
|
99
|
+
expect(Object.keys(functionEnvironment.escrow()).sort()).toEqual(
|
|
100
|
+
[...SOURCES.bundle, ...SOURCES.toggle].sort()
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('never checks in escrow credentials', () => {
|
|
105
|
+
for (const [stage, config] of Object.entries(stages)) {
|
|
106
|
+
for (const name of [...SOURCES.bundle, ...SOURCES.toggle]) {
|
|
107
|
+
expect(
|
|
108
|
+
(config as Record<string, string>)[name],
|
|
109
|
+
`${stage}/${name}`
|
|
110
|
+
).toBeUndefined();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import 'dotenv/config';
|
|
2
|
+
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import {
|
|
5
|
+
optionalEnvironmentBoolean,
|
|
6
|
+
optionalEnvironmentString,
|
|
7
|
+
parseEnvironment,
|
|
8
|
+
} from '@learncard/helpers';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Focused environment contract for the OIDC provider surface so the OIDC Lambda
|
|
12
|
+
* can boot without SEED/MONGO/Postmark/Firebase/bundle. Validates ONLY the
|
|
13
|
+
* fields read by src/oidc.ts and src/helpers/oidc.helpers.ts, reproducing the
|
|
14
|
+
* full schema's parsers and its KEYCLOAK_ISSUERS->KEYCLOAK_AUDIENCES superRefine
|
|
15
|
+
* verbatim so no constraint is weakened.
|
|
16
|
+
*/
|
|
17
|
+
export const oidcEnvironmentShape = {
|
|
18
|
+
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
|
19
|
+
LAMBDA_STAGE: optionalEnvironmentString,
|
|
20
|
+
IS_OFFLINE: optionalEnvironmentBoolean.default(false),
|
|
21
|
+
IS_E2E_TEST: optionalEnvironmentBoolean.default(false),
|
|
22
|
+
KEYCLOAK_ISSUERS: optionalEnvironmentString,
|
|
23
|
+
KEYCLOAK_AUDIENCES: optionalEnvironmentString,
|
|
24
|
+
OIDC_ISSUER: optionalEnvironmentString,
|
|
25
|
+
OIDC_SIGNING_KEY_JWK: optionalEnvironmentString,
|
|
26
|
+
OIDC_SIGNING_KEY_SECRET_ID: optionalEnvironmentString,
|
|
27
|
+
OIDC_CLIENT_ID: optionalEnvironmentString,
|
|
28
|
+
OIDC_CLIENT_SECRET: optionalEnvironmentString,
|
|
29
|
+
OIDC_REDIRECT_URIS: optionalEnvironmentString,
|
|
30
|
+
} satisfies z.ZodRawShape;
|
|
31
|
+
|
|
32
|
+
export const oidcEnvironmentSchema = z
|
|
33
|
+
.object(oidcEnvironmentShape)
|
|
34
|
+
.superRefine((environment, context) => {
|
|
35
|
+
if (
|
|
36
|
+
environment.KEYCLOAK_ISSUERS?.split(',').some(value => value.trim()) &&
|
|
37
|
+
!environment.KEYCLOAK_AUDIENCES?.split(',').some(value => value.trim())
|
|
38
|
+
) {
|
|
39
|
+
context.addIssue({
|
|
40
|
+
code: 'custom',
|
|
41
|
+
path: ['KEYCLOAK_AUDIENCES'],
|
|
42
|
+
message: 'Required when KEYCLOAK_ISSUERS is configured',
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export type OidcEnvironment = z.output<typeof oidcEnvironmentSchema>;
|
|
48
|
+
|
|
49
|
+
export const parseOidcEnvironment = (
|
|
50
|
+
raw: Record<string, unknown>,
|
|
51
|
+
source = 'process environment'
|
|
52
|
+
): OidcEnvironment =>
|
|
53
|
+
parseEnvironment(oidcEnvironmentSchema, raw, {
|
|
54
|
+
project: 'lca-api (oidc)',
|
|
55
|
+
source,
|
|
56
|
+
examplePath: 'services/learn-card-network/lca-api/.env.example',
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
export const environment = parseOidcEnvironment(process.env);
|