@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,228 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import type { Browser, Page, chromium as Chromium } from '@playwright/test';
|
|
3
|
+
import { decodeJwt } from 'jose';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
6
|
+
import { createKeycloakAdmin } from '../scripts/keycloak-admin';
|
|
7
|
+
import {
|
|
8
|
+
callback,
|
|
9
|
+
createBrokerAuthorization,
|
|
10
|
+
exchangeBrokerCode,
|
|
11
|
+
keycloakIssuer,
|
|
12
|
+
roundtripEnabled,
|
|
13
|
+
signInThroughBroker,
|
|
14
|
+
trpc,
|
|
15
|
+
} from './helpers/keycloak-broker';
|
|
16
|
+
|
|
17
|
+
const idpSchema = z.object({
|
|
18
|
+
trustEmail: z.boolean(),
|
|
19
|
+
firstBrokerLoginFlowAlias: z.string(),
|
|
20
|
+
config: z.record(z.string(), z.string()),
|
|
21
|
+
});
|
|
22
|
+
const isCallback = (url: URL): boolean => `${url.origin}${url.pathname}` === callback;
|
|
23
|
+
const callbacks = new WeakMap<Page, URL>();
|
|
24
|
+
|
|
25
|
+
// Same browser-form driver as infra/keycloak/qa/signin.ts, shared PKCE with the ticket tests.
|
|
26
|
+
// No traces/screenshots: Playwright errors can include authorization codes and action links.
|
|
27
|
+
// The original error is intentionally not attached as `cause`: it would carry the URLs.
|
|
28
|
+
const redactBrowserError = (error: unknown): Error => {
|
|
29
|
+
const reason =
|
|
30
|
+
error instanceof Error
|
|
31
|
+
? error.message.split('\n')[0]?.replace(/https?:\/\/\S+/g, '[redacted URL]')
|
|
32
|
+
: 'unknown';
|
|
33
|
+
return new Error(`Synthetic browser action failed: ${reason}`);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const browserAction = <T>(action: () => Promise<T>): Promise<T> =>
|
|
37
|
+
action().catch((error: unknown): never => {
|
|
38
|
+
throw redactBrowserError(error);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe.runIf(roundtripEnabled)('web social broker ownership policy', () => {
|
|
42
|
+
let admin: Awaited<ReturnType<typeof createKeycloakAdmin>>;
|
|
43
|
+
let browser: Browser;
|
|
44
|
+
beforeAll(async () => {
|
|
45
|
+
if (keycloakIssuer !== 'http://localhost:8081/realms/learncard')
|
|
46
|
+
throw new Error('Fake social tests are loopback-only');
|
|
47
|
+
admin = await createKeycloakAdmin();
|
|
48
|
+
const require = createRequire(
|
|
49
|
+
new URL('../../../../tests/smoketests/package.json', import.meta.url)
|
|
50
|
+
);
|
|
51
|
+
const { chromium } = require('@playwright/test') as { chromium: typeof Chromium };
|
|
52
|
+
browser = await chromium.launch();
|
|
53
|
+
});
|
|
54
|
+
afterAll(async () => {
|
|
55
|
+
await browser?.close();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const signIn = async (
|
|
59
|
+
page: Page,
|
|
60
|
+
username: string
|
|
61
|
+
): Promise<ReturnType<typeof createBrokerAuthorization>> => {
|
|
62
|
+
const authorization = createBrokerAuthorization('fake-google');
|
|
63
|
+
page.setDefaultTimeout(15_000);
|
|
64
|
+
page.on('request', request => {
|
|
65
|
+
const url = new URL(request.url());
|
|
66
|
+
if (isCallback(url)) callbacks.set(page, url);
|
|
67
|
+
});
|
|
68
|
+
await browserAction(async () => {
|
|
69
|
+
await page.route(isCallback, route =>
|
|
70
|
+
route.fulfill({ status: 200, contentType: 'text/plain', body: 'Test callback' })
|
|
71
|
+
);
|
|
72
|
+
await page.goto(authorization.url.href);
|
|
73
|
+
await page.locator('input[name="username"]').fill(username);
|
|
74
|
+
await page.locator('input[name="password"]').fill('test-only-password');
|
|
75
|
+
await page.locator('#kc-login').click();
|
|
76
|
+
await page.waitForURL(url => !url.pathname.startsWith('/realms/fake-google/'));
|
|
77
|
+
});
|
|
78
|
+
return authorization;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const tokenFromPage = async (
|
|
82
|
+
page: Page,
|
|
83
|
+
auth: ReturnType<typeof createBrokerAuthorization>
|
|
84
|
+
): Promise<string> => {
|
|
85
|
+
await expect.poll(() => callbacks.has(page), { timeout: 15_000 }).toBe(true);
|
|
86
|
+
// Chromium may refuse the unused app port after a server-side redirect.
|
|
87
|
+
// Capture the request rather than relying on the destination being served.
|
|
88
|
+
const url = callbacks.get(page);
|
|
89
|
+
if (!url) throw new Error('No authorization callback request');
|
|
90
|
+
if (url.searchParams.get('state') !== auth.state || url.searchParams.has('error'))
|
|
91
|
+
throw new Error('Invalid social callback state/result');
|
|
92
|
+
const code = url.searchParams.get('code');
|
|
93
|
+
if (!code) throw new Error('Social callback has no code');
|
|
94
|
+
return exchangeBrokerCode(code, auth.verifier);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
it('uses the same verified-claim and automatic linking policy for Google, Apple and the fake', async () => {
|
|
98
|
+
const providers = await Promise.all(
|
|
99
|
+
['google', 'apple', 'fake-google'].map(async alias =>
|
|
100
|
+
idpSchema.parse(
|
|
101
|
+
await (await admin.request(`/identity-provider/instances/${alias}`)).json()
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
);
|
|
105
|
+
for (const provider of providers) {
|
|
106
|
+
expect(provider.trustEmail).toBe(true);
|
|
107
|
+
expect(provider.firstBrokerLoginFlowAlias).toBe(
|
|
108
|
+
providers[0]!.firstBrokerLoginFlowAlias
|
|
109
|
+
);
|
|
110
|
+
expect(provider.config).toMatchObject({
|
|
111
|
+
filteredByClaim: 'true',
|
|
112
|
+
claimFilterName: 'email_verified',
|
|
113
|
+
claimFilterValue: '^true$',
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
expect(providers[1]!.config.tokenExchangeAccountLinkingEnabled).toBe('false');
|
|
117
|
+
const executions = z
|
|
118
|
+
.array(z.object({ providerId: z.string().optional(), requirement: z.string() }))
|
|
119
|
+
.parse(
|
|
120
|
+
await (
|
|
121
|
+
await admin.request(
|
|
122
|
+
`/authentication/flows/${encodeURIComponent(providers[0]!.firstBrokerLoginFlowAlias)}/executions`
|
|
123
|
+
)
|
|
124
|
+
).json()
|
|
125
|
+
);
|
|
126
|
+
expect(executions).toContainEqual(
|
|
127
|
+
expect.objectContaining({ providerId: 'idp-auto-link', requirement: 'ALTERNATIVE' })
|
|
128
|
+
);
|
|
129
|
+
expect(
|
|
130
|
+
executions.some(
|
|
131
|
+
e =>
|
|
132
|
+
['idp-confirm-link', 'idp-detect-existing-broker-user'].includes(
|
|
133
|
+
e.providerId ?? ''
|
|
134
|
+
) && e.requirement !== 'DISABLED'
|
|
135
|
+
)
|
|
136
|
+
).toBe(false);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('creates a verified new account via browser + PKCE that the live lca-api accepts', async () => {
|
|
140
|
+
const email = 'alice@example.com';
|
|
141
|
+
expect(await admin.findUsers(email)).toHaveLength(0);
|
|
142
|
+
const context = await browser.newContext();
|
|
143
|
+
try {
|
|
144
|
+
const page = await context.newPage();
|
|
145
|
+
const auth = await signIn(page, 'new-social');
|
|
146
|
+
const idToken = await tokenFromPage(page, auth);
|
|
147
|
+
expect(decodeJwt(idToken).email_verified).toBe(true);
|
|
148
|
+
const { verifyKeycloakToken } = await import('../src/helpers/auth.helpers');
|
|
149
|
+
expect(await verifyKeycloakToken(idToken)).toMatchObject({
|
|
150
|
+
email,
|
|
151
|
+
providerType: 'keycloak',
|
|
152
|
+
});
|
|
153
|
+
expect(
|
|
154
|
+
await trpc('keys.getAuthShare', { authToken: idToken, providerType: 'keycloak' })
|
|
155
|
+
).toBeNull();
|
|
156
|
+
const users = await admin.findUsers(email);
|
|
157
|
+
expect(users).toHaveLength(1);
|
|
158
|
+
expect(users[0]!.emailVerified).toBe(true);
|
|
159
|
+
expect(await admin.links(users[0]!.id)).toContainEqual(
|
|
160
|
+
expect.objectContaining({ identityProvider: 'fake-google' })
|
|
161
|
+
);
|
|
162
|
+
// A fresh ticket hop has no browser SSO cookies and must link the existing
|
|
163
|
+
// web-social user rather than rendering an account confirmation page.
|
|
164
|
+
expect(await admin.links(users[0]!.id)).not.toContainEqual(
|
|
165
|
+
expect.objectContaining({ identityProvider: 'lca-api' })
|
|
166
|
+
);
|
|
167
|
+
const emailLogin = await signInThroughBroker(email);
|
|
168
|
+
expect(decodeJwt(emailLogin.idToken).sub).toBe(users[0]!.id);
|
|
169
|
+
expect(await admin.findUsers(email)).toHaveLength(1);
|
|
170
|
+
expect(await admin.links(users[0]!.id)).toEqual(
|
|
171
|
+
expect.arrayContaining([
|
|
172
|
+
expect.objectContaining({ identityProvider: 'fake-google' }),
|
|
173
|
+
expect.objectContaining({ identityProvider: 'lca-api' }),
|
|
174
|
+
])
|
|
175
|
+
);
|
|
176
|
+
} finally {
|
|
177
|
+
await context.close();
|
|
178
|
+
for (const user of await admin.findUsers(email))
|
|
179
|
+
await admin.request(`/users/${user.id}`, { method: 'DELETE' });
|
|
180
|
+
}
|
|
181
|
+
}, 60_000);
|
|
182
|
+
|
|
183
|
+
it('automatically links an existing passwordless account without an extra page', async () => {
|
|
184
|
+
const email = 'existing-social@example.com';
|
|
185
|
+
expect(await admin.findUsers(email)).toHaveLength(0);
|
|
186
|
+
await admin.request('/users', {
|
|
187
|
+
method: 'POST',
|
|
188
|
+
body: JSON.stringify({
|
|
189
|
+
username: 'r2-existing-passwordless',
|
|
190
|
+
email,
|
|
191
|
+
emailVerified: true,
|
|
192
|
+
enabled: true,
|
|
193
|
+
}),
|
|
194
|
+
});
|
|
195
|
+
const [existing] = await admin.findUsers(email);
|
|
196
|
+
if (!existing) throw new Error('Missing collision fixture');
|
|
197
|
+
const context = await browser.newContext();
|
|
198
|
+
try {
|
|
199
|
+
const page = await context.newPage();
|
|
200
|
+
const auth = await signIn(page, 'existing-social');
|
|
201
|
+
const idToken = await tokenFromPage(page, auth);
|
|
202
|
+
expect(decodeJwt(idToken).sub).toBe(existing.id);
|
|
203
|
+
expect(decodeJwt(idToken).email_verified).toBe(true);
|
|
204
|
+
expect(await admin.links(existing.id)).toContainEqual(
|
|
205
|
+
expect.objectContaining({ identityProvider: 'fake-google' })
|
|
206
|
+
);
|
|
207
|
+
expect(await admin.findUsers(email)).toHaveLength(1);
|
|
208
|
+
} finally {
|
|
209
|
+
await context.close();
|
|
210
|
+
await admin.request(`/users/${existing.id}`, { method: 'DELETE' });
|
|
211
|
+
}
|
|
212
|
+
}, 60_000);
|
|
213
|
+
|
|
214
|
+
it('rejects an explicitly unverified upstream email before creating or linking a user', async () => {
|
|
215
|
+
const context = await browser.newContext();
|
|
216
|
+
try {
|
|
217
|
+
const page = await context.newPage();
|
|
218
|
+
await signIn(page, 'unverified-social');
|
|
219
|
+
expect(isCallback(new URL(page.url()))).toBe(false);
|
|
220
|
+
expect(await page.locator('#kc-error-message').innerText()).toContain(
|
|
221
|
+
'identity provider'
|
|
222
|
+
);
|
|
223
|
+
expect(await admin.findUsers('unverified-social@example.com')).toHaveLength(0);
|
|
224
|
+
} finally {
|
|
225
|
+
await context.close();
|
|
226
|
+
}
|
|
227
|
+
}, 60_000);
|
|
228
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// No Firebase credentials or network calls are needed to verify Keycloak tokens.
|
|
4
|
+
vi.mock('firebase-admin', () => ({ default: { auth: vi.fn() } }));
|
|
5
|
+
vi.mock('@environment', () => ({
|
|
6
|
+
environment: {
|
|
7
|
+
IS_OFFLINE: false,
|
|
8
|
+
IS_E2E_TEST: false,
|
|
9
|
+
get KEYCLOAK_ISSUERS(): string | undefined {
|
|
10
|
+
return process.env.KEYCLOAK_ISSUERS;
|
|
11
|
+
},
|
|
12
|
+
get KEYCLOAK_AUDIENCES(): string | undefined {
|
|
13
|
+
return process.env.KEYCLOAK_AUDIENCES;
|
|
14
|
+
},
|
|
15
|
+
get KEYCLOAK_JWKS_URL_OVERRIDES(): string | undefined {
|
|
16
|
+
return process.env.KEYCLOAK_JWKS_URL_OVERRIDES;
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
describe.skipIf(process.env.KEYCLOAK_INTEGRATION !== 'true')('real Keycloak tokens', () => {
|
|
22
|
+
let verifyKeycloakToken: typeof import('../src/helpers/auth.helpers').verifyKeycloakToken;
|
|
23
|
+
let verifyAuthToken: typeof import('../src/helpers/auth.helpers').verifyAuthToken;
|
|
24
|
+
|
|
25
|
+
beforeAll(async () => {
|
|
26
|
+
// Avoid initializing service configuration at all when this suite is skipped.
|
|
27
|
+
({ verifyKeycloakToken, verifyAuthToken } = await import('../src/helpers/auth.helpers'));
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => vi.unstubAllEnvs());
|
|
31
|
+
|
|
32
|
+
const getToken = async (
|
|
33
|
+
username: string
|
|
34
|
+
): Promise<{ access_token: string; id_token: string }> => {
|
|
35
|
+
const issuer = process.env.KEYCLOAK_ISSUERS?.split(',')[0];
|
|
36
|
+
if (!issuer) throw new Error('KEYCLOAK_ISSUERS must be set for integration tests');
|
|
37
|
+
const response = await fetch(`${issuer}/protocol/openid-connect/token`, {
|
|
38
|
+
method: 'POST',
|
|
39
|
+
body: new URLSearchParams({
|
|
40
|
+
grant_type: 'password',
|
|
41
|
+
client_id: 'ci-tests',
|
|
42
|
+
client_secret: 'ci-tests-dev-only-secret',
|
|
43
|
+
username,
|
|
44
|
+
password: 'password',
|
|
45
|
+
scope: 'openid',
|
|
46
|
+
}),
|
|
47
|
+
});
|
|
48
|
+
if (!response.ok) {
|
|
49
|
+
throw new Error(`Token request failed (${response.status}): ${await response.text()}`);
|
|
50
|
+
}
|
|
51
|
+
return response.json();
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
it('accepts a verified email ID token through provider dispatch', async () => {
|
|
55
|
+
const { id_token } = await getToken('dev-email');
|
|
56
|
+
const user = await verifyAuthToken(id_token, 'keycloak');
|
|
57
|
+
expect(user.id).toBeTruthy();
|
|
58
|
+
expect(user.email).toBe('dev-email@example.com');
|
|
59
|
+
expect(user.providerType).toBe('keycloak');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('preserves phone profile attributes and accepts a phone-only ID token', async () => {
|
|
63
|
+
const { id_token } = await getToken('dev-phone');
|
|
64
|
+
const payload = JSON.parse(Buffer.from(id_token.split('.')[1]!, 'base64url').toString());
|
|
65
|
+
expect(payload.phone_number).toBe('+15555550100');
|
|
66
|
+
expect(payload.phone_number_verified).toBe(true);
|
|
67
|
+
const user = await verifyKeycloakToken(id_token);
|
|
68
|
+
expect(user.phone).toBe('+15555550100');
|
|
69
|
+
expect(user.email).toBeUndefined();
|
|
70
|
+
expect(user.providerType).toBe('keycloak');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('accepts a phone-only access token using the authorized party', async () => {
|
|
74
|
+
const { access_token } = await getToken('dev-phone');
|
|
75
|
+
const user = await verifyKeycloakToken(access_token);
|
|
76
|
+
expect(user.phone).toBe('+15555550100');
|
|
77
|
+
expect(user.email).toBeUndefined();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('rejects an unverified contact', async () => {
|
|
81
|
+
const { id_token } = await getToken('dev-unverified');
|
|
82
|
+
await expect(verifyKeycloakToken(id_token)).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('rejects a tampered signature', async () => {
|
|
86
|
+
const { id_token } = await getToken('dev-email');
|
|
87
|
+
const parts = id_token.split('.');
|
|
88
|
+
const signature = parts[2]!;
|
|
89
|
+
parts[2] = `${signature[0] === 'A' ? 'B' : 'A'}${signature.slice(1)}`;
|
|
90
|
+
await expect(verifyKeycloakToken(parts.join('.'))).rejects.toMatchObject({
|
|
91
|
+
code: 'UNAUTHORIZED',
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('rejects a client outside the configured audience allowlist', async () => {
|
|
96
|
+
const { id_token } = await getToken('dev-email');
|
|
97
|
+
vi.stubEnv('KEYCLOAK_AUDIENCES', 'some-other-client');
|
|
98
|
+
await expect(verifyKeycloakToken(id_token)).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { createLocalJWKSet, exportJWK, generateKeyPair, SignJWT } from 'jose';
|
|
3
|
+
import type { JWTPayload } from 'jose';
|
|
4
|
+
import admin from 'firebase-admin';
|
|
5
|
+
import {
|
|
6
|
+
verifyFirebaseToken,
|
|
7
|
+
verifyAuthToken,
|
|
8
|
+
verifyKeycloakToken,
|
|
9
|
+
} from '../src/helpers/auth.helpers';
|
|
10
|
+
import {
|
|
11
|
+
getKeycloakIssuerJwksUrl,
|
|
12
|
+
getKeycloakJwks,
|
|
13
|
+
getKeycloakVerifyOptionsFromEnv,
|
|
14
|
+
setKeycloakJwksResolverForTests,
|
|
15
|
+
} from '../src/helpers/keycloak.helpers';
|
|
16
|
+
|
|
17
|
+
vi.mock('@environment', () => ({
|
|
18
|
+
environment: {
|
|
19
|
+
IS_OFFLINE: true,
|
|
20
|
+
IS_E2E_TEST: true,
|
|
21
|
+
get KEYCLOAK_ISSUERS(): string | undefined {
|
|
22
|
+
return process.env.KEYCLOAK_ISSUERS;
|
|
23
|
+
},
|
|
24
|
+
get KEYCLOAK_AUDIENCES(): string | undefined {
|
|
25
|
+
return process.env.KEYCLOAK_AUDIENCES;
|
|
26
|
+
},
|
|
27
|
+
get KEYCLOAK_JWKS_URL_OVERRIDES(): string | undefined {
|
|
28
|
+
return process.env.KEYCLOAK_JWKS_URL_OVERRIDES;
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
}));
|
|
32
|
+
vi.mock('firebase-admin', () => ({ default: { auth: vi.fn() } }));
|
|
33
|
+
|
|
34
|
+
const issuer = 'https://identity.example/realms/learncard';
|
|
35
|
+
const client = 'learncard-app';
|
|
36
|
+
const kid = 'test-key';
|
|
37
|
+
let keys: Awaited<ReturnType<typeof generateKeyPair>>;
|
|
38
|
+
let otherKeys: Awaited<ReturnType<typeof generateKeyPair>>;
|
|
39
|
+
let localJwks: ReturnType<typeof createLocalJWKSet>;
|
|
40
|
+
const resolver = vi.fn(() => localJwks);
|
|
41
|
+
|
|
42
|
+
const signToken = async (
|
|
43
|
+
overrides: JWTPayload = {},
|
|
44
|
+
signingKeys = keys,
|
|
45
|
+
algorithm = 'RS256'
|
|
46
|
+
): Promise<string> => {
|
|
47
|
+
const now = Math.floor(Date.now() / 1000);
|
|
48
|
+
return new SignJWT({
|
|
49
|
+
sub: 'user-123',
|
|
50
|
+
iss: issuer,
|
|
51
|
+
aud: client,
|
|
52
|
+
typ: 'ID',
|
|
53
|
+
email: 'learner@example.com',
|
|
54
|
+
email_verified: true,
|
|
55
|
+
exp: now + 300,
|
|
56
|
+
...overrides,
|
|
57
|
+
})
|
|
58
|
+
.setProtectedHeader({ alg: algorithm, kid, typ: 'JWT' })
|
|
59
|
+
.sign(signingKeys.privateKey);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
beforeAll(async () => {
|
|
63
|
+
keys = await generateKeyPair('RS256');
|
|
64
|
+
otherKeys = await generateKeyPair('RS256');
|
|
65
|
+
localJwks = createLocalJWKSet({ keys: [{ ...(await exportJWK(keys.publicKey)), kid }] });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
beforeEach(() => {
|
|
69
|
+
vi.stubEnv('KEYCLOAK_ISSUERS', issuer);
|
|
70
|
+
vi.stubEnv('KEYCLOAK_AUDIENCES', client);
|
|
71
|
+
vi.stubEnv('KEYCLOAK_JWKS_URL_OVERRIDES', '');
|
|
72
|
+
resolver.mockClear();
|
|
73
|
+
setKeycloakJwksResolverForTests(resolver);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
afterEach(() => {
|
|
77
|
+
setKeycloakJwksResolverForTests(undefined);
|
|
78
|
+
vi.unstubAllEnvs();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('Keycloak token verification', () => {
|
|
82
|
+
it('accepts ID tokens with a verified email', async () => {
|
|
83
|
+
await expect(verifyKeycloakToken(await signToken())).resolves.toEqual({
|
|
84
|
+
id: 'user-123',
|
|
85
|
+
email: 'learner@example.com',
|
|
86
|
+
phone: undefined,
|
|
87
|
+
providerType: 'keycloak',
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('accepts access tokens via azp even when aud is account', async () => {
|
|
92
|
+
await expect(
|
|
93
|
+
verifyKeycloakToken(await signToken({ typ: 'Bearer', aud: 'account', azp: client }))
|
|
94
|
+
).resolves.toMatchObject({ providerType: 'keycloak' });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('accepts a verified token without the optional payload typ', async () => {
|
|
98
|
+
await expect(verifyKeycloakToken(await signToken({ typ: undefined }))).resolves.toEqual({
|
|
99
|
+
id: 'user-123',
|
|
100
|
+
email: 'learner@example.com',
|
|
101
|
+
phone: undefined,
|
|
102
|
+
providerType: 'keycloak',
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it.each([
|
|
107
|
+
['wrong issuer', { iss: 'https://wrong.example/realms/learncard' }],
|
|
108
|
+
['same-key issuer outside allowlist', { iss: `${issuer}/` }],
|
|
109
|
+
])('rejects %s before resolving keys', async (_label, claims) => {
|
|
110
|
+
await expect(verifyKeycloakToken(await signToken(claims))).rejects.toMatchObject({
|
|
111
|
+
code: 'UNAUTHORIZED',
|
|
112
|
+
});
|
|
113
|
+
expect(resolver).not.toHaveBeenCalled();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it.each([
|
|
117
|
+
['wrong audience and azp', { aud: 'account', azp: 'another-client' }],
|
|
118
|
+
['expired token', { exp: 1 }],
|
|
119
|
+
['future nbf', { nbf: Math.floor(Date.now() / 1000) + 300 }],
|
|
120
|
+
['refresh token', { typ: 'Refresh' }],
|
|
121
|
+
['offline token', { typ: 'Offline' }],
|
|
122
|
+
['unsupported payload typ', { typ: 'bearer' }],
|
|
123
|
+
['null payload typ', { typ: null }],
|
|
124
|
+
['missing subject', { sub: undefined }],
|
|
125
|
+
['missing audience and azp', { aud: undefined, azp: undefined }],
|
|
126
|
+
])('rejects %s', async (_label, claims) => {
|
|
127
|
+
await expect(verifyKeycloakToken(await signToken(claims))).rejects.toMatchObject({
|
|
128
|
+
code: 'UNAUTHORIZED',
|
|
129
|
+
message: 'Invalid Keycloak token',
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('rejects a bad signature', async () => {
|
|
134
|
+
await expect(verifyKeycloakToken(await signToken({}, otherKeys))).rejects.toMatchObject({
|
|
135
|
+
code: 'UNAUTHORIZED',
|
|
136
|
+
message: 'Invalid Keycloak token',
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it.each([
|
|
141
|
+
['unverified email', { email_verified: false }, 'Email address is not verified'],
|
|
142
|
+
[
|
|
143
|
+
'missing email verification',
|
|
144
|
+
{ email_verified: undefined },
|
|
145
|
+
'Email address is not verified',
|
|
146
|
+
],
|
|
147
|
+
[
|
|
148
|
+
'unverified phone with verified email',
|
|
149
|
+
{ phone_number: '+15555550123', phone_number_verified: false },
|
|
150
|
+
'Phone number is not verified',
|
|
151
|
+
],
|
|
152
|
+
[
|
|
153
|
+
'missing phone verification',
|
|
154
|
+
{ phone_number: '+15555550123' },
|
|
155
|
+
'Phone number is not verified',
|
|
156
|
+
],
|
|
157
|
+
['no contact claims', { email: undefined }, 'Token has no verified contact method'],
|
|
158
|
+
])('rejects %s', async (_label, claims, message) => {
|
|
159
|
+
await expect(verifyKeycloakToken(await signToken(claims))).rejects.toMatchObject({
|
|
160
|
+
code: 'UNAUTHORIZED',
|
|
161
|
+
message,
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('accepts a verified phone-only user', async () => {
|
|
166
|
+
const token = await signToken({
|
|
167
|
+
email: undefined,
|
|
168
|
+
phone_number: '+15555550123',
|
|
169
|
+
phone_number_verified: true,
|
|
170
|
+
});
|
|
171
|
+
await expect(verifyKeycloakToken(token)).resolves.toEqual({
|
|
172
|
+
id: 'user-123',
|
|
173
|
+
email: undefined,
|
|
174
|
+
phone: '+15555550123',
|
|
175
|
+
providerType: 'keycloak',
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('rejects unconfigured verification', async () => {
|
|
180
|
+
vi.stubEnv('KEYCLOAK_ISSUERS', undefined);
|
|
181
|
+
await expect(verifyKeycloakToken(await signToken())).rejects.toMatchObject({
|
|
182
|
+
code: 'UNAUTHORIZED',
|
|
183
|
+
message: 'Keycloak verification is not configured',
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('dispatches verifyAuthToken to Keycloak', async () => {
|
|
188
|
+
await expect(verifyAuthToken(await signToken(), 'keycloak')).resolves.toMatchObject({
|
|
189
|
+
id: 'user-123',
|
|
190
|
+
providerType: 'keycloak',
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('accepts audience arrays', async () => {
|
|
195
|
+
await expect(
|
|
196
|
+
verifyKeycloakToken(await signToken({ aud: ['account', client] }))
|
|
197
|
+
).resolves.toMatchObject({ id: 'user-123' });
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('rejects malformed tokens without leaking parser errors', async () => {
|
|
201
|
+
await expect(verifyKeycloakToken('not-a-token')).rejects.toMatchObject({
|
|
202
|
+
code: 'UNAUTHORIZED',
|
|
203
|
+
message: 'Invalid Keycloak token',
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('accepts ES256 signatures', async () => {
|
|
208
|
+
const ecKeys = await generateKeyPair('ES256');
|
|
209
|
+
const ecJwks = createLocalJWKSet({
|
|
210
|
+
keys: [{ ...(await exportJWK(ecKeys.publicKey)), kid }],
|
|
211
|
+
});
|
|
212
|
+
setKeycloakJwksResolverForTests(() => ecJwks);
|
|
213
|
+
await expect(
|
|
214
|
+
verifyKeycloakToken(await signToken({}, ecKeys, 'ES256'))
|
|
215
|
+
).resolves.toMatchObject({ id: 'user-123' });
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('rejects algorithms outside the allowlist', async () => {
|
|
219
|
+
const rs512Keys = await generateKeyPair('RS512');
|
|
220
|
+
await expect(
|
|
221
|
+
verifyKeycloakToken(await signToken({}, rs512Keys, 'RS512'))
|
|
222
|
+
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe('Keycloak configuration', () => {
|
|
227
|
+
it('memoizes remote JWKS by exact issuer without fetching', () => {
|
|
228
|
+
setKeycloakJwksResolverForTests(undefined);
|
|
229
|
+
expect(getKeycloakJwks(issuer)).toBe(getKeycloakJwks(issuer));
|
|
230
|
+
expect(getKeycloakJwks(`${issuer}/other`)).not.toBe(getKeycloakJwks(issuer));
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('trims allowlists and drops empty entries', () => {
|
|
234
|
+
vi.stubEnv('KEYCLOAK_ISSUERS', ` , ${issuer}, , https://second.example, `);
|
|
235
|
+
vi.stubEnv('KEYCLOAK_AUDIENCES', ' , learncard-app, , another-client, ');
|
|
236
|
+
expect(getKeycloakVerifyOptionsFromEnv()).toEqual({
|
|
237
|
+
issuers: [issuer, 'https://second.example'],
|
|
238
|
+
audiences: [client, 'another-client'],
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('returns null for empty issuers', () => {
|
|
243
|
+
vi.stubEnv('KEYCLOAK_ISSUERS', ' , , ');
|
|
244
|
+
expect(getKeycloakVerifyOptionsFromEnv()).toBeNull();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('reports missing audiences as a configuration error', async () => {
|
|
248
|
+
vi.stubEnv('KEYCLOAK_AUDIENCES', ' , ');
|
|
249
|
+
await expect(verifyKeycloakToken(await signToken())).rejects.toMatchObject({
|
|
250
|
+
code: 'INTERNAL_SERVER_ERROR',
|
|
251
|
+
message: 'KEYCLOAK_AUDIENCES is required when KEYCLOAK_ISSUERS is configured',
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('resolves exact-issuer overrides and preserves equals in URLs', () => {
|
|
256
|
+
const url = 'http://keycloak:8080/certs?value=a=b';
|
|
257
|
+
vi.stubEnv(
|
|
258
|
+
'KEYCLOAK_JWKS_URL_OVERRIDES',
|
|
259
|
+
`ignored, ${issuer} = ${url}, https://other.example= `
|
|
260
|
+
);
|
|
261
|
+
expect(getKeycloakIssuerJwksUrl(issuer)).toBe(url);
|
|
262
|
+
expect(getKeycloakIssuerJwksUrl(`${issuer}/`)).toBe(
|
|
263
|
+
`${issuer}//protocol/openid-connect/certs`
|
|
264
|
+
);
|
|
265
|
+
expect(getKeycloakIssuerJwksUrl('https://other.example')).toBe(
|
|
266
|
+
'https://other.example/protocol/openid-connect/certs'
|
|
267
|
+
);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
describe('offline Firebase verification', () => {
|
|
272
|
+
it.each(['invalid', 'header.%%.signature', 'header.eyJlbWFpbCI6MTIzfQ.signature'])(
|
|
273
|
+
'rejects malformed claims without contacting Firebase: %s',
|
|
274
|
+
async token => {
|
|
275
|
+
vi.mocked(admin.auth).mockClear();
|
|
276
|
+
await expect(verifyFirebaseToken(token)).rejects.toMatchObject({
|
|
277
|
+
code: 'UNAUTHORIZED',
|
|
278
|
+
message: 'Could not decode Firebase token in offline/E2E mode',
|
|
279
|
+
});
|
|
280
|
+
expect(admin.auth).not.toHaveBeenCalled();
|
|
281
|
+
}
|
|
282
|
+
);
|
|
283
|
+
it('accepts valid offline claims without contacting Firebase', async () => {
|
|
284
|
+
vi.mocked(admin.auth).mockClear();
|
|
285
|
+
const payload = Buffer.from(JSON.stringify({ uid: 'offline-id' })).toString('base64url');
|
|
286
|
+
await expect(verifyFirebaseToken(`header.${payload}.signature`)).resolves.toMatchObject({
|
|
287
|
+
id: 'offline-id',
|
|
288
|
+
providerType: 'firebase',
|
|
289
|
+
});
|
|
290
|
+
expect(admin.auth).not.toHaveBeenCalled();
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
describe('JWKS endpoint cache', () => {
|
|
295
|
+
it('replaces the resolver when the resolved URL changes and reuses unchanged URLs', () => {
|
|
296
|
+
setKeycloakJwksResolverForTests(undefined);
|
|
297
|
+
const original = getKeycloakJwks(issuer);
|
|
298
|
+
vi.stubEnv('KEYCLOAK_JWKS_URL_OVERRIDES', `${issuer}=http://internal:8080/certs`);
|
|
299
|
+
const updated = getKeycloakJwks(issuer);
|
|
300
|
+
expect(updated).not.toBe(original);
|
|
301
|
+
expect(getKeycloakJwks(issuer)).toBe(updated);
|
|
302
|
+
vi.stubEnv('KEYCLOAK_JWKS_URL_OVERRIDES', `${issuer}=http://other:8080/certs`);
|
|
303
|
+
expect(getKeycloakJwks(issuer)).not.toBe(updated);
|
|
304
|
+
vi.stubEnv('KEYCLOAK_JWKS_URL_OVERRIDES', '');
|
|
305
|
+
expect(getKeycloakJwks(issuer)).not.toBe(updated);
|
|
306
|
+
});
|
|
307
|
+
});
|