Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions lib/seat-device/contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
readSeatDeviceTrustedProxyHops,
resolveSeatDeviceTrustedSource,
seatDeviceCredentialExpiresAt,
seatDeviceEntryLinks,
seatDeviceHashesEqual,
seatDeviceIdentity,
seatDevicePreviousAccessExpiresAt,
Expand Down Expand Up @@ -249,6 +250,26 @@ describe("seat device request validation", () => {
expect(parseSeatDeviceRevokeRequest({ refreshToken, reason: "device_logout", extra: 1 })).toEqual({ ok: false, code: "invalid_input" });
});

it("builds entry links from the given origin only; activation follows its own switch; no sign-up link", () => {
const origin = "https://seat.example.test";
expect(seatDeviceEntryLinks(origin, {})).toEqual({ signIn: `${origin}/login`, activateMember: null, signUp: null });
expect(seatDeviceEntryLinks(origin, { HELM_AUTH_MEMBER_ACTIVATION_ENABLED: "true" })).toEqual({
signIn: `${origin}/login`,
activateMember: `${origin}/activate-member`,
signUp: null,
});
// Only the exact "true" opens the activation page (it answers 404 otherwise).
for (const value of ["false", "TRUE", "1", "", undefined]) {
expect(seatDeviceEntryLinks(origin, { HELM_AUTH_MEMBER_ACTIVATION_ENABLED: value }).activateMember).toBeNull();
}
// The origin passed in is the only source: APP_URL itself is not re-read here.
expect(seatDeviceEntryLinks(origin, { ...on, APP_URL: "https://other.example.test" })).toEqual({
signIn: `${origin}/login`,
activateMember: null,
signUp: null,
});
});

it("normalizes pairing codes in lookup and decision bodies", () => {
expect(parseSeatDeviceRequest(SEAT_DEVICE_LOOKUP_REQUEST_SCHEMA, { pairingCode: "bcdf-ghjk" })).toEqual({ ok: true, value: { pairingCode: "BCDFGHJK" } });
expect(parseSeatDeviceRequest(SEAT_DEVICE_LOOKUP_REQUEST_SCHEMA, { pairingCode: "BCDF-GHJA" }).ok).toBe(false);
Expand Down
25 changes: 25 additions & 0 deletions lib/seat-device/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ export type SeatDeviceClientType = (typeof SEAT_DEVICE_CLIENT_TYPES)[number];
export const SEAT_DEVICE_PUBLIC_AUTH_BASE_PATH = "/api/public-auth/seat-device";
// The verification URL never carries the pairing code: the member types it.
export const SEAT_DEVICE_VERIFICATION_PATH = "/device";
// Existing Core pages the client may point a member to (server design §8).
export const SEAT_DEVICE_SIGN_IN_PATH = "/login";
export const SEAT_DEVICE_ACTIVATE_MEMBER_PATH = "/activate-member";

export const SEAT_DEVICE_REQUEST_TTL_SECONDS = 600;
export const SEAT_DEVICE_POLL_INTERVAL_SECONDS = 5;
Expand Down Expand Up @@ -242,6 +245,28 @@ export function isSeatDeviceWorkspaceEnabled(
}
}

export type SeatDeviceEntryLinks = {
signIn: string;
activateMember: string | null;
signUp: string | null;
};

// Entry links in the authorization answer (server design §8.3), for the
// client's guidance text. `origin` is the already resolved APP_URL origin
// (https outside local development), never anything from the request.
// activateMember is null while its page answers 404. signUp stays null:
// Core has no sign-up page and no sign-up address is configured.
export function seatDeviceEntryLinks(
origin: string,
env: Readonly<Record<string, string | undefined>> = process.env,
): SeatDeviceEntryLinks {
return {
signIn: `${origin}${SEAT_DEVICE_SIGN_IN_PATH}`,
activateMember: env.HELM_AUTH_MEMBER_ACTIVATION_ENABLED === "true" ? `${origin}${SEAT_DEVICE_ACTIVATE_MEMBER_PATH}` : null,
signUp: null,
};
}

// Number of proxies in front of the app that append to x-forwarded-for. Any
// value other than a small non-negative integer means 0: source untrusted.
export function readSeatDeviceTrustedProxyHops(
Expand Down
3 changes: 2 additions & 1 deletion lib/seat-device/device-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
parseSeatDeviceRevokeRequest,
readSeatDeviceTrustedProxyHops,
resolveSeatDeviceTrustedSource,
seatDeviceEntryLinks,
type SeatDeviceErrorCode,
} from "@/lib/seat-device/contract";
import {
Expand Down Expand Up @@ -118,7 +119,7 @@ const OPERATIONS: Record<SeatDeviceOperation, (request: Request) => Promise<Resp
const input = rejectOnParse(parseSeatDeviceAuthorizationRequest(await readJson(request)));
const grant = await requestSeatDeviceAuthorization({ ...input, source: trustedSource(request) });
// The member types the pairing code; the URL never carries it.
return ok({ ...grant, verificationUrl: `${origin}${SEAT_DEVICE_VERIFICATION_PATH}` });
return ok({ ...grant, verificationUrl: `${origin}${SEAT_DEVICE_VERIFICATION_PATH}`, entryLinks: seatDeviceEntryLinks(origin) });
},
async cancel(request) {
const input = rejectOnParse(parseSeatDeviceRequest(SEAT_DEVICE_POLL_REQUEST_SCHEMA, await readJson(request)));
Expand Down
28 changes: 24 additions & 4 deletions lib/seat-device/seat-device-routes.mysql.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// The five D1 device endpoints (design v0.3 §4.2, §15) through their real
// route handlers and the real store on an isolated MySQL database: answer
// shapes, the code map with its HTTP statuses, no-store on every answer,
// Retry-After on every 429 and 503, the verification URL (APP_URL only, no
// Retry-After on every 429 and 503, the verification URL and entry links (APP_URL only, no
// code), and a log probe that no token or pairing code reaches a log line.
//
// SEAT_DEVICE_DATABASE_URL=<disposable db> DATABASE_URL=<same>
Expand Down Expand Up @@ -160,7 +160,7 @@ describeMysql("seat device endpoints on MySQL", () => {
async function authorize(source = freshSource()) {
const answer = await post("authorize", DEVICE, source);
expect(answer.status).toBe(200);
return { grant: answer.body as Grant & { verificationUrl: string }, source };
return { grant: answer.body as Grant & { verificationUrl: string; entryLinks: unknown }, source };
}

// A claimed device of a fresh member, from a trusted source.
Expand All @@ -185,6 +185,8 @@ describeMysql("seat device endpoints on MySQL", () => {
vi.stubEnv("HELM_SEAT_DEVICE_AUTH_ENABLED", "true");
vi.stubEnv("HELM_SEAT_DEVICE_TRUSTED_PROXY_HOPS", "1");
vi.stubEnv("APP_URL", APP_ORIGIN);
// The activation page (and so its entry link) is off unless a test opens it.
vi.stubEnv("HELM_AUTH_MEMBER_ACTIVATION_ENABLED", undefined);
for (const method of CONSOLE_METHODS) {
const original = console[method].bind(console);
vi.spyOn(console, method).mockImplementation((...args: unknown[]) => {
Expand All @@ -209,9 +211,11 @@ describeMysql("seat device endpoints on MySQL", () => {
it("authorizes with a URL built from APP_URL only, never carrying the code, and keeps the trusted source", async () => {
nextDay();
const { grant, source } = await authorize();
expect(Object.keys(grant).sort()).toEqual(["expiresInSeconds", "intervalSeconds", "pairingCode", "pollToken", "requestId", "verificationUrl"]);
expect(Object.keys(grant).sort()).toEqual(["entryLinks", "expiresInSeconds", "intervalSeconds", "pairingCode", "pollToken", "requestId", "verificationUrl"]);
expect(grant).toMatchObject({ expiresInSeconds: 600, intervalSeconds: 5 });
expect(grant.verificationUrl).toBe(`${APP_ORIGIN}/device`);
// post() sends a hostile Host, x-forwarded-host and an http forwarded proto.
expect(grant.entryLinks).toEqual({ signIn: `${APP_ORIGIN}/login`, activateMember: null, signUp: null });
const url = new URL(grant.verificationUrl);
expect(url.search + url.hash).toBe("");
expect(grant.verificationUrl).not.toContain(grant.pairingCode.replace("-", ""));
Expand All @@ -222,13 +226,29 @@ describeMysql("seat device endpoints on MySQL", () => {
expect(row.pollTokenHash).toBe(hashSeatDeviceToken(grant.pollToken));
});

it("offers the activation link only while the activation page is on, and takes no address from the client", async () => {
nextDay();
vi.stubEnv("HELM_AUTH_MEMBER_ACTIVATION_ENABLED", "true");
try {
const { grant } = await authorize();
expect(grant.entryLinks).toEqual({ signIn: `${APP_ORIGIN}/login`, activateMember: `${APP_ORIGIN}/activate-member`, signUp: null });
} finally {
vi.stubEnv("HELM_AUTH_MEMBER_ACTIVATION_ENABLED", undefined);
}
// A client cannot steer the links: any address field is an unknown field.
for (const extra of [{ entryLinks: { signUp: "https://phish.example" } }, { signUpUrl: "https://phish.example" }, { verificationUrl: "https://phish.example/device" }]) {
expect(only(await post("authorize", { ...DEVICE, ...extra }, freshSource()))).toEqual({ status: 400, body: errorOf("invalid_input") });
}
});

it("resolves the public origin before storing anything", async () => {
nextDay();
const before = await db.seatDeviceAuthorization.count();
vi.stubEnv("NODE_ENV", "production");
vi.stubEnv("APP_URL", "http://plain-http.example.test");
try {
expect(await post("authorize", DEVICE, freshSource())).toMatchObject({ status: 503, body: errorOf("temporarily_unavailable") });
// Fail closed: no link of any kind leaves with a non-https origin.
expect(only(await post("authorize", DEVICE, freshSource()))).toEqual({ status: 503, body: errorOf("temporarily_unavailable") });
} finally {
vi.stubEnv("NODE_ENV", "test");
vi.stubEnv("APP_URL", APP_ORIGIN);
Expand Down
Loading