@learncard/lca-api-service 1.3.2 → 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 +99 -2
- package/AGENTS.md +155 -44
- package/CHANGELOG.md +84 -0
- package/bootstrapLambda.test.ts +76 -0
- package/compose.env +5 -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/accesslayer/signing-authority/index.d.ts +8 -2
- package/dist/app.d.ts +361 -8
- package/dist/cache/challenges.d.ts +2 -0
- 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/cache/recoverySessions.d.ts +42 -0
- package/dist/config/applyDockerStageConfig.d.ts +1 -0
- package/dist/config/cacheEnvironment.d.ts +16 -0
- package/dist/config/environment.d.ts +253 -2
- package/dist/config/environment.test.d.ts +1 -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/learnCard.signingAuthority.test.d.ts +1 -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/rateLimit.helpers.d.ts +4 -0
- package/dist/helpers/seedEncryption.helpers.d.ts +23 -0
- package/dist/helpers/seedEncryption.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/migrations/signingAuthoritySeeds.d.ts +13 -0
- package/dist/migrations/signingAuthoritySeedsCli.test.d.ts +1 -0
- package/dist/models/AuthSubject.d.ts +20 -0
- package/dist/models/EscrowHold.d.ts +153 -0
- package/dist/models/Notification.d.ts +5 -0
- package/dist/models/SigningAuthority.d.ts +7 -1
- package/dist/models/UserKey.d.ts +326 -3
- package/dist/models/UserKey.escrowPin.test.d.ts +1 -0
- package/dist/models/index.d.ts +2 -0
- package/dist/oidc.d.ts +5 -0
- package/dist/routes/auth.d.ts +31 -0
- package/dist/routes/escrow.d.ts +164 -0
- package/dist/routes/index.d.ts +6 -0
- package/dist/routes/keys.d.ts +182 -7
- package/dist/routes/notifications.d.ts +5 -0
- package/dist/routes/qr-login.d.ts +1 -1
- 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 +25 -0
- package/dist/services/escrow-enclave/notifications.d.ts +24 -0
- 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 +27 -0
- package/dist/services/escrow-enclave/softwareEnclave.test.d.ts +1 -0
- package/dist/services/escrow-enclave/types.d.ts +111 -0
- package/dist/types/notifications.d.ts +5 -0
- package/dist/types/seed-encryption.d.ts +31 -0
- package/dist/types/seed-migration.d.ts +49 -0
- package/esbuildPlugins.cjs +1 -0
- package/lambda.ts +27 -112
- package/lambdaApp.ts +146 -0
- package/oidcLambda.test.ts +38 -0
- package/oidcLambda.ts +18 -0
- package/oidcLambdaApp.ts +11 -0
- package/package.json +30 -14
- package/scripts/README.md +88 -0
- package/scripts/check-seed-encryption-infrastructure.mjs +139 -0
- package/scripts/check-signing-authority-redis.mjs +93 -0
- 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/migrate-signing-authority-seeds-local.sh +51 -0
- package/scripts/migrate-signing-authority-seeds.mjs +105 -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 +40 -0
- package/seedMigrationLambda.ts +19 -0
- package/serverless-local.yml +28 -1
- package/serverless.function-env.cjs +58 -0
- package/serverless.yml +294 -26
- package/src/accesslayer/notifications/index.ts +9 -2
- package/src/accesslayer/signing-authority/create.ts +11 -7
- package/src/accesslayer/signing-authority/index.ts +4 -1
- package/src/app.ts +4 -0
- package/src/cache/challenges.ts +12 -2
- package/src/cache/getDel.test.ts +34 -0
- package/src/cache/getDel.ts +32 -0
- package/src/cache/in-memory-lru.ts +1 -0
- package/src/cache/index.ts +1 -1
- package/src/cache/login-tickets.ts +26 -0
- package/src/cache/recoverySessions.ts +252 -0
- package/src/config/applyDockerStageConfig.ts +6 -0
- package/src/config/cacheEnvironment.ts +34 -0
- package/src/config/environment.test.ts +159 -0
- package/src/config/environment.ts +164 -0
- 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 +13 -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/learnCard.helpers.test.ts +11 -1
- package/src/helpers/learnCard.helpers.ts +57 -45
- package/src/helpers/learnCard.signingAuthority.test.ts +194 -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/rateLimit.helpers.ts +112 -0
- package/src/helpers/seedEncryption.helpers.test.ts +427 -0
- package/src/helpers/seedEncryption.helpers.ts +372 -0
- package/src/helpers/signingAuthority.helpers.ts +4 -1
- 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/migrations/signingAuthoritySeeds.ts +430 -0
- package/src/migrations/signingAuthoritySeedsCli.test.ts +70 -0
- package/src/models/AuthSubject.ts +147 -0
- package/src/models/EscrowHold.ts +541 -0
- package/src/models/SigningAuthority.ts +14 -3
- package/src/models/UserKey.escrowPin.test.ts +49 -0
- package/src/models/UserKey.ts +1109 -40
- package/src/models/index.ts +2 -0
- package/src/models/pruneOrphanedRecoveryMethods.ts +3 -1
- package/src/oidc.ts +371 -0
- package/src/routes/auth.ts +173 -0
- package/src/routes/credentials.ts +8 -0
- package/src/routes/escrow.ts +1073 -0
- package/src/routes/firebase.ts +90 -48
- package/src/routes/index.ts +98 -5
- package/src/routes/keys.ts +1022 -174
- package/src/routes/qr-login.ts +51 -53
- package/src/routes/signing-authority.ts +39 -1
- package/src/services/delivery/adapters/postmark.adapter.ts +48 -1
- package/src/services/escrow-enclave/README.md +122 -0
- 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 +174 -0
- package/src/services/escrow-enclave/notifications.test.ts +377 -0
- package/src/services/escrow-enclave/notifications.ts +348 -0
- 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 +450 -0
- package/src/services/escrow-enclave/softwareEnclave.ts +254 -0
- package/src/services/escrow-enclave/types.ts +122 -0
- package/src/types/seed-encryption.ts +51 -0
- package/src/types/seed-migration.ts +61 -0
- package/test/auth-tickets.spec.ts +480 -0
- package/test/credentials.spec.ts +2 -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 +2328 -0
- package/test/keys-identity-rebind.spec.ts +456 -0
- package/test/keys-lifecycle-invariants.spec.ts +785 -0
- package/test/keys-recovery-confirmation.spec.ts +586 -0
- package/test/keys-recovery-rate-limit.spec.ts +123 -0
- package/test/keys-secret-leakage.spec.ts +265 -0
- package/test/models/authSubjectIndexes.spec.ts +132 -0
- package/test/models/escrow.spec.ts +572 -0
- 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-http.spec.ts +161 -0
- package/test/signing-authority-seed-migration.spec.ts +414 -0
- package/test/signing-authority.spec.ts +90 -0
- package/tsconfig.build.json +1 -1
- package/tsconfig.json +1 -0
- package/vitest.config.ts +24 -1
- package/vitest.integration.config.ts +25 -3
- package/dist/cache/signing-authority.d.ts +0 -6
- package/src/cache/signing-authority.ts +0 -37
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { KMSClient, GenerateDataKeyCommand } from '@aws-sdk/client-kms';
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
const mocks = vi.hoisted(() => ({
|
|
5
|
+
read: vi.fn(),
|
|
6
|
+
init: vi.fn(),
|
|
7
|
+
nativeInit: vi.fn(async () => ({})),
|
|
8
|
+
keys: [] as string[],
|
|
9
|
+
}));
|
|
10
|
+
vi.mock('@accesslayer/signing-authority/read', () => ({ getSigningAuthorityForDid: mocks.read }));
|
|
11
|
+
vi.mock('@learncard/init', () => ({ initLearnCard: mocks.init }));
|
|
12
|
+
vi.mock('@learncard/didkit-plugin-node', () => ({ getDidKitPlugin: mocks.nativeInit }));
|
|
13
|
+
vi.mock('@cache/in-memory-lru', async importOriginal => {
|
|
14
|
+
const actual = await importOriginal<typeof import('@cache/in-memory-lru')>();
|
|
15
|
+
return {
|
|
16
|
+
getLRUCache: <T>() => {
|
|
17
|
+
const cache = actual.getLRUCache<T>();
|
|
18
|
+
return {
|
|
19
|
+
...cache,
|
|
20
|
+
add: (key: string, value: T) => {
|
|
21
|
+
mocks.keys.push(key);
|
|
22
|
+
return cache.add(key, value);
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
});
|
|
28
|
+
vi.mock('@environment', async importOriginal => {
|
|
29
|
+
const actual = await importOriginal<typeof import('@environment')>();
|
|
30
|
+
return {
|
|
31
|
+
environment: {
|
|
32
|
+
...actual.environment,
|
|
33
|
+
SKIP_DIDKIT_NAPI: false,
|
|
34
|
+
SA_SEED_KMS_KEY_ARN:
|
|
35
|
+
'arn:aws:kms:us-east-1:123456789012:key/11111111-1111-1111-1111-111111111111',
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
import { environment } from '@environment';
|
|
41
|
+
import { seedEncryption } from './seedEncryption.helpers';
|
|
42
|
+
import { getEphemeralLearnCard, getSigningAuthorityLearnCard } from './learnCard.helpers';
|
|
43
|
+
|
|
44
|
+
const spyOnKms = () => vi.spyOn(KMSClient.prototype, 'send');
|
|
45
|
+
let send: ReturnType<typeof spyOnKms>;
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
vi.restoreAllMocks();
|
|
48
|
+
mocks.read.mockReset();
|
|
49
|
+
mocks.init.mockReset().mockImplementation(async options => ({ options }));
|
|
50
|
+
mocks.keys.length = 0;
|
|
51
|
+
send = vi.spyOn(KMSClient.prototype, 'send').mockImplementation(async command => ({
|
|
52
|
+
Plaintext: Buffer.alloc(32, 7),
|
|
53
|
+
KeyId: environment.SA_SEED_KMS_KEY_ARN,
|
|
54
|
+
...(command instanceof GenerateDataKeyCommand
|
|
55
|
+
? { CiphertextBlob: Buffer.from('wrapped') }
|
|
56
|
+
: {}),
|
|
57
|
+
$metadata: {},
|
|
58
|
+
}));
|
|
59
|
+
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const authority = async (id: string, ownerDid = 'did:example:owner', seed = 'a'.repeat(64)) => {
|
|
63
|
+
const identity = { _id: id, ownerDid, name: 'main', did: 'did:key:example' };
|
|
64
|
+
return { ...identity, ...(await seedEncryption.encrypt(seed, identity)) };
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
describe('SA signing cache', () => {
|
|
68
|
+
it('waits for engine setup before decrypting the seed', async () => {
|
|
69
|
+
const sa = await authority('engine-setup', 'did:example:engine-setup');
|
|
70
|
+
mocks.read.mockResolvedValue(sa);
|
|
71
|
+
let finishEngineSetup!: () => void;
|
|
72
|
+
mocks.nativeInit.mockImplementationOnce(
|
|
73
|
+
() =>
|
|
74
|
+
new Promise(resolve => {
|
|
75
|
+
finishEngineSetup = () => resolve({});
|
|
76
|
+
})
|
|
77
|
+
);
|
|
78
|
+
send.mockClear();
|
|
79
|
+
|
|
80
|
+
const initialization = getSigningAuthorityLearnCard(sa.ownerDid, sa.name);
|
|
81
|
+
await vi.waitFor(() => expect(finishEngineSetup).toBeTypeOf('function'));
|
|
82
|
+
expect(send).not.toHaveBeenCalled();
|
|
83
|
+
expect(mocks.init).not.toHaveBeenCalled();
|
|
84
|
+
|
|
85
|
+
finishEngineSetup();
|
|
86
|
+
await initialization;
|
|
87
|
+
expect(send).toHaveBeenCalledTimes(1);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it.each(['did:example:seed-lifetime', 'did:web:seed-lifetime.example'])(
|
|
91
|
+
'releases initialization options after success for %s',
|
|
92
|
+
async ownerDid => {
|
|
93
|
+
const seed = 'c'.repeat(64);
|
|
94
|
+
const sa = await authority(ownerDid, ownerDid, seed);
|
|
95
|
+
mocks.read.mockResolvedValue(sa);
|
|
96
|
+
let finishInitialization!: () => void;
|
|
97
|
+
let initializationOptions: { seed: string; didWeb?: string } | undefined;
|
|
98
|
+
const card = {};
|
|
99
|
+
mocks.init.mockImplementationOnce(options => {
|
|
100
|
+
initializationOptions = options;
|
|
101
|
+
return new Promise(resolve => {
|
|
102
|
+
finishInitialization = () => resolve(card);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const initialization = getSigningAuthorityLearnCard(sa.ownerDid, sa.name);
|
|
107
|
+
await vi.waitFor(() => expect(initializationOptions?.seed).toBe(seed));
|
|
108
|
+
expect(initializationOptions?.didWeb).toBe(
|
|
109
|
+
ownerDid.startsWith('did:web:') ? ownerDid : undefined
|
|
110
|
+
);
|
|
111
|
+
expect(mocks.keys).toEqual([]);
|
|
112
|
+
|
|
113
|
+
finishInitialization();
|
|
114
|
+
expect(await initialization).toBe(card);
|
|
115
|
+
expect(initializationOptions?.seed).toBe('');
|
|
116
|
+
expect(await getSigningAuthorityLearnCard(sa.ownerDid, sa.name)).toBe(card);
|
|
117
|
+
expect(mocks.init).toHaveBeenCalledTimes(1);
|
|
118
|
+
}
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
it('releases initialization options after failure and does not cache a failed signer', async () => {
|
|
122
|
+
const sa = await authority('init-failure', 'did:example:init-failure');
|
|
123
|
+
mocks.read.mockResolvedValue(sa);
|
|
124
|
+
let initializationOptions: { seed: string } | undefined;
|
|
125
|
+
const failure = new Error('Signer initialization failed');
|
|
126
|
+
mocks.init.mockImplementationOnce(async options => {
|
|
127
|
+
initializationOptions = options;
|
|
128
|
+
expect(options.seed).toBe('a'.repeat(64));
|
|
129
|
+
throw failure;
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
await expect(getSigningAuthorityLearnCard(sa.ownerDid, sa.name)).rejects.toBe(failure);
|
|
133
|
+
expect(initializationOptions?.seed).toBe('');
|
|
134
|
+
expect(mocks.keys).toEqual([]);
|
|
135
|
+
|
|
136
|
+
await getSigningAuthorityLearnCard(sa.ownerDid, sa.name);
|
|
137
|
+
expect(mocks.init).toHaveBeenCalledTimes(2);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('makes one KMS call when cold and zero when warm, retaining no seed in cache keys', async () => {
|
|
141
|
+
const sa = await authority('warm');
|
|
142
|
+
mocks.read.mockResolvedValue(sa);
|
|
143
|
+
send.mockClear();
|
|
144
|
+
const first = await getSigningAuthorityLearnCard(sa.ownerDid, sa.name);
|
|
145
|
+
expect(send).toHaveBeenCalledTimes(1);
|
|
146
|
+
expect(await getSigningAuthorityLearnCard(sa.ownerDid, sa.name)).toBe(first);
|
|
147
|
+
expect(send).toHaveBeenCalledTimes(1);
|
|
148
|
+
expect(mocks.read).toHaveBeenCalledTimes(2);
|
|
149
|
+
expect(mocks.keys).toEqual([`${sa.ownerDid}|${sa.name}`]);
|
|
150
|
+
await getEphemeralLearnCard('b'.repeat(64));
|
|
151
|
+
expect(mocks.keys).toEqual([`${sa.ownerDid}|${sa.name}`]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('does not reuse a deleted/recreated authority under the same owner and name', async () => {
|
|
155
|
+
const firstRecord = await authority('old');
|
|
156
|
+
const replacement = await authority('replacement', firstRecord.ownerDid, 'b'.repeat(64));
|
|
157
|
+
mocks.read
|
|
158
|
+
.mockResolvedValueOnce(firstRecord)
|
|
159
|
+
.mockResolvedValueOnce(null)
|
|
160
|
+
.mockResolvedValueOnce(replacement);
|
|
161
|
+
send.mockClear();
|
|
162
|
+
const first = await getSigningAuthorityLearnCard(firstRecord.ownerDid, firstRecord.name);
|
|
163
|
+
await expect(
|
|
164
|
+
getSigningAuthorityLearnCard(firstRecord.ownerDid, firstRecord.name)
|
|
165
|
+
).rejects.toThrow();
|
|
166
|
+
const second = await getSigningAuthorityLearnCard(replacement.ownerDid, replacement.name);
|
|
167
|
+
expect(first).not.toBe(second);
|
|
168
|
+
expect(send).toHaveBeenCalledTimes(2);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('does not serve a warm wallet after its envelope is tampered with', async () => {
|
|
172
|
+
const sa = await authority('tamper');
|
|
173
|
+
mocks.read.mockResolvedValue(sa);
|
|
174
|
+
await getSigningAuthorityLearnCard(sa.ownerDid, sa.name);
|
|
175
|
+
const bytes = Buffer.from(sa.encryptedSeed, 'base64');
|
|
176
|
+
bytes[15] = bytes[15]! ^ 1;
|
|
177
|
+
mocks.read.mockResolvedValue({ ...sa, encryptedSeed: bytes.toString('base64') });
|
|
178
|
+
await expect(getSigningAuthorityLearnCard(sa.ownerDid, sa.name)).rejects.toMatchObject({
|
|
179
|
+
category: 'authentication_failed',
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('keeps did:web initialization behavior and does not cache failed decrypts', async () => {
|
|
184
|
+
const sa = await authority('did-web', 'did:web:issuer.example');
|
|
185
|
+
mocks.read.mockResolvedValue(sa);
|
|
186
|
+
send.mockRejectedValueOnce({ name: 'AccessDeniedException' });
|
|
187
|
+
await expect(getSigningAuthorityLearnCard(sa.ownerDid, sa.name)).rejects.toThrow();
|
|
188
|
+
expect(mocks.keys).toEqual([]);
|
|
189
|
+
await getSigningAuthorityLearnCard(sa.ownerDid, sa.name);
|
|
190
|
+
expect(mocks.init).toHaveBeenLastCalledWith(
|
|
191
|
+
expect.objectContaining({ didWeb: sa.ownerDid })
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import cache from '@cache';
|
|
3
|
+
import { issueLoginTicket } from '@cache/login-tickets';
|
|
4
|
+
import { storeAccessToken, storeAuthorizationCode } from './login-ticket.helpers';
|
|
5
|
+
|
|
6
|
+
vi.mock('@environment', () => ({ environment: {} }));
|
|
7
|
+
|
|
8
|
+
afterEach(() => vi.restoreAllMocks());
|
|
9
|
+
|
|
10
|
+
describe('authentication artifact persistence', () => {
|
|
11
|
+
const writers = [
|
|
12
|
+
{
|
|
13
|
+
name: 'login ticket',
|
|
14
|
+
write: () =>
|
|
15
|
+
issueLoginTicket({ subject: 'subject', identityKey: 'email:test@example.com' }),
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
name: 'authorization code',
|
|
19
|
+
write: () =>
|
|
20
|
+
storeAuthorizationCode({
|
|
21
|
+
subject: 'subject',
|
|
22
|
+
code: 'code',
|
|
23
|
+
scope: 'openid',
|
|
24
|
+
clientId: 'broker',
|
|
25
|
+
redirectUri: 'https://broker.test/callback',
|
|
26
|
+
createdAt: Date.now(),
|
|
27
|
+
}),
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: 'access token',
|
|
31
|
+
write: () =>
|
|
32
|
+
storeAccessToken('access-token', { subject: 'subject', createdAt: Date.now() }),
|
|
33
|
+
},
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
it.each(writers)('rejects a swallowed $name write failure', async ({ name, write }) => {
|
|
37
|
+
vi.spyOn(cache, 'set').mockResolvedValueOnce(undefined);
|
|
38
|
+
await expect(write()).rejects.toThrow(`Failed to persist ${name}`);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it.each(writers)('propagates a rejected $name write', async ({ write }) => {
|
|
42
|
+
vi.spyOn(cache, 'set').mockRejectedValueOnce(new Error('Redis unavailable'));
|
|
43
|
+
await expect(write()).rejects.toThrow('Redis unavailable');
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Login tickets, authorization codes, and opaque access tokens for the lca-api
|
|
3
|
+
* OIDC provider (AD-2).
|
|
4
|
+
*
|
|
5
|
+
* Three short-lived, one-time-use artifacts back the "silent hop" login:
|
|
6
|
+
* - A **login ticket** is minted after lca-api proves ownership of a contact
|
|
7
|
+
* method / federated identity (email code, native Google/Apple). It carries
|
|
8
|
+
* the verified claims and the stable subject id.
|
|
9
|
+
* - An **authorization code** is minted by `/oidc/authorize` when it redeems a
|
|
10
|
+
* login ticket (passed by Keycloak as `login_hint`). Keycloak exchanges the
|
|
11
|
+
* code at `/oidc/token` for the signed `id_token`. The code is bound to the
|
|
12
|
+
* client + redirect URI it was issued for.
|
|
13
|
+
* - An **opaque access token** is minted alongside the id_token so Keycloak's
|
|
14
|
+
* broker can call `/oidc/userinfo`. It maps to the same verified claims.
|
|
15
|
+
*
|
|
16
|
+
* All live in the cache (Redis in prod, in-memory mock in tests) with short
|
|
17
|
+
* TTLs. Tickets and codes are consumed (read-then-delete) so neither can be
|
|
18
|
+
* replayed; the access token is read-only for its short life.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import crypto from 'node:crypto';
|
|
22
|
+
|
|
23
|
+
import cache from '@cache';
|
|
24
|
+
import { getDel } from '@cache/getDel';
|
|
25
|
+
|
|
26
|
+
export const AUTH_CODE_PREFIX = 'oidc-code:';
|
|
27
|
+
export const ACCESS_TOKEN_PREFIX = 'oidc:access:';
|
|
28
|
+
|
|
29
|
+
export const AUTH_CODE_TTL_SECONDS = 60;
|
|
30
|
+
export const ACCESS_TOKEN_TTL_SECONDS = 300;
|
|
31
|
+
|
|
32
|
+
export interface SubjectClaims {
|
|
33
|
+
subject: string;
|
|
34
|
+
email?: string;
|
|
35
|
+
emailVerified?: boolean;
|
|
36
|
+
phoneNumber?: string;
|
|
37
|
+
phoneNumberVerified?: boolean;
|
|
38
|
+
name?: string;
|
|
39
|
+
picture?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface AuthorizationCodeData extends SubjectClaims {
|
|
43
|
+
scope: string;
|
|
44
|
+
code: string;
|
|
45
|
+
clientId: string;
|
|
46
|
+
redirectUri: string;
|
|
47
|
+
nonce?: string;
|
|
48
|
+
state?: string;
|
|
49
|
+
createdAt: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface AccessTokenData extends SubjectClaims {
|
|
53
|
+
createdAt: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const codeKey = (code: string): string => `${AUTH_CODE_PREFIX}${code}`;
|
|
57
|
+
const accessKey = (accessToken: string): string => `${ACCESS_TOKEN_PREFIX}${accessToken}`;
|
|
58
|
+
|
|
59
|
+
export const generateAuthorizationCode = (): string => crypto.randomBytes(32).toString('base64url');
|
|
60
|
+
export const generateAccessToken = (): string => crypto.randomBytes(32).toString('base64url');
|
|
61
|
+
|
|
62
|
+
export const storeAuthorizationCode = async (
|
|
63
|
+
data: AuthorizationCodeData,
|
|
64
|
+
ttl = AUTH_CODE_TTL_SECONDS
|
|
65
|
+
): Promise<void> => {
|
|
66
|
+
const result = await cache.set(codeKey(data.code), JSON.stringify(data), ttl);
|
|
67
|
+
if (result !== 'OK') throw new Error('Failed to persist authorization code');
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export const consumeAuthorizationCode = async (
|
|
71
|
+
code: string
|
|
72
|
+
): Promise<AuthorizationCodeData | null> => {
|
|
73
|
+
const raw = await getDel(codeKey(code));
|
|
74
|
+
if (!raw) return null;
|
|
75
|
+
return JSON.parse(raw) as AuthorizationCodeData;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export const storeAccessToken = async (
|
|
79
|
+
accessToken: string,
|
|
80
|
+
data: AccessTokenData,
|
|
81
|
+
ttl = ACCESS_TOKEN_TTL_SECONDS
|
|
82
|
+
): Promise<void> => {
|
|
83
|
+
const result = await cache.set(accessKey(accessToken), JSON.stringify(data), ttl);
|
|
84
|
+
if (result !== 'OK') throw new Error('Failed to persist access token');
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export const getAccessToken = async (accessToken: string): Promise<AccessTokenData | null> => {
|
|
88
|
+
const raw = await cache.get(accessKey(accessToken));
|
|
89
|
+
if (!raw) return null;
|
|
90
|
+
return JSON.parse(raw) as AccessTokenData;
|
|
91
|
+
};
|
|
@@ -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
|
+
};
|