@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
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { expect } from '@playwright/test';
|
|
3
|
+
import {
|
|
4
|
+
test,
|
|
5
|
+
admin,
|
|
6
|
+
completeLogin,
|
|
7
|
+
issueTicket,
|
|
8
|
+
redeemCode,
|
|
9
|
+
redis,
|
|
10
|
+
rejectTicket,
|
|
11
|
+
subjects,
|
|
12
|
+
usersFor,
|
|
13
|
+
} from './fixtures';
|
|
14
|
+
|
|
15
|
+
test('new and returning users reach the same account without a web form', async ({
|
|
16
|
+
browser,
|
|
17
|
+
request,
|
|
18
|
+
account,
|
|
19
|
+
}) => {
|
|
20
|
+
expect(await usersFor(account.email)).toEqual([]);
|
|
21
|
+
const first = await completeLogin(
|
|
22
|
+
browser,
|
|
23
|
+
await issueTicket(request, account.email),
|
|
24
|
+
account.email
|
|
25
|
+
);
|
|
26
|
+
const subject = await subjects.findOne({ identityKey: account.identityKey });
|
|
27
|
+
const returning = await completeLogin(
|
|
28
|
+
browser,
|
|
29
|
+
await issueTicket(request, account.email),
|
|
30
|
+
account.email
|
|
31
|
+
);
|
|
32
|
+
expect(returning).toBe(first);
|
|
33
|
+
expect(await subjects.countDocuments({ identityKey: account.identityKey })).toBe(1);
|
|
34
|
+
expect((await subjects.findOne({ identityKey: account.identityKey }))?.subject).toBe(
|
|
35
|
+
subject?.subject
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('a migrated user keeps the pre-linked Keycloak account', async ({
|
|
40
|
+
browser,
|
|
41
|
+
request,
|
|
42
|
+
account,
|
|
43
|
+
}) => {
|
|
44
|
+
const subject = randomUUID();
|
|
45
|
+
await subjects.insertOne({
|
|
46
|
+
identityKey: account.identityKey,
|
|
47
|
+
subject,
|
|
48
|
+
email: account.email,
|
|
49
|
+
emailVerified: true,
|
|
50
|
+
createdAt: new Date(),
|
|
51
|
+
lastLoginAt: new Date(),
|
|
52
|
+
});
|
|
53
|
+
const response = await admin('users', {
|
|
54
|
+
method: 'POST',
|
|
55
|
+
body: JSON.stringify({
|
|
56
|
+
username: `migrated-${subject}`,
|
|
57
|
+
email: account.email,
|
|
58
|
+
emailVerified: true,
|
|
59
|
+
enabled: true,
|
|
60
|
+
federatedIdentities: [
|
|
61
|
+
{ identityProvider: 'lca-api', userId: subject, userName: subject },
|
|
62
|
+
],
|
|
63
|
+
}),
|
|
64
|
+
});
|
|
65
|
+
expect(response.status).toBe(201);
|
|
66
|
+
const id = response.headers.get('location')!.split('/').at(-1);
|
|
67
|
+
expect(
|
|
68
|
+
await completeLogin(browser, await issueTicket(request, account.email), account.email)
|
|
69
|
+
).toBe(id);
|
|
70
|
+
expect((await subjects.findOne({ identityKey: account.identityKey }))?.subject).toBe(subject);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('a consumed ticket cannot create another Keycloak session', async ({
|
|
74
|
+
browser,
|
|
75
|
+
request,
|
|
76
|
+
account,
|
|
77
|
+
}) => {
|
|
78
|
+
const ticket = await issueTicket(request, account.email);
|
|
79
|
+
const id = await completeLogin(browser, ticket, account.email);
|
|
80
|
+
const before = await (await admin(`users/${id}/sessions`)).json();
|
|
81
|
+
expect(before).toHaveLength(1);
|
|
82
|
+
await rejectTicket(browser, ticket);
|
|
83
|
+
const after = await (await admin(`users/${id}/sessions`)).json();
|
|
84
|
+
expect(after.map((session: { id: string }) => session.id)).toEqual(
|
|
85
|
+
before.map((session: { id: string }) => session.id)
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('an expired ticket cannot create a Keycloak user', async ({ browser, request, account }) => {
|
|
90
|
+
const ticket = await issueTicket(request, account.email);
|
|
91
|
+
// issueTicket checks the production 60-second TTL. Shorten this one key to
|
|
92
|
+
// exercise actual Redis expiration without adding a minute to each run.
|
|
93
|
+
expect(await redis.pexpire(`login-ticket:${ticket}`, 1)).toBe(1);
|
|
94
|
+
await expect.poll(() => redis.exists(`login-ticket:${ticket}`)).toBe(0);
|
|
95
|
+
await rejectTicket(browser, ticket);
|
|
96
|
+
expect(await usersFor(account.email)).toEqual([]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('a wrong email code preserves the correct code, which can be redeemed only once concurrently', async ({
|
|
100
|
+
browser,
|
|
101
|
+
request,
|
|
102
|
+
account,
|
|
103
|
+
}) => {
|
|
104
|
+
await redis.set(`login-code:${account.email}`, '123456', 'EX', 300);
|
|
105
|
+
expect(await redeemCode(request, account.email, '000000')).toMatchObject({ success: false });
|
|
106
|
+
expect(await redis.get(`login-code:${account.email}`)).toBe('123456');
|
|
107
|
+
const results = await Promise.all([
|
|
108
|
+
redeemCode(request, account.email, '123456'),
|
|
109
|
+
redeemCode(request, account.email, '123456'),
|
|
110
|
+
]);
|
|
111
|
+
expect(results.filter(result => result.success)).toHaveLength(1);
|
|
112
|
+
expect(results.filter(result => !result.success)).toHaveLength(1);
|
|
113
|
+
expect(await redis.exists(`login-code:${account.email}`)).toBe(0);
|
|
114
|
+
await completeLogin(browser, results.find(result => result.success)!.ticket!, account.email);
|
|
115
|
+
expect(await subjects.countDocuments({ identityKey: account.identityKey })).toBe(1);
|
|
116
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Exclusive, disposable stack owned by run.mjs. Never mounts developer data.
|
|
2
|
+
services:
|
|
3
|
+
mongo:
|
|
4
|
+
image: mongo:7.0
|
|
5
|
+
ports: ['127.0.0.1::27017']
|
|
6
|
+
tmpfs: ['/data/db', '/data/configdb']
|
|
7
|
+
redis:
|
|
8
|
+
image: redis:7.4-alpine
|
|
9
|
+
command: ['redis-server', '--save', '', '--appendonly', 'no']
|
|
10
|
+
ports: ['127.0.0.1::6379']
|
|
11
|
+
tmpfs: ['/data']
|
|
12
|
+
keycloak:
|
|
13
|
+
# The canonical realm includes Apple, so stock Keycloak cannot import it.
|
|
14
|
+
build:
|
|
15
|
+
context: ../../../../../infra/keycloak
|
|
16
|
+
dockerfile: Dockerfile.dev
|
|
17
|
+
command: start-dev --import-realm
|
|
18
|
+
environment:
|
|
19
|
+
KC_BOOTSTRAP_ADMIN_USERNAME: admin
|
|
20
|
+
KC_BOOTSTRAP_ADMIN_PASSWORD: ${OIDC_E2E_ADMIN_PASSWORD:-unused-during-cleanup}
|
|
21
|
+
KC_HOSTNAME: http://localhost:${OIDC_E2E_KEYCLOAK_PORT:-18081}
|
|
22
|
+
KC_HTTP_ENABLED: 'true'
|
|
23
|
+
GOOGLE_CLIENT_ID: ci-google-client-id.apps.googleusercontent.com
|
|
24
|
+
GOOGLE_CLIENT_SECRET: ci-google-client-secret
|
|
25
|
+
APPLE_SERVICE_ID: com.learncard.ci.signin
|
|
26
|
+
APPLE_TEAM_ID: ci-apple-team-id
|
|
27
|
+
APPLE_KEY_ID: ci-apple-key-id
|
|
28
|
+
APPLE_P8_CONTENT: ci-placeholder-not-a-real-key
|
|
29
|
+
ports: ['127.0.0.1:${OIDC_E2E_KEYCLOAK_PORT:-18081}:8080']
|
|
30
|
+
extra_hosts: ['host.docker.internal:host-gateway']
|
|
31
|
+
tmpfs: ['/opt/keycloak/data:uid=1000,gid=0']
|
|
32
|
+
volumes:
|
|
33
|
+
- ${OIDC_E2E_REALM_FILE:-/dev/null}:/opt/keycloak/data/import/learncard-oidc-e2e-realm.json:ro
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { createHash, randomBytes, randomInt, randomUUID } from 'node:crypto';
|
|
3
|
+
import { test as base, expect, type Browser, type APIRequestContext } from '@playwright/test';
|
|
4
|
+
import { MongoClient } from 'mongodb';
|
|
5
|
+
import Redis from 'ioredis';
|
|
6
|
+
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
|
7
|
+
import type { OidcE2eRuntime, LoginTicketResult, TestAccount } from './types';
|
|
8
|
+
|
|
9
|
+
if (!process.env.OIDC_E2E_RUNTIME) throw new Error('Run this suite via bun run test:oidc:e2e');
|
|
10
|
+
export const runtime: OidcE2eRuntime = JSON.parse(
|
|
11
|
+
readFileSync(process.env.OIDC_E2E_RUNTIME, 'utf8')
|
|
12
|
+
);
|
|
13
|
+
const mongo = new MongoClient(runtime.mongoUri);
|
|
14
|
+
export const subjects = mongo.db(runtime.mongoDbName).collection('authsubjects');
|
|
15
|
+
export const redis = new Redis({ host: '127.0.0.1', port: runtime.redisPort, lazyConnect: true });
|
|
16
|
+
const jwks = createRemoteJWKSet(new URL(`${runtime.issuer}/protocol/openid-connect/certs`));
|
|
17
|
+
|
|
18
|
+
/** Admin calls can only target the ephemeral realm created by the runner. */
|
|
19
|
+
export const admin = async (path: string, init?: RequestInit): Promise<Response> => {
|
|
20
|
+
const tokenResponse = await fetch(
|
|
21
|
+
`${runtime.keycloakUrl}/realms/master/protocol/openid-connect/token`,
|
|
22
|
+
{
|
|
23
|
+
method: 'POST',
|
|
24
|
+
body: new URLSearchParams({
|
|
25
|
+
grant_type: 'password',
|
|
26
|
+
client_id: 'admin-cli',
|
|
27
|
+
username: 'admin',
|
|
28
|
+
password: runtime.adminPassword,
|
|
29
|
+
}),
|
|
30
|
+
}
|
|
31
|
+
);
|
|
32
|
+
expect(tokenResponse.ok).toBe(true);
|
|
33
|
+
const { access_token } = await tokenResponse.json();
|
|
34
|
+
return fetch(`${runtime.keycloakUrl}/admin/realms/learncard-oidc-e2e/${path}`, {
|
|
35
|
+
...init,
|
|
36
|
+
headers: {
|
|
37
|
+
'Content-Type': 'application/json',
|
|
38
|
+
Authorization: `Bearer ${access_token}`,
|
|
39
|
+
...init?.headers,
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const usersFor = async (email: string): Promise<{ id: string }[]> => {
|
|
45
|
+
const response = await admin(`users?${new URLSearchParams({ email, exact: 'true' })}`);
|
|
46
|
+
expect(response.ok).toBe(true);
|
|
47
|
+
return response.json();
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const cleanupAccount = async ({ email, identityKey }: TestAccount): Promise<void> => {
|
|
51
|
+
const cleanup = await Promise.allSettled([
|
|
52
|
+
(async () => {
|
|
53
|
+
for (const user of await usersFor(email)) {
|
|
54
|
+
expect((await admin(`users/${user.id}`, { method: 'DELETE' })).status).toBe(204);
|
|
55
|
+
}
|
|
56
|
+
expect(await usersFor(email)).toEqual([]);
|
|
57
|
+
})(),
|
|
58
|
+
(async () => {
|
|
59
|
+
await subjects.deleteMany({ identityKey });
|
|
60
|
+
expect(await subjects.countDocuments({ identityKey })).toBe(0);
|
|
61
|
+
})(),
|
|
62
|
+
// The runner provisions a private Redis container and this config has
|
|
63
|
+
// one worker. Clear all transient tickets/codes/tokens/rate counters.
|
|
64
|
+
(async () => {
|
|
65
|
+
await redis.flushdb();
|
|
66
|
+
expect(await redis.dbsize()).toBe(0);
|
|
67
|
+
})(),
|
|
68
|
+
]);
|
|
69
|
+
const failures = cleanup.filter(result => result.status === 'rejected');
|
|
70
|
+
if (failures.length)
|
|
71
|
+
throw new AggregateError(
|
|
72
|
+
failures.map(result => result.reason),
|
|
73
|
+
'Per-test cleanup failed'
|
|
74
|
+
);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export const test = base.extend<{ account: TestAccount }>({
|
|
78
|
+
// Playwright requires a destructured fixture argument even with no dependencies.
|
|
79
|
+
// eslint-disable-next-line no-empty-pattern
|
|
80
|
+
account: async ({}, provideAccount) => {
|
|
81
|
+
const email = `oidc-e2e-${randomUUID()}@example.com`;
|
|
82
|
+
const identityKey = `email:${email}`;
|
|
83
|
+
try {
|
|
84
|
+
await provideAccount({ email, identityKey });
|
|
85
|
+
} finally {
|
|
86
|
+
// Exact test identity, never a broad user deletion. Deleting the user also
|
|
87
|
+
// removes sessions and federated links. The outer runner independently
|
|
88
|
+
// destroys all three disposable stores even if any of these actions fail.
|
|
89
|
+
await cleanupAccount({ email, identityKey });
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test.beforeAll(async () => {
|
|
95
|
+
await Promise.all([mongo.connect(), redis.connect()]);
|
|
96
|
+
});
|
|
97
|
+
test.afterAll(async () => {
|
|
98
|
+
await Promise.all([mongo.close(), redis.quit()]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
export const redeemCode = async (
|
|
102
|
+
request: APIRequestContext,
|
|
103
|
+
email: string,
|
|
104
|
+
code: string
|
|
105
|
+
): Promise<LoginTicketResult> => {
|
|
106
|
+
const response = await request.post(`${runtime.apiUrl}/api/auth/login-ticket`, {
|
|
107
|
+
data: { email, code },
|
|
108
|
+
});
|
|
109
|
+
expect(response.ok()).toBe(true);
|
|
110
|
+
return response.json();
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export const issueTicket = async (request: APIRequestContext, email: string): Promise<string> => {
|
|
114
|
+
// Seed only the email delivery boundary. All code validation, identity creation,
|
|
115
|
+
// ticket issuance and token exchange go through real HTTP routes and stores.
|
|
116
|
+
const code = String(randomInt(100_000, 1_000_000));
|
|
117
|
+
await redis.set(`login-code:${email}`, code, 'EX', 300);
|
|
118
|
+
const result = await redeemCode(request, email, code);
|
|
119
|
+
expect(result.success).toBe(true);
|
|
120
|
+
expect(result.ticket).toBeTruthy();
|
|
121
|
+
expect(await redis.exists(`login-code:${email}`)).toBe(0);
|
|
122
|
+
expect(await redis.ttl(`login-ticket:${result.ticket}`)).toBeGreaterThan(0);
|
|
123
|
+
expect(await redis.ttl(`login-ticket:${result.ticket}`)).toBeLessThanOrEqual(60);
|
|
124
|
+
return result.ticket!;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const authorization = (
|
|
128
|
+
ticket: string
|
|
129
|
+
): { url: string; verifier: string; nonce: string; state: string } => {
|
|
130
|
+
const verifier = randomBytes(32).toString('base64url');
|
|
131
|
+
const nonce = randomUUID();
|
|
132
|
+
const state = randomUUID();
|
|
133
|
+
const params = new URLSearchParams({
|
|
134
|
+
client_id: 'learncard-app',
|
|
135
|
+
redirect_uri: runtime.callbackUrl,
|
|
136
|
+
response_type: 'code',
|
|
137
|
+
scope: 'openid email profile',
|
|
138
|
+
state,
|
|
139
|
+
nonce,
|
|
140
|
+
code_challenge: createHash('sha256').update(verifier).digest('base64url'),
|
|
141
|
+
code_challenge_method: 'S256',
|
|
142
|
+
kc_idp_hint: 'lca-api',
|
|
143
|
+
login_hint: ticket,
|
|
144
|
+
});
|
|
145
|
+
return {
|
|
146
|
+
url: `${runtime.issuer}/protocol/openid-connect/auth?${params}`,
|
|
147
|
+
verifier,
|
|
148
|
+
nonce,
|
|
149
|
+
state,
|
|
150
|
+
};
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/** Fresh context on every attempt prevents an existing SSO cookie hiding a broken ticket flow. */
|
|
154
|
+
export const completeLogin = async (
|
|
155
|
+
browser: Browser,
|
|
156
|
+
ticket: string,
|
|
157
|
+
email: string
|
|
158
|
+
): Promise<string> => {
|
|
159
|
+
const context = await browser.newContext();
|
|
160
|
+
try {
|
|
161
|
+
const page = await context.newPage();
|
|
162
|
+
const auth = authorization(ticket);
|
|
163
|
+
await page.goto(auth.url);
|
|
164
|
+
// There are no form-filling/clicking actions: an unexpected login, profile,
|
|
165
|
+
// or account-linking form cannot satisfy this callback assertion.
|
|
166
|
+
await page.waitForURL(url => `${url.origin}${url.pathname}` === runtime.callbackUrl);
|
|
167
|
+
const callback = new URL(page.url());
|
|
168
|
+
expect(callback.searchParams.get('error')).toBeNull();
|
|
169
|
+
expect(callback.searchParams.get('state')).toBe(auth.state);
|
|
170
|
+
const code = callback.searchParams.get('code');
|
|
171
|
+
expect(code).toBeTruthy();
|
|
172
|
+
expect(await redis.exists(`login-ticket:${ticket}`)).toBe(0);
|
|
173
|
+
const response = await context.request.post(
|
|
174
|
+
`${runtime.issuer}/protocol/openid-connect/token`,
|
|
175
|
+
{
|
|
176
|
+
form: {
|
|
177
|
+
grant_type: 'authorization_code',
|
|
178
|
+
client_id: 'learncard-app',
|
|
179
|
+
code: code!,
|
|
180
|
+
redirect_uri: runtime.callbackUrl,
|
|
181
|
+
code_verifier: auth.verifier,
|
|
182
|
+
},
|
|
183
|
+
}
|
|
184
|
+
);
|
|
185
|
+
expect(response.ok()).toBe(true);
|
|
186
|
+
const tokens = await response.json();
|
|
187
|
+
const { payload } = await jwtVerify(tokens.id_token, jwks, {
|
|
188
|
+
issuer: runtime.issuer,
|
|
189
|
+
audience: 'learncard-app',
|
|
190
|
+
algorithms: ['RS256'],
|
|
191
|
+
requiredClaims: ['sub', 'exp', 'iat', 'nonce'],
|
|
192
|
+
});
|
|
193
|
+
expect(payload).toMatchObject({ email, email_verified: true, nonce: auth.nonce });
|
|
194
|
+
expect(payload.sub).toBeTruthy();
|
|
195
|
+
const userinfo = await context.request.get(
|
|
196
|
+
`${runtime.issuer}/protocol/openid-connect/userinfo`,
|
|
197
|
+
{
|
|
198
|
+
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
|
199
|
+
}
|
|
200
|
+
);
|
|
201
|
+
expect(userinfo.ok()).toBe(true);
|
|
202
|
+
expect(await userinfo.json()).toMatchObject({ sub: payload.sub, email });
|
|
203
|
+
const record = await subjects.findOne({ identityKey: `email:${email}` });
|
|
204
|
+
expect(record?.subject).toBeTruthy();
|
|
205
|
+
const links = await admin(`users/${payload.sub}/federated-identity`);
|
|
206
|
+
expect(links.ok).toBe(true);
|
|
207
|
+
expect(await links.json()).toEqual(
|
|
208
|
+
expect.arrayContaining([
|
|
209
|
+
expect.objectContaining({ identityProvider: 'lca-api', userId: record!.subject }),
|
|
210
|
+
])
|
|
211
|
+
);
|
|
212
|
+
expect(await usersFor(email)).toEqual([expect.objectContaining({ id: payload.sub })]);
|
|
213
|
+
return payload.sub!;
|
|
214
|
+
} finally {
|
|
215
|
+
await context.close();
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
export const rejectTicket = async (browser: Browser, ticket: string): Promise<void> => {
|
|
220
|
+
const context = await browser.newContext();
|
|
221
|
+
try {
|
|
222
|
+
const page = await context.newPage();
|
|
223
|
+
const upstream = page.waitForResponse(response => {
|
|
224
|
+
const url = new URL(response.url());
|
|
225
|
+
return url.origin === runtime.apiUrl && url.pathname === '/oidc/authorize';
|
|
226
|
+
});
|
|
227
|
+
const auth = authorization(ticket);
|
|
228
|
+
await page.goto(auth.url);
|
|
229
|
+
const response = await upstream;
|
|
230
|
+
expect(response.status()).toBe(302);
|
|
231
|
+
const location = new URL(response.headers().location!);
|
|
232
|
+
expect(`${location.origin}${location.pathname}`).toBe(
|
|
233
|
+
`${runtime.issuer}/broker/lca-api/endpoint`
|
|
234
|
+
);
|
|
235
|
+
expect(location.searchParams.get('error')).toBe('login_required');
|
|
236
|
+
expect(location.searchParams.get('code')).toBeNull();
|
|
237
|
+
await page.waitForURL(url => `${url.origin}${url.pathname}` === runtime.callbackUrl);
|
|
238
|
+
const callback = new URL(page.url());
|
|
239
|
+
expect(callback.searchParams.get('error')).toBeTruthy();
|
|
240
|
+
expect(callback.searchParams.get('code')).toBeNull();
|
|
241
|
+
expect(callback.searchParams.get('state')).toBe(auth.state);
|
|
242
|
+
} finally {
|
|
243
|
+
await context.close();
|
|
244
|
+
}
|
|
245
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { fork } from 'node:child_process';
|
|
3
|
+
import { once } from 'node:events';
|
|
4
|
+
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { test } from 'node:test';
|
|
8
|
+
|
|
9
|
+
for (const replaceLock of [false, true]) {
|
|
10
|
+
test(`concurrent stale-lock contenders preserve the ${replaceLock ? 'replacement' : 'stale'} lock`, { timeout: 15_000 }, async () => {
|
|
11
|
+
const temp = await mkdtemp(join(tmpdir(), 'oidc-lock-test-'));
|
|
12
|
+
const lock = join(temp, 'learncard-oidc-e2e.lock');
|
|
13
|
+
const preload = join(temp, 'barrier.mjs');
|
|
14
|
+
const children = [];
|
|
15
|
+
try {
|
|
16
|
+
// Hold both runners after reading the old PID, reproducing the reclamation race.
|
|
17
|
+
await writeFile(preload, `
|
|
18
|
+
import fs from 'node:fs/promises';
|
|
19
|
+
import { syncBuiltinESMExports } from 'node:module';
|
|
20
|
+
import { once } from 'node:events';
|
|
21
|
+
const readFile = fs.readFile;
|
|
22
|
+
fs.readFile = async (...args) => {
|
|
23
|
+
const result = await readFile(...args);
|
|
24
|
+
if (String(args[0]).endsWith('learncard-oidc-e2e.lock')) {
|
|
25
|
+
const release = once(process, 'message');
|
|
26
|
+
process.send('read');
|
|
27
|
+
await release;
|
|
28
|
+
process.disconnect();
|
|
29
|
+
}
|
|
30
|
+
return result;
|
|
31
|
+
};
|
|
32
|
+
syncBuiltinESMExports();
|
|
33
|
+
`);
|
|
34
|
+
const stalePid = '2147483647';
|
|
35
|
+
assert.throws(() => process.kill(Number(stalePid), 0), { code: 'ESRCH' });
|
|
36
|
+
await writeFile(lock, stalePid);
|
|
37
|
+
const runs = [0, 1].map(() => {
|
|
38
|
+
const child = fork(new URL('./run.mjs', import.meta.url), [], {
|
|
39
|
+
execArgv: ['--import', preload],
|
|
40
|
+
env: { ...process.env, TMPDIR: temp, TMP: temp, TEMP: temp },
|
|
41
|
+
silent: true,
|
|
42
|
+
});
|
|
43
|
+
children.push(child);
|
|
44
|
+
let output = '';
|
|
45
|
+
child.stderr.on('data', chunk => { output += chunk; });
|
|
46
|
+
return {
|
|
47
|
+
child,
|
|
48
|
+
ready: once(child, 'message'),
|
|
49
|
+
exited: once(child, 'exit'),
|
|
50
|
+
output: () => output,
|
|
51
|
+
};
|
|
52
|
+
});
|
|
53
|
+
await Promise.all(runs.map(run => run.ready));
|
|
54
|
+
const expected = replaceLock ? String(process.pid) : stalePid;
|
|
55
|
+
if (replaceLock) {
|
|
56
|
+
await rm(lock);
|
|
57
|
+
await writeFile(lock, expected);
|
|
58
|
+
}
|
|
59
|
+
for (const run of runs) run.child.send('release');
|
|
60
|
+
for (const run of runs) {
|
|
61
|
+
assert.deepEqual(await run.exited, [1, null]);
|
|
62
|
+
assert.match(run.output(), /Stale OIDC E2E lock at .*remove this lock manually/);
|
|
63
|
+
assert.doesNotMatch(run.output(), /cleanup complete|Starting disposable/);
|
|
64
|
+
}
|
|
65
|
+
assert.equal(await readFile(lock, 'utf8'), expected);
|
|
66
|
+
} finally {
|
|
67
|
+
for (const child of children) {
|
|
68
|
+
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
|
|
69
|
+
}
|
|
70
|
+
await rm(temp, { recursive: true, force: true });
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { defineConfig } from '@playwright/test';
|
|
2
|
+
|
|
3
|
+
if (!process.env.OIDC_E2E_RUNTIME) {
|
|
4
|
+
throw new Error('Use bun run test:oidc:e2e; this suite requires its disposable services.');
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export default defineConfig({
|
|
8
|
+
testDir: '.',
|
|
9
|
+
testMatch: 'broker.spec.ts',
|
|
10
|
+
workers: 1,
|
|
11
|
+
fullyParallel: false,
|
|
12
|
+
retries: 0,
|
|
13
|
+
forbidOnly: Boolean(process.env.CI),
|
|
14
|
+
timeout: 30_000,
|
|
15
|
+
globalTimeout: 180_000,
|
|
16
|
+
expect: { timeout: 10_000 },
|
|
17
|
+
reporter: 'list',
|
|
18
|
+
// All artifacts, including any browser state, disappear with the runner's temp directory.
|
|
19
|
+
outputDir: process.env.OIDC_E2E_OUTPUT,
|
|
20
|
+
use: { browserName: 'chromium', headless: true, trace: 'off' },
|
|
21
|
+
});
|