You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
331 lines
9.0 KiB
331 lines
9.0 KiB
import {
|
|
AUTH_CACHE_TAGS,
|
|
AUTH_COOKIE_NAMES,
|
|
AUTH_DEFAULTS,
|
|
AUTH_EVENT_NAMES,
|
|
AUTH_ROUTE_PATHS,
|
|
AUTH_TEST_ACTIONS,
|
|
AUTH_TEST_COOKIE_NAMES,
|
|
AUTH_TEST_FORM_FIELDS,
|
|
AUTH_TEST_IDS,
|
|
AUTH_TEST_TENANT_ID,
|
|
issueAuthCsrf,
|
|
verifyAuthCsrf,
|
|
type AuthSessionId
|
|
} from '$libs/auth';
|
|
import {
|
|
createDeterministicAuthCrypto,
|
|
createEngineAuth,
|
|
createMemoryAuthActors,
|
|
createMemoryAuthAdapter,
|
|
createMemoryAuthCache,
|
|
createMemoryAuthClock,
|
|
createMemoryAuthLogr,
|
|
createMemoryAuthMailer,
|
|
createMemoryAuthSessPort,
|
|
createTestPasswordHasher
|
|
} from '$svrs/auth';
|
|
|
|
export const prerender = false;
|
|
|
|
const AUTH_TEST_PASSWORD = 'correct horse battery staple';
|
|
const AUTH_TEST_CSRF_KEY = 'test-page-csrf-key';
|
|
const AUTH_TEST_SESSION_COOKIE_MAX_AGE = 60 * 60;
|
|
|
|
let harness = createHarness();
|
|
|
|
export async function load({ cookies }: { cookies: { get(name: string): string | undefined } }) {
|
|
const sessionId = cookies.get(AUTH_TEST_COOKIE_NAMES.SESSION) as AuthSessionId | undefined;
|
|
return {
|
|
auth: {
|
|
routes: AUTH_ROUTE_PATHS,
|
|
events: AUTH_EVENT_NAMES,
|
|
cacheTags: AUTH_CACHE_TAGS,
|
|
testIds: AUTH_TEST_IDS,
|
|
actions: AUTH_TEST_ACTIONS,
|
|
fields: AUTH_TEST_FORM_FIELDS,
|
|
defaults: {
|
|
identifier: 'ada@example.com',
|
|
password: AUTH_TEST_PASSWORD,
|
|
displayName: 'Ada Lovelace'
|
|
}
|
|
},
|
|
state: await snapshot(sessionId)
|
|
};
|
|
}
|
|
|
|
export const actions = {
|
|
async signUp({ request, cookies }: TestActionEvent) {
|
|
return runAuthAction(cookies, AUTH_TEST_ACTIONS.SIGN_UP, async () => {
|
|
const input = await readPasswordForm(request);
|
|
const result = await harness.engine.signUpPassword({
|
|
tenantId: AUTH_TEST_TENANT_ID,
|
|
identifier: input.identifier,
|
|
password: input.password,
|
|
profile: { displayName: input.displayName },
|
|
device: {
|
|
displayName: 'Test browser',
|
|
userAgentDisplay: 'SvelteKit action'
|
|
}
|
|
});
|
|
writeSessionCookie(cookies, result.session.sessionId);
|
|
return { current: result.current };
|
|
});
|
|
},
|
|
|
|
async signIn({ request, cookies }: TestActionEvent) {
|
|
return runAuthAction(cookies, AUTH_TEST_ACTIONS.SIGN_IN, async () => {
|
|
const input = await readPasswordForm(request);
|
|
const result = await harness.engine.signInPassword({
|
|
tenantId: AUTH_TEST_TENANT_ID,
|
|
identifier: input.identifier,
|
|
password: input.password,
|
|
device: {
|
|
displayName: 'Test browser',
|
|
userAgentDisplay: 'SvelteKit action'
|
|
}
|
|
});
|
|
writeSessionCookie(cookies, result.session.sessionId);
|
|
return { current: result.current };
|
|
});
|
|
},
|
|
|
|
async signOut({ cookies }: TestActionEvent) {
|
|
return runAuthAction(cookies, AUTH_TEST_ACTIONS.SIGN_OUT, async () => {
|
|
const sessionId = readSessionCookie(cookies);
|
|
const result = await harness.engine.signOut({
|
|
tenantId: AUTH_TEST_TENANT_ID,
|
|
sessionId
|
|
});
|
|
clearSessionCookie(cookies);
|
|
return { current: result.current, endedSessionIds: result.endedSessionIds };
|
|
});
|
|
},
|
|
|
|
async signOutGlobal({ cookies }: TestActionEvent) {
|
|
return runAuthAction(cookies, AUTH_TEST_ACTIONS.SIGN_OUT_GLOBAL, async () => {
|
|
const sessionId = readSessionCookie(cookies);
|
|
const result = await harness.engine.signOutGlobal({
|
|
tenantId: AUTH_TEST_TENANT_ID,
|
|
sessionId
|
|
});
|
|
clearSessionCookie(cookies);
|
|
return { current: result.current, endedSessionIds: result.endedSessionIds };
|
|
});
|
|
},
|
|
|
|
async csrfRoundtrip({ cookies }: TestActionEvent) {
|
|
return runAuthAction(cookies, AUTH_TEST_ACTIONS.CSRF_ROUNDTRIP, async () => {
|
|
const result = await harness.engine.issueCsrf({ tenantId: AUTH_TEST_TENANT_ID });
|
|
await harness.engine.verifyCsrf({
|
|
tenantId: AUTH_TEST_TENANT_ID,
|
|
token: result.token,
|
|
cookie: result.cookie.value
|
|
});
|
|
return {
|
|
csrf: {
|
|
ok: true,
|
|
expiresAt: result.expiresAt,
|
|
cookieName: AUTH_COOKIE_NAMES.CSRF
|
|
}
|
|
};
|
|
});
|
|
},
|
|
|
|
async csrfExpired({ cookies }: TestActionEvent) {
|
|
return runAuthAction(cookies, AUTH_TEST_ACTIONS.CSRF_EXPIRED, async () => {
|
|
const crypto = createDeterministicAuthCrypto('auth-test-page-expired-csrf');
|
|
const clock = createMemoryAuthClock(10_000);
|
|
const result = await issueAuthCsrf({
|
|
crypto,
|
|
clock,
|
|
tenantId: AUTH_TEST_TENANT_ID,
|
|
config: { signingKey: AUTH_TEST_CSRF_KEY }
|
|
});
|
|
clock.advance(AUTH_DEFAULTS.CSRF_TTL_MS + 1);
|
|
try {
|
|
await verifyAuthCsrf({
|
|
crypto,
|
|
clock,
|
|
tenantId: AUTH_TEST_TENANT_ID,
|
|
token: result.token,
|
|
cookie: result.cookie.value,
|
|
config: { signingKey: AUTH_TEST_CSRF_KEY }
|
|
});
|
|
return { csrf: { ok: false, reason: 'unexpected-valid-token' } };
|
|
} catch (error) {
|
|
return { csrf: { ok: true, rejected: errorName(error) } };
|
|
}
|
|
});
|
|
},
|
|
|
|
async reset({ cookies }: TestActionEvent) {
|
|
harness = createHarness();
|
|
clearSessionCookie(cookies);
|
|
return {
|
|
ok: true,
|
|
action: AUTH_TEST_ACTIONS.RESET,
|
|
message: 'Auth test harness reset.',
|
|
state: await snapshot()
|
|
};
|
|
}
|
|
};
|
|
|
|
interface TestCookies {
|
|
get(name: string): string | undefined;
|
|
set(name: string, value: string, options: TestCookieOptions): void;
|
|
delete(name: string, options: Pick<TestCookieOptions, 'path'>): void;
|
|
}
|
|
|
|
interface TestCookieOptions {
|
|
readonly path: string;
|
|
readonly httpOnly?: boolean;
|
|
readonly sameSite?: 'strict' | 'lax' | 'none';
|
|
readonly secure?: boolean;
|
|
readonly maxAge?: number;
|
|
}
|
|
|
|
interface TestActionEvent {
|
|
readonly request: Request;
|
|
readonly cookies: TestCookies;
|
|
}
|
|
|
|
interface PasswordFormInput {
|
|
readonly identifier: string;
|
|
readonly password: string;
|
|
readonly displayName: string;
|
|
}
|
|
|
|
function createHarness() {
|
|
const crypto = createDeterministicAuthCrypto('auth-test-page');
|
|
const clock = createMemoryAuthClock(Date.now());
|
|
const store = createMemoryAuthAdapter({ suppressProductionWarning: true });
|
|
const actors = createMemoryAuthActors(crypto);
|
|
const sess = createMemoryAuthSessPort({ crypto, clock });
|
|
const logger = createMemoryAuthLogr();
|
|
const cache = createMemoryAuthCache();
|
|
const mailer = createMemoryAuthMailer();
|
|
const engine = createEngineAuth({
|
|
security: { csrf: { signingKey: AUTH_TEST_CSRF_KEY } },
|
|
ports: {
|
|
store,
|
|
actors,
|
|
sess,
|
|
logger,
|
|
timer: clock,
|
|
crypto,
|
|
cache,
|
|
mailer,
|
|
passwordHasher: createTestPasswordHasher()
|
|
}
|
|
});
|
|
return { engine, store, actors, sess, logger, cache, mailer, clock };
|
|
}
|
|
|
|
async function snapshot(sessionId?: AuthSessionId) {
|
|
const current = await harness.engine.current({
|
|
tenantId: AUTH_TEST_TENANT_ID,
|
|
sessionId
|
|
});
|
|
const store = harness.store.snapshot();
|
|
return {
|
|
current,
|
|
counts: {
|
|
actors: harness.actors.snapshot().length,
|
|
credentials: store.credentials.length,
|
|
sessions: store.sessionBindings.length,
|
|
devices: store.devices.length,
|
|
flows: store.flows.length,
|
|
events: harness.logger.entries.length,
|
|
cacheInvalidations: harness.cache.invalidations.length,
|
|
mails: harness.mailer.messages.length
|
|
},
|
|
events: harness.logger.entries.slice(-10).map((entry) => ({
|
|
level: entry.level,
|
|
category: entry.category,
|
|
message: entry.message,
|
|
eventName: entry.eventName,
|
|
code: entry.code,
|
|
actorId: entry.actorRef?.actorId,
|
|
sessionId: entry.sessionId
|
|
})),
|
|
cacheInvalidations: harness.cache.invalidations.slice(-10),
|
|
devices: store.devices.slice(-10),
|
|
credentials: store.credentials.map((credential) => ({
|
|
id: credential.id,
|
|
kind: credential.kind,
|
|
identifierDisplay: credential.identifierDisplay,
|
|
actorId: credential.actorRef.actorId,
|
|
verifiedAt: credential.verifiedAt
|
|
}))
|
|
};
|
|
}
|
|
|
|
async function runAuthAction(
|
|
cookies: TestCookies,
|
|
action: string,
|
|
run: () => Promise<Record<string, unknown>>
|
|
) {
|
|
try {
|
|
const result = await run();
|
|
return {
|
|
ok: true,
|
|
action,
|
|
...result,
|
|
state: await snapshot(readSessionCookie(cookies))
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
ok: false,
|
|
action,
|
|
error: errorToPayload(error),
|
|
state: await snapshot(readSessionCookie(cookies))
|
|
};
|
|
}
|
|
}
|
|
|
|
async function readPasswordForm(request: Request): Promise<PasswordFormInput> {
|
|
const form = await request.formData();
|
|
return {
|
|
identifier: String(form.get(AUTH_TEST_FORM_FIELDS.IDENTIFIER) ?? ''),
|
|
password: String(form.get(AUTH_TEST_FORM_FIELDS.PASSWORD) ?? ''),
|
|
displayName: String(form.get(AUTH_TEST_FORM_FIELDS.DISPLAY_NAME) ?? '')
|
|
};
|
|
}
|
|
|
|
function readSessionCookie(cookies: TestCookies): AuthSessionId | undefined {
|
|
return cookies.get(AUTH_TEST_COOKIE_NAMES.SESSION) as AuthSessionId | undefined;
|
|
}
|
|
|
|
function writeSessionCookie(cookies: TestCookies, sessionId: AuthSessionId | undefined): void {
|
|
if (!sessionId) return;
|
|
cookies.set(AUTH_TEST_COOKIE_NAMES.SESSION, sessionId, {
|
|
path: '/test/auth',
|
|
httpOnly: true,
|
|
sameSite: 'lax',
|
|
secure: false,
|
|
maxAge: AUTH_TEST_SESSION_COOKIE_MAX_AGE
|
|
});
|
|
}
|
|
|
|
function clearSessionCookie(cookies: TestCookies): void {
|
|
cookies.delete(AUTH_TEST_COOKIE_NAMES.SESSION, { path: '/test/auth' });
|
|
}
|
|
|
|
function errorToPayload(error: unknown) {
|
|
return {
|
|
name: errorName(error),
|
|
message:
|
|
error instanceof Error
|
|
? error.message
|
|
: typeof error === 'string'
|
|
? error
|
|
: 'Unknown auth error',
|
|
code: typeof error === 'object' && error && 'code' in error ? String(error.code) : undefined
|
|
};
|
|
}
|
|
|
|
function errorName(error: unknown): string {
|
|
return error instanceof Error ? error.name : typeof error;
|
|
}
|