seamless-cli 0.2.0 → 0.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/README.md +5 -6
- package/dist/commands/help.js +18 -1
- package/dist/commands/help.js.map +1 -1
- package/dist/commands/init.js +104 -21
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/verify.js +259 -0
- package/dist/commands/verify.js.map +1 -0
- package/dist/core/bootstrapSecret.js +12 -4
- package/dist/core/bootstrapSecret.js.map +1 -1
- package/dist/core/exec.js +2 -1
- package/dist/core/exec.js.map +1 -1
- package/dist/core/fetch.js +2 -1
- package/dist/core/fetch.js.map +1 -1
- package/dist/core/images.js +11 -0
- package/dist/core/images.js.map +1 -0
- package/dist/core/oauthProviders.js +89 -0
- package/dist/core/oauthProviders.js.map +1 -0
- package/dist/core/templates.js +185 -0
- package/dist/core/templates.js.map +1 -0
- package/dist/generators/auth/auth.js +40 -19
- package/dist/generators/auth/auth.js.map +1 -1
- package/dist/generators/config/config.js +3 -4
- package/dist/generators/config/config.js.map +1 -1
- package/dist/generators/docker/docker.js +51 -31
- package/dist/generators/docker/docker.js.map +1 -1
- package/dist/index.js +11 -2
- package/dist/index.js.map +1 -1
- package/dist/prompts/oauthSetup.js +36 -0
- package/dist/prompts/oauthSetup.js.map +1 -0
- package/dist/prompts/projectSetup.js +43 -19
- package/dist/prompts/projectSetup.js.map +1 -1
- package/package.json +6 -1
- package/verify/adapter-app/Dockerfile +14 -0
- package/verify/adapter-app/package.json +13 -0
- package/verify/adapter-app/server.mjs +62 -0
- package/verify/adapter-app/vendor/.gitignore +3 -0
- package/verify/adapter-app/vendor/.gitkeep +0 -0
- package/verify/docker-compose.verify.yml +154 -0
- package/verify/harness/adapter/emailOtpLogin.spec.ts +12 -0
- package/verify/harness/adapter/magicLink.spec.ts +14 -0
- package/verify/harness/adapter/oauth.spec.ts +11 -0
- package/verify/harness/adapter/registration.spec.ts +11 -0
- package/verify/harness/adapter/sessionLifecycle.spec.ts +20 -0
- package/verify/harness/api/adminBootstrap.spec.ts +34 -0
- package/verify/harness/api/emailOtp.spec.ts +17 -0
- package/verify/harness/api/jwks.spec.ts +17 -0
- package/verify/harness/api/magicLink.spec.ts +36 -0
- package/verify/harness/api/oauth.spec.ts +12 -0
- package/verify/harness/api/organizations.spec.ts +28 -0
- package/verify/harness/api/phoneOtp.spec.ts +19 -0
- package/verify/harness/api/sessionLifecycle.spec.ts +26 -0
- package/verify/harness/api/stepUp.spec.ts +39 -0
- package/verify/harness/api/totp.spec.ts +24 -0
- package/verify/harness/global-setup.ts +45 -0
- package/verify/harness/lib/adapterFlows.ts +57 -0
- package/verify/harness/lib/client.ts +32 -0
- package/verify/harness/lib/env.ts +45 -0
- package/verify/harness/lib/fixtures.ts +20 -0
- package/verify/harness/lib/flows.ts +204 -0
- package/verify/harness/lib/matrixReporter.ts +73 -0
- package/verify/harness/lib/reactFlows.ts +117 -0
- package/verify/harness/lib/serviceToken.ts +26 -0
- package/verify/harness/lib/totp.ts +36 -0
- package/verify/harness/mock-oidc.ts +112 -0
- package/verify/harness/package-lock.json +93 -0
- package/verify/harness/package.json +14 -0
- package/verify/harness/playwright.config.ts +30 -0
- package/verify/harness/react/emailOtpLogin.spec.ts +13 -0
- package/verify/harness/react/logout.spec.ts +17 -0
- package/verify/harness/react/magicLinkLogin.spec.ts +29 -0
- package/verify/harness/react/oauth.spec.ts +13 -0
- package/verify/harness/react/passkeyLogin.spec.ts +24 -0
- package/verify/harness/react/passkeyRegister.spec.ts +15 -0
- package/verify/harness/react/register.spec.ts +18 -0
- package/verify/react-vendor/.gitkeep +0 -0
- package/dist/commands/contribute.js +0 -121
- package/dist/commands/contribute.js.map +0 -1
- package/dist/commands/deploy.js +0 -190
- package/dist/commands/deploy.js.map +0 -1
- package/dist/commands/destroy.js +0 -122
- package/dist/commands/destroy.js.map +0 -1
- package/dist/contributor/catalog.js +0 -129
- package/dist/contributor/catalog.js.map +0 -1
- package/dist/contributor/workspace.js +0 -551
- package/dist/contributor/workspace.js.map +0 -1
- package/dist/core/acm.js +0 -24
- package/dist/core/acm.js.map +0 -1
- package/dist/core/aws.js +0 -374
- package/dist/core/aws.js.map +0 -1
- package/dist/core/buildPush.js +0 -91
- package/dist/core/buildPush.js.map +0 -1
- package/dist/core/configure.js +0 -25
- package/dist/core/configure.js.map +0 -1
- package/dist/core/deployConfig.js +0 -66
- package/dist/core/deployConfig.js.map +0 -1
- package/dist/core/deployManifest.js +0 -163
- package/dist/core/deployManifest.js.map +0 -1
- package/dist/core/deploySecrets.js +0 -31
- package/dist/core/deploySecrets.js.map +0 -1
- package/dist/core/destroy.js +0 -38
- package/dist/core/destroy.js.map +0 -1
- package/dist/core/envDiscovery.js +0 -153
- package/dist/core/envDiscovery.js.map +0 -1
- package/dist/core/envRegistry.js +0 -76
- package/dist/core/envRegistry.js.map +0 -1
- package/dist/core/health.js +0 -11
- package/dist/core/health.js.map +0 -1
- package/dist/core/help.js +0 -75
- package/dist/core/help.js.map +0 -1
- package/dist/core/preReqs.js +0 -14
- package/dist/core/preReqs.js.map +0 -1
- package/dist/core/terraform.js +0 -34
- package/dist/core/terraform.js.map +0 -1
- package/dist/core/tfVars.js +0 -69
- package/dist/core/tfVars.js.map +0 -1
- package/dist/generators/backend/express.js +0 -13
- package/dist/generators/backend/express.js.map +0 -1
- package/dist/generators/deploy/aws/dev/files.js +0 -36
- package/dist/generators/deploy/aws/dev/files.js.map +0 -1
- package/dist/generators/deploy/aws/dev/template.js +0 -1240
- package/dist/generators/deploy/aws/dev/template.js.map +0 -1
- package/dist/generators/frontend/react.js +0 -13
- package/dist/generators/frontend/react.js.map +0 -1
- package/dist/prompts/contributeSetup.js +0 -39
- package/dist/prompts/contributeSetup.js.map +0 -1
- package/dist/prompts/deploySetup.js +0 -244
- package/dist/prompts/deploySetup.js.map +0 -1
- package/dist/utils/repoUtils.js +0 -23
- package/dist/utils/repoUtils.js.map +0 -1
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { expect, test } from '../lib/fixtures';
|
|
2
|
+
|
|
3
|
+
test.describe('JWKS (api)', () => {
|
|
4
|
+
test('publishes the active signing key as a JWK', async ({ actor }) => {
|
|
5
|
+
const res = await actor.ctx.get('/.well-known/jwks.json');
|
|
6
|
+
expect(res.status(), 'JWKS endpoint is available').toBe(200);
|
|
7
|
+
|
|
8
|
+
const body = await res.json();
|
|
9
|
+
expect(Array.isArray(body.keys), 'has a keys array').toBeTruthy();
|
|
10
|
+
|
|
11
|
+
const key = body.keys.find((k: { kid?: string }) => k.kid === 'dev-main');
|
|
12
|
+
expect(key, 'publishes the dev-main signing key').toBeTruthy();
|
|
13
|
+
expect(key.kty).toBe('RSA');
|
|
14
|
+
expect(key.use).toBe('sig');
|
|
15
|
+
expect(key.alg).toBe('RS256');
|
|
16
|
+
});
|
|
17
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { expect, test } from '../lib/fixtures';
|
|
2
|
+
import {
|
|
3
|
+
login,
|
|
4
|
+
pollMagicLink,
|
|
5
|
+
registerAndVerifyEmail,
|
|
6
|
+
requestMagicLink,
|
|
7
|
+
verifyMagicLink,
|
|
8
|
+
} from '../lib/flows';
|
|
9
|
+
|
|
10
|
+
test.describe('magic link (api)', () => {
|
|
11
|
+
test('request -> poll(waiting) -> verify -> poll issues a session', async ({ actor }) => {
|
|
12
|
+
await registerAndVerifyEmail(actor.ctx, actor.email); // user must exist + be verified
|
|
13
|
+
const ephemeral = await login(actor.ctx, actor.email);
|
|
14
|
+
|
|
15
|
+
const { token } = await requestMagicLink(actor.ctx, ephemeral);
|
|
16
|
+
|
|
17
|
+
// Polling before the link is verified must return 204 (still waiting) — never 500.
|
|
18
|
+
// This is the exact regression that created the otp-sanatization branch.
|
|
19
|
+
const pending = await pollMagicLink(actor.ctx, ephemeral);
|
|
20
|
+
expect(pending.status(), 'poll before verify is 204, not 500').toBe(204);
|
|
21
|
+
|
|
22
|
+
const verified = await verifyMagicLink(actor.ctx, token);
|
|
23
|
+
expect(verified.ok(), `verify -> ${verified.status()}`).toBeTruthy();
|
|
24
|
+
|
|
25
|
+
const completed = await pollMagicLink(actor.ctx, ephemeral);
|
|
26
|
+
expect(completed.status(), 'poll after verify issues a session').toBe(200);
|
|
27
|
+
const body = await completed.json();
|
|
28
|
+
expect(body.token, 'session access token').toBeTruthy();
|
|
29
|
+
expect(body.refreshToken, 'session refresh token').toBeTruthy();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('an invalid magic-link token is rejected', async ({ actor }) => {
|
|
33
|
+
const res = await verifyMagicLink(actor.ctx, 'not-a-real-token');
|
|
34
|
+
expect(res.status()).toBe(400);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { expect, test } from '../lib/fixtures';
|
|
2
|
+
import { oauthLogin } from '../lib/flows';
|
|
3
|
+
|
|
4
|
+
test.describe('OAuth (api)', () => {
|
|
5
|
+
test('start -> authorize -> callback issues a session', async ({ actor }) => {
|
|
6
|
+
const session = await oauthLogin(actor.ctx);
|
|
7
|
+
|
|
8
|
+
expect(session.token, 'oauth issues an access token').toBeTruthy();
|
|
9
|
+
expect(session.refreshToken, 'oauth issues a refresh token').toBeTruthy();
|
|
10
|
+
expect(session.sub, 'oauth resolves a user').toBeTruthy();
|
|
11
|
+
});
|
|
12
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { expect, test } from '../lib/fixtures';
|
|
2
|
+
import { registerAndVerifyEmail } from '../lib/flows';
|
|
3
|
+
|
|
4
|
+
test.describe('organizations (api)', () => {
|
|
5
|
+
test('create -> list -> switch active', async ({ actor }) => {
|
|
6
|
+
const { token } = await registerAndVerifyEmail(actor.ctx, actor.email);
|
|
7
|
+
const auth = { Authorization: `Bearer ${token}` };
|
|
8
|
+
|
|
9
|
+
const created = await actor.ctx.post('/organizations', {
|
|
10
|
+
headers: auth,
|
|
11
|
+
data: { name: 'Acme Inc' },
|
|
12
|
+
});
|
|
13
|
+
expect(created.ok(), `create org -> ${created.status()}`).toBeTruthy();
|
|
14
|
+
const orgId = (await created.json()).organization.id;
|
|
15
|
+
expect(orgId, 'create returns an organization id').toBeTruthy();
|
|
16
|
+
|
|
17
|
+
const list = await actor.ctx.get('/organizations', { headers: auth });
|
|
18
|
+
expect(list.ok()).toBeTruthy();
|
|
19
|
+
const orgs = (await list.json()).organizations as Array<{ id: string }>;
|
|
20
|
+
expect(orgs.some((o) => o.id === orgId), 'created org appears in the list').toBeTruthy();
|
|
21
|
+
|
|
22
|
+
const switched = await actor.ctx.post(`/organizations/${orgId}/switch`, {
|
|
23
|
+
headers: auth,
|
|
24
|
+
data: {},
|
|
25
|
+
});
|
|
26
|
+
expect(switched.ok(), `switch org -> ${switched.status()}`).toBeTruthy();
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { expect, test } from '../lib/fixtures';
|
|
2
|
+
import { uniquePhone } from '../lib/env';
|
|
3
|
+
import { registerWithPhone, requestPhoneOtp, verifyPhoneOtp } from '../lib/flows';
|
|
4
|
+
|
|
5
|
+
test.describe('phone OTP (api)', () => {
|
|
6
|
+
test('register with phone -> request OTP -> verify', async ({ actor }) => {
|
|
7
|
+
const ephemeral = await registerWithPhone(actor.ctx, actor.email, uniquePhone());
|
|
8
|
+
const code = await requestPhoneOtp(actor.ctx, ephemeral);
|
|
9
|
+
const res = await verifyPhoneOtp(actor.ctx, ephemeral, code);
|
|
10
|
+
expect(res.ok(), `verify-phone-otp -> ${res.status()}`).toBeTruthy();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('a wrong phone OTP code is rejected with 401', async ({ actor }) => {
|
|
14
|
+
const ephemeral = await registerWithPhone(actor.ctx, actor.email, uniquePhone());
|
|
15
|
+
await requestPhoneOtp(actor.ctx, ephemeral); // issue a real code we deliberately won't use
|
|
16
|
+
const res = await verifyPhoneOtp(actor.ctx, ephemeral, '000000');
|
|
17
|
+
expect(res.status()).toBe(401);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { expect, test } from '../lib/fixtures';
|
|
2
|
+
import { listSessions, logout, refresh, registerAndVerifyEmail } from '../lib/flows';
|
|
3
|
+
|
|
4
|
+
test.describe('session lifecycle (api)', () => {
|
|
5
|
+
test('refresh issues a new access token', async ({ actor }) => {
|
|
6
|
+
const { refreshToken } = await registerAndVerifyEmail(actor.ctx, actor.email);
|
|
7
|
+
const res = await refresh(actor.ctx, refreshToken);
|
|
8
|
+
expect(res.status()).toBe(200);
|
|
9
|
+
const body = await res.json();
|
|
10
|
+
expect(body.token, 'refresh returns a new access token').toBeTruthy();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('sessions list shows the active session; logout revokes it', async ({ actor }) => {
|
|
14
|
+
const { token } = await registerAndVerifyEmail(actor.ctx, actor.email);
|
|
15
|
+
|
|
16
|
+
const list = await listSessions(actor.ctx, token);
|
|
17
|
+
expect(list.status()).toBe(200);
|
|
18
|
+
expect((await list.json()).total).toBeGreaterThanOrEqual(1);
|
|
19
|
+
|
|
20
|
+
const out = await logout(actor.ctx, token);
|
|
21
|
+
expect(out.ok(), `logout -> ${out.status()}`).toBeTruthy();
|
|
22
|
+
|
|
23
|
+
const after = await listSessions(actor.ctx, token);
|
|
24
|
+
expect(after.status(), 'access token rejected after logout').toBe(401);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { expect, test } from '../lib/fixtures';
|
|
2
|
+
import { registerAndVerifyEmail } from '../lib/flows';
|
|
3
|
+
import { totp } from '../lib/totp';
|
|
4
|
+
|
|
5
|
+
test.describe('step-up (api)', () => {
|
|
6
|
+
test('TOTP MFA freshens the session step-up status', async ({ actor }) => {
|
|
7
|
+
const { token } = await registerAndVerifyEmail(actor.ctx, actor.email);
|
|
8
|
+
const auth = { Authorization: `Bearer ${token}` };
|
|
9
|
+
|
|
10
|
+
// Enroll TOTP (enrollment alone must not satisfy step-up).
|
|
11
|
+
const start = await actor.ctx.post('/totp/enroll/start', { headers: auth, data: {} });
|
|
12
|
+
expect(start.ok(), `enroll/start -> ${start.status()}`).toBeTruthy();
|
|
13
|
+
const { secret } = await start.json();
|
|
14
|
+
const enroll = await actor.ctx.post('/totp/enroll/verify', {
|
|
15
|
+
headers: auth,
|
|
16
|
+
data: { code: totp(secret) },
|
|
17
|
+
});
|
|
18
|
+
expect(enroll.ok(), `enroll/verify -> ${enroll.status()}`).toBeTruthy();
|
|
19
|
+
|
|
20
|
+
const before = await actor.ctx.get('/step-up/status', { headers: auth });
|
|
21
|
+
expect(before.ok()).toBeTruthy();
|
|
22
|
+
expect((await before.json()).fresh, 'step-up is not fresh before MFA').toBe(false);
|
|
23
|
+
|
|
24
|
+
// Verifying TOTP MFA records a step-up. Use the next window's code: the API
|
|
25
|
+
// rejects a code whose counter is not strictly greater than the last used one
|
|
26
|
+
// (enrollment just consumed the current window), and accepts up to +1 of skew.
|
|
27
|
+
const mfa = await actor.ctx.post('/totp/verify-mfa', {
|
|
28
|
+
headers: auth,
|
|
29
|
+
data: { code: totp(secret, Date.now() + 30_000) },
|
|
30
|
+
});
|
|
31
|
+
expect(mfa.ok(), `verify-mfa -> ${mfa.status()} ${await mfa.text()}`).toBeTruthy();
|
|
32
|
+
const mfaBody = await mfa.json();
|
|
33
|
+
expect(mfaBody.fresh, 'verify-mfa returns a fresh step-up').toBe(true);
|
|
34
|
+
expect(mfaBody.method, 'step-up method is totp').toBe('totp');
|
|
35
|
+
|
|
36
|
+
const after = await actor.ctx.get('/step-up/status', { headers: auth });
|
|
37
|
+
expect((await after.json()).fresh, 'step-up is fresh after MFA').toBe(true);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { expect, test } from '../lib/fixtures';
|
|
2
|
+
import { registerAndVerifyEmail } from '../lib/flows';
|
|
3
|
+
import { totp } from '../lib/totp';
|
|
4
|
+
|
|
5
|
+
test.describe('TOTP (api)', () => {
|
|
6
|
+
test('enroll -> verify enrollment with a computed code -> status enabled', async ({ actor }) => {
|
|
7
|
+
const { token } = await registerAndVerifyEmail(actor.ctx, actor.email);
|
|
8
|
+
const auth = { Authorization: `Bearer ${token}` };
|
|
9
|
+
|
|
10
|
+
const start = await actor.ctx.post('/totp/enroll/start', { headers: auth, data: {} });
|
|
11
|
+
expect(start.ok(), `enroll/start -> ${start.status()}`).toBeTruthy();
|
|
12
|
+
const { secret } = await start.json();
|
|
13
|
+
expect(secret, 'enroll/start returns a base32 secret').toBeTruthy();
|
|
14
|
+
|
|
15
|
+
const verify = await actor.ctx.post('/totp/enroll/verify', {
|
|
16
|
+
headers: auth,
|
|
17
|
+
data: { code: totp(secret) },
|
|
18
|
+
});
|
|
19
|
+
expect(verify.ok(), `enroll/verify -> ${verify.status()}`).toBeTruthy();
|
|
20
|
+
|
|
21
|
+
const status = await actor.ctx.get('/totp/status', { headers: auth });
|
|
22
|
+
expect((await status.json()).enabled, 'TOTP is enabled after enrollment').toBe(true);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { request as playwrightRequest } from '@playwright/test';
|
|
2
|
+
|
|
3
|
+
import { ADAPTER_URL, API_URL, MOCK_OIDC_PORT, REACT_URL } from './lib/env';
|
|
4
|
+
import { startMockOidc } from './mock-oidc';
|
|
5
|
+
|
|
6
|
+
async function waitForHealth(url: string, name: string, timeoutMs = 120_000): Promise<void> {
|
|
7
|
+
const ctx = await playwrightRequest.newContext();
|
|
8
|
+
const start = Date.now();
|
|
9
|
+
let lastError = '';
|
|
10
|
+
try {
|
|
11
|
+
while (Date.now() - start < timeoutMs) {
|
|
12
|
+
try {
|
|
13
|
+
const res = await ctx.get(url);
|
|
14
|
+
if (res.ok()) {
|
|
15
|
+
// eslint-disable-next-line no-console
|
|
16
|
+
console.log(`✔ ${name} healthy (${url})`);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
lastError = `status ${res.status()}`;
|
|
20
|
+
} catch (err) {
|
|
21
|
+
lastError = err instanceof Error ? err.message : String(err);
|
|
22
|
+
}
|
|
23
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
24
|
+
}
|
|
25
|
+
} finally {
|
|
26
|
+
await ctx.dispose();
|
|
27
|
+
}
|
|
28
|
+
throw new Error(`✖ ${name} not healthy at ${url} within ${timeoutMs}ms (last: ${lastError})`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export default async function globalSetup(): Promise<void> {
|
|
32
|
+
// In-process mock OIDC provider for the OAuth flow (the API reaches it via
|
|
33
|
+
// host.docker.internal; the harness drives /authorize via localhost).
|
|
34
|
+
startMockOidc(MOCK_OIDC_PORT);
|
|
35
|
+
// eslint-disable-next-line no-console
|
|
36
|
+
console.log(`✔ mock OIDC listening (:${MOCK_OIDC_PORT})`);
|
|
37
|
+
|
|
38
|
+
await waitForHealth(`${API_URL}/health/status`, 'auth-api');
|
|
39
|
+
if (process.env.SEAMLESS_VERIFY_ADAPTER === '1') {
|
|
40
|
+
await waitForHealth(`${ADAPTER_URL}/`, 'adapter');
|
|
41
|
+
}
|
|
42
|
+
if (process.env.SEAMLESS_VERIFY_REACT === '1') {
|
|
43
|
+
await waitForHealth(`${REACT_URL}/health`, 'react');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { APIRequestContext, expect } from '@playwright/test';
|
|
2
|
+
|
|
3
|
+
// Cookie-based flows against the adapter (baseURL = adapter). The adapter strips
|
|
4
|
+
// OTP/magic-link secrets from browser responses, so codes are read from the
|
|
5
|
+
// harness adapter app's /__captured readout.
|
|
6
|
+
|
|
7
|
+
async function readCapturedCode(ctx: APIRequestContext, email: string): Promise<string> {
|
|
8
|
+
const res = await ctx.get(`/__captured/${encodeURIComponent(email)}`);
|
|
9
|
+
expect(res.ok(), `captured lookup -> ${res.status()}`).toBeTruthy();
|
|
10
|
+
const body = await res.json();
|
|
11
|
+
expect(body?.token, `a captured code for ${email}`).toBeTruthy();
|
|
12
|
+
return String(body.token);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function registerAndVerifyEmail(ctx: APIRequestContext, email: string): Promise<void> {
|
|
16
|
+
let res = await ctx.post('/auth/registration/register', { data: { email } });
|
|
17
|
+
expect(res.ok(), `register -> ${res.status()}`).toBeTruthy();
|
|
18
|
+
|
|
19
|
+
res = await ctx.get('/auth/otp/generate-email-otp');
|
|
20
|
+
expect(res.ok(), `generate-email-otp -> ${res.status()}`).toBeTruthy();
|
|
21
|
+
|
|
22
|
+
const code = await readCapturedCode(ctx, email);
|
|
23
|
+
res = await ctx.post('/auth/otp/verify-email-otp', { data: { verificationToken: code } });
|
|
24
|
+
expect(res.ok(), `verify-email-otp -> ${res.status()}`).toBeTruthy();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function loginViaEmailOtp(ctx: APIRequestContext, email: string): Promise<void> {
|
|
28
|
+
let res = await ctx.post('/auth/login', { data: { identifier: email } });
|
|
29
|
+
expect(res.ok(), `login -> ${res.status()}`).toBeTruthy();
|
|
30
|
+
|
|
31
|
+
res = await ctx.get('/auth/otp/generate-login-email-otp');
|
|
32
|
+
expect(res.ok(), `generate-login-email-otp -> ${res.status()}`).toBeTruthy();
|
|
33
|
+
|
|
34
|
+
const code = await readCapturedCode(ctx, email);
|
|
35
|
+
res = await ctx.post('/auth/otp/verify-login-email-otp', { data: { verificationToken: code } });
|
|
36
|
+
expect(res.ok(), `verify-login-email-otp -> ${res.status()}`).toBeTruthy();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function loginViaMagicLink(ctx: APIRequestContext, email: string): Promise<void> {
|
|
40
|
+
let res = await ctx.post('/auth/login', { data: { identifier: email } });
|
|
41
|
+
expect(res.ok(), `login -> ${res.status()}`).toBeTruthy();
|
|
42
|
+
|
|
43
|
+
res = await ctx.get('/auth/magic-link');
|
|
44
|
+
expect(res.ok(), `magic-link request -> ${res.status()}`).toBeTruthy();
|
|
45
|
+
const token = await readCapturedCode(ctx, email);
|
|
46
|
+
|
|
47
|
+
// Polling before the link is verified must return 204 (still waiting).
|
|
48
|
+
const pending = await ctx.get('/auth/magic-link/check');
|
|
49
|
+
expect(pending.status(), 'poll before verify is 204').toBe(204);
|
|
50
|
+
|
|
51
|
+
res = await ctx.get(`/auth/magic-link/verify/${token}`);
|
|
52
|
+
expect(res.ok(), `verify magic link -> ${res.status()}`).toBeTruthy();
|
|
53
|
+
|
|
54
|
+
// Polling after verification issues the session (device binding must match).
|
|
55
|
+
const completed = await ctx.get('/auth/magic-link/check');
|
|
56
|
+
expect(completed.status(), 'poll after verify issues a session').toBe(200);
|
|
57
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { APIRequestContext, request as playwrightRequest } from '@playwright/test';
|
|
2
|
+
|
|
3
|
+
import { ADAPTER_URL, API_SERVICE_TOKEN, API_URL, uniqueClientIp, uniqueEmail } from './env';
|
|
4
|
+
import { mintServiceToken } from './serviceToken';
|
|
5
|
+
|
|
6
|
+
export interface Actor {
|
|
7
|
+
email: string;
|
|
8
|
+
ctx: APIRequestContext;
|
|
9
|
+
dispose: () => Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// An actor is one virtual user: a fresh request context bound to the API, with
|
|
13
|
+
// its own client IP + service token applied to every request. This both isolates
|
|
14
|
+
// rate-limit buckets and faithfully mirrors how the server adapter calls the API.
|
|
15
|
+
export async function newApiActor(prefix = 'verify'): Promise<Actor> {
|
|
16
|
+
const ctx = await playwrightRequest.newContext({
|
|
17
|
+
baseURL: API_URL,
|
|
18
|
+
extraHTTPHeaders: {
|
|
19
|
+
'x-seamless-client-ip': uniqueClientIp(),
|
|
20
|
+
'x-seamless-service-token': `Bearer ${mintServiceToken(API_SERVICE_TOKEN)}`,
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
return { email: uniqueEmail(prefix), ctx, dispose: () => ctx.dispose() };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// A browser-like actor for the cookie path: a context bound to the adapter that
|
|
28
|
+
// persists cookies across requests (the adapter handles service tokens internally).
|
|
29
|
+
export async function newAdapterActor(prefix = 'verify'): Promise<Actor> {
|
|
30
|
+
const ctx = await playwrightRequest.newContext({ baseURL: ADAPTER_URL });
|
|
31
|
+
return { email: uniqueEmail(prefix), ctx, dispose: () => ctx.dispose() };
|
|
32
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Shared config + helpers for the conformance harness.
|
|
2
|
+
|
|
3
|
+
import { randomInt, randomUUID } from 'crypto';
|
|
4
|
+
|
|
5
|
+
export const API_URL = process.env.SEAMLESS_API_URL ?? 'http://localhost:5312';
|
|
6
|
+
export const ADAPTER_URL = process.env.SEAMLESS_ADAPTER_URL ?? 'http://localhost:3000';
|
|
7
|
+
export const REACT_URL = process.env.SEAMLESS_REACT_URL ?? 'http://localhost:5173';
|
|
8
|
+
export const MOCK_OIDC_PORT = Number(process.env.SEAMLESS_MOCK_OIDC_PORT ?? 9000);
|
|
9
|
+
|
|
10
|
+
// Must match the API's API_SERVICE_TOKEN so the harness can mint M2M tokens.
|
|
11
|
+
export const API_SERVICE_TOKEN =
|
|
12
|
+
process.env.SEAMLESS_API_SERVICE_TOKEN ?? 'verify-dev-service-token';
|
|
13
|
+
|
|
14
|
+
// Must match the API's SEAMLESS_BOOTSTRAP_SECRET so the harness can mint the
|
|
15
|
+
// first admin invite (bootstrap-promotion flow).
|
|
16
|
+
export const BOOTSTRAP_SECRET =
|
|
17
|
+
process.env.SEAMLESS_BOOTSTRAP_SECRET ?? 'verify-dev-bootstrap-secret';
|
|
18
|
+
|
|
19
|
+
// Non-production seam: makes the API return OTP / magic-link tokens in the
|
|
20
|
+
// response `delivery` object instead of sending real email/SMS.
|
|
21
|
+
export const EXTERNAL_DELIVERY = {
|
|
22
|
+
'x-seamless-auth-delivery-mode': 'external',
|
|
23
|
+
} as const;
|
|
24
|
+
|
|
25
|
+
const runId = process.env.SEAMLESS_RUN_ID ?? String(Date.now());
|
|
26
|
+
|
|
27
|
+
// A random suffix (not just a module-level counter) keeps emails unique even
|
|
28
|
+
// though Playwright re-evaluates this module per spec file, which resets the
|
|
29
|
+
// counter — duplicate emails otherwise collide on the API's per-email OTP limit.
|
|
30
|
+
export function uniqueEmail(prefix = 'verify'): string {
|
|
31
|
+
return `${prefix}.${runId}.${randomUUID().slice(0, 8)}@example.test`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Distinct client IP per actor so the API's per-IP rate limiters see separate
|
|
35
|
+
// buckets (honored only alongside a valid service token — see client.ts).
|
|
36
|
+
// Random (not a module-level counter) to survive per-file module re-evaluation.
|
|
37
|
+
export function uniqueClientIp(): string {
|
|
38
|
+
return `10.${randomInt(256)}.${randomInt(256)}.${randomInt(1, 255)}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Valid-format US numbers (415 area, 7-digit subscriber). Random so they stay
|
|
42
|
+
// unique across runs and across per-file module resets without a shared counter.
|
|
43
|
+
export function uniquePhone(): string {
|
|
44
|
+
return `+1415${2_000_000 + randomInt(7_000_000)}`;
|
|
45
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { test as base } from '@playwright/test';
|
|
2
|
+
|
|
3
|
+
import { Actor, newAdapterActor, newApiActor } from './client';
|
|
4
|
+
|
|
5
|
+
// `actor` drives the API directly (Bearer + service token); `adapterActor` drives
|
|
6
|
+
// the adopter backend over cookies. Both are auto-created and disposed per test.
|
|
7
|
+
export const test = base.extend<{ actor: Actor; adapterActor: Actor }>({
|
|
8
|
+
actor: async ({}, use) => {
|
|
9
|
+
const actor = await newApiActor();
|
|
10
|
+
await use(actor);
|
|
11
|
+
await actor.dispose();
|
|
12
|
+
},
|
|
13
|
+
adapterActor: async ({}, use) => {
|
|
14
|
+
const actor = await newAdapterActor();
|
|
15
|
+
await use(actor);
|
|
16
|
+
await actor.dispose();
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export { expect } from '@playwright/test';
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { APIRequestContext, expect } from '@playwright/test';
|
|
2
|
+
|
|
3
|
+
import { BOOTSTRAP_SECRET, EXTERNAL_DELIVERY } from './env';
|
|
4
|
+
|
|
5
|
+
export interface SessionTokens {
|
|
6
|
+
token: string; // signed access token
|
|
7
|
+
refreshToken: string; // opaque refresh token
|
|
8
|
+
sub?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function bearer(token: string) {
|
|
12
|
+
return { Authorization: `Bearer ${token}` };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// All helpers take an actor's request context (baseURL = API, with client-IP +
|
|
16
|
+
// service-token headers already applied). Paths are therefore relative.
|
|
17
|
+
|
|
18
|
+
/** Register a new email user; returns the ephemeral (pre-auth) token. */
|
|
19
|
+
export async function registerEmail(ctx: APIRequestContext, email: string): Promise<string> {
|
|
20
|
+
const res = await ctx.post('/registration/register', {
|
|
21
|
+
headers: EXTERNAL_DELIVERY,
|
|
22
|
+
data: { email },
|
|
23
|
+
});
|
|
24
|
+
expect(res.ok(), `register ${email} -> ${res.status()} ${await res.text()}`).toBeTruthy();
|
|
25
|
+
const body = await res.json();
|
|
26
|
+
expect(body.token, 'register returns an ephemeral token').toBeTruthy();
|
|
27
|
+
return body.token as string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Register a new user with email + phone; returns the ephemeral (pre-auth) token. */
|
|
31
|
+
export async function registerWithPhone(
|
|
32
|
+
ctx: APIRequestContext,
|
|
33
|
+
email: string,
|
|
34
|
+
phone: string,
|
|
35
|
+
): Promise<string> {
|
|
36
|
+
const res = await ctx.post('/registration/register', {
|
|
37
|
+
headers: EXTERNAL_DELIVERY,
|
|
38
|
+
data: { email, phone },
|
|
39
|
+
});
|
|
40
|
+
expect(res.ok(), `register ${email} -> ${res.status()} ${await res.text()}`).toBeTruthy();
|
|
41
|
+
const body = await res.json();
|
|
42
|
+
expect(body.token, 'register returns an ephemeral token').toBeTruthy();
|
|
43
|
+
return body.token as string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Mint the one-time bootstrap admin invite (returns the raw invite token via
|
|
48
|
+
* the external-delivery seam). Only succeeds before any admin exists — the API
|
|
49
|
+
* gates re-use with 410, so this assumes a fresh DB (what `seamless verify` runs).
|
|
50
|
+
*/
|
|
51
|
+
export async function createBootstrapInvite(
|
|
52
|
+
ctx: APIRequestContext,
|
|
53
|
+
email: string,
|
|
54
|
+
): Promise<string> {
|
|
55
|
+
const res = await ctx.post('/internal/bootstrap/admin-invite', {
|
|
56
|
+
headers: { Authorization: `Bearer ${BOOTSTRAP_SECRET}`, ...EXTERNAL_DELIVERY },
|
|
57
|
+
data: { email },
|
|
58
|
+
});
|
|
59
|
+
expect(res.ok(), `bootstrap invite -> ${res.status()} ${await res.text()}`).toBeTruthy();
|
|
60
|
+
const token = (await res.json())?.data?.token;
|
|
61
|
+
expect(token, 'bootstrap invite returns a token via external delivery').toBeTruthy();
|
|
62
|
+
return String(token);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Register a user carrying a bootstrap invite token; returns the ephemeral token. */
|
|
66
|
+
export async function registerWithBootstrapToken(
|
|
67
|
+
ctx: APIRequestContext,
|
|
68
|
+
email: string,
|
|
69
|
+
bootstrapToken: string,
|
|
70
|
+
): Promise<string> {
|
|
71
|
+
const res = await ctx.post('/registration/register', {
|
|
72
|
+
headers: EXTERNAL_DELIVERY,
|
|
73
|
+
data: { email, bootstrapToken },
|
|
74
|
+
});
|
|
75
|
+
expect(res.ok(), `register ${email} -> ${res.status()} ${await res.text()}`).toBeTruthy();
|
|
76
|
+
const body = await res.json();
|
|
77
|
+
expect(body.token, 'register returns an ephemeral token').toBeTruthy();
|
|
78
|
+
return body.token as string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Generate a phone OTP and return the raw code via the external-delivery seam. */
|
|
82
|
+
export async function requestPhoneOtp(ctx: APIRequestContext, ephemeral: string): Promise<string> {
|
|
83
|
+
const res = await ctx.get('/otp/generate-phone-otp', {
|
|
84
|
+
headers: { ...bearer(ephemeral), ...EXTERNAL_DELIVERY },
|
|
85
|
+
});
|
|
86
|
+
expect(res.ok(), `generate-phone-otp -> ${res.status()} ${await res.text()}`).toBeTruthy();
|
|
87
|
+
const code = (await res.json())?.delivery?.token;
|
|
88
|
+
expect(code, 'external delivery returns the phone OTP code').toBeTruthy();
|
|
89
|
+
return String(code);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function verifyPhoneOtp(ctx: APIRequestContext, ephemeral: string, code: string) {
|
|
93
|
+
return ctx.post('/otp/verify-phone-otp', {
|
|
94
|
+
headers: bearer(ephemeral),
|
|
95
|
+
data: { verificationToken: code },
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Generate an email OTP and return the raw code via the external-delivery seam. */
|
|
100
|
+
export async function requestEmailOtp(ctx: APIRequestContext, ephemeral: string): Promise<string> {
|
|
101
|
+
const res = await ctx.get('/otp/generate-email-otp', {
|
|
102
|
+
headers: { ...bearer(ephemeral), ...EXTERNAL_DELIVERY },
|
|
103
|
+
});
|
|
104
|
+
expect(res.ok(), `generate-email-otp -> ${res.status()} ${await res.text()}`).toBeTruthy();
|
|
105
|
+
const code = (await res.json())?.delivery?.token;
|
|
106
|
+
expect(code, 'external delivery returns the OTP code').toBeTruthy();
|
|
107
|
+
return String(code);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function verifyEmailOtp(ctx: APIRequestContext, ephemeral: string, code: string) {
|
|
111
|
+
return ctx.post('/otp/verify-email-otp', {
|
|
112
|
+
headers: bearer(ephemeral),
|
|
113
|
+
data: { verificationToken: code },
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Full email-OTP registration -> verified session tokens. */
|
|
118
|
+
export async function registerAndVerifyEmail(
|
|
119
|
+
ctx: APIRequestContext,
|
|
120
|
+
email: string,
|
|
121
|
+
): Promise<SessionTokens> {
|
|
122
|
+
const ephemeral = await registerEmail(ctx, email);
|
|
123
|
+
const code = await requestEmailOtp(ctx, ephemeral);
|
|
124
|
+
const res = await verifyEmailOtp(ctx, ephemeral, code);
|
|
125
|
+
expect(res.ok(), `verify-email-otp -> ${res.status()} ${await res.text()}`).toBeTruthy();
|
|
126
|
+
const body = await res.json();
|
|
127
|
+
expect(body.token, 'verify returns an access token').toBeTruthy();
|
|
128
|
+
expect(body.refreshToken, 'verify returns a refresh token').toBeTruthy();
|
|
129
|
+
return { token: body.token, refreshToken: body.refreshToken, sub: body.sub };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Begin a login; returns the ephemeral (pre-auth) token. */
|
|
133
|
+
export async function login(ctx: APIRequestContext, identifier: string): Promise<string> {
|
|
134
|
+
const res = await ctx.post('/login', { data: { identifier } });
|
|
135
|
+
expect(res.ok(), `login ${identifier} -> ${res.status()} ${await res.text()}`).toBeTruthy();
|
|
136
|
+
const body = await res.json();
|
|
137
|
+
expect(body.token, 'login returns an ephemeral token').toBeTruthy();
|
|
138
|
+
return body.token as string;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Request a magic link; returns the raw token + URL via external delivery. */
|
|
142
|
+
export async function requestMagicLink(
|
|
143
|
+
ctx: APIRequestContext,
|
|
144
|
+
ephemeral: string,
|
|
145
|
+
): Promise<{ token: string; magicLinkUrl: string }> {
|
|
146
|
+
const res = await ctx.get('/magic-link', {
|
|
147
|
+
headers: { ...bearer(ephemeral), ...EXTERNAL_DELIVERY },
|
|
148
|
+
});
|
|
149
|
+
expect(res.ok(), `magic-link request -> ${res.status()} ${await res.text()}`).toBeTruthy();
|
|
150
|
+
const body = await res.json();
|
|
151
|
+
expect(body?.delivery?.token, 'external delivery returns the magic-link token').toBeTruthy();
|
|
152
|
+
return { token: body.delivery.token, magicLinkUrl: body.delivery.magicLinkUrl };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function verifyMagicLink(ctx: APIRequestContext, rawToken: string) {
|
|
156
|
+
return ctx.get(`/magic-link/verify/${rawToken}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function pollMagicLink(ctx: APIRequestContext, ephemeral: string) {
|
|
160
|
+
return ctx.get('/magic-link/check', { headers: bearer(ephemeral) });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function refresh(ctx: APIRequestContext, refreshToken: string) {
|
|
164
|
+
return ctx.post('/refresh', { headers: bearer(refreshToken) });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function listSessions(ctx: APIRequestContext, accessToken: string) {
|
|
168
|
+
return ctx.get('/sessions', { headers: bearer(accessToken) });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function logout(ctx: APIRequestContext, accessToken: string) {
|
|
172
|
+
return ctx.delete('/logout', { headers: bearer(accessToken) });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Full OAuth login against the mock IdP: start the flow, follow the authorize
|
|
177
|
+
* redirect to obtain the code (the mock mints a fresh user), then exchange it at
|
|
178
|
+
* the callback for a session. `pathPrefix` is '' for the API, '/auth' for the adapter.
|
|
179
|
+
*/
|
|
180
|
+
export async function oauthLogin(
|
|
181
|
+
ctx: APIRequestContext,
|
|
182
|
+
providerId = 'mock',
|
|
183
|
+
pathPrefix = '',
|
|
184
|
+
): Promise<SessionTokens & { email?: string }> {
|
|
185
|
+
const start = await ctx.post(`${pathPrefix}/oauth/${providerId}/start`, { data: {} });
|
|
186
|
+
expect(start.ok(), `oauth start -> ${start.status()} ${await start.text()}`).toBeTruthy();
|
|
187
|
+
const { authorizationUrl } = await start.json();
|
|
188
|
+
|
|
189
|
+
const authorize = await ctx.get(authorizationUrl, { maxRedirects: 0 });
|
|
190
|
+
expect(authorize.status(), `authorize redirects with a code (got ${authorize.status()})`).toBe(
|
|
191
|
+
302,
|
|
192
|
+
);
|
|
193
|
+
const redirected = new URL(authorize.headers()['location']);
|
|
194
|
+
const code = redirected.searchParams.get('code');
|
|
195
|
+
const state = redirected.searchParams.get('state');
|
|
196
|
+
expect(code, 'authorize returns an auth code').toBeTruthy();
|
|
197
|
+
|
|
198
|
+
const callback = await ctx.post(`${pathPrefix}/oauth/${providerId}/callback`, {
|
|
199
|
+
data: { code, state },
|
|
200
|
+
});
|
|
201
|
+
expect(callback.ok(), `oauth callback -> ${callback.status()} ${await callback.text()}`).toBeTruthy();
|
|
202
|
+
const body = await callback.json();
|
|
203
|
+
return { token: body.token, refreshToken: body.refreshToken, sub: body.sub, email: body.email };
|
|
204
|
+
}
|