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
9 changes: 8 additions & 1 deletion services/w3ds-oidc-bridge/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import { createAuthorizeHandler } from "./oidc/authorize.js";
import { createDiscoveryHandler, createJwksHandler } from "./oidc/discovery.js";
import { createTokenHandler } from "./oidc/token.js";
import { createUserinfoHandler } from "./oidc/userinfo.js";
import { createCallbackHandler, createEventsHandler } from "./w3ds/callback.js";
import {
createCallbackHandler,
createDeeplinkHandler,
createEventsHandler,
} from "./w3ds/callback.js";

export function createApp(ctx: BridgeContext): Express {
const app = express();
Expand Down Expand Up @@ -53,6 +57,9 @@ export function createApp(ctx: BridgeContext): Express {
app.options("/w3ds/callback", w3dsCors);
app.post("/w3ds/callback", w3dsCors, createCallbackHandler(ctx));
app.get("/w3ds/events/:session", w3dsCors, createEventsHandler(ctx));
// Same-phone sign-in: the wallet opens this in the browser instead of
// posting to /w3ds/callback. A top-level navigation, so no CORS.
app.get("/deeplink-login", createDeeplinkHandler(ctx));

return app;
}
77 changes: 77 additions & 0 deletions services/w3ds-oidc-bridge/src/oidc/flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -757,3 +757,80 @@ describe("userinfo", () => {
expect(response.status).toBe(401);
});
});

describe("same-phone sign-in", () => {
async function openSession() {
const page = await fetch(authorizeUrl(bridge.url));
return sessionFromQrPage(await page.text());
}

function deeplink(fields: Record<string, string>) {
const url = new URL("/deeplink-login", bridge.url);
for (const [key, value] of Object.entries(fields)) {
url.searchParams.set(key, value);
}
return fetch(url, { redirect: "manual" });
}

it("is served where the wallet derives it: the origin root", async () => {
// The wallet builds `new URL("/deeplink-login", redirect)`, which
// drops any path the bridge is mounted under.
const session = await openSession();
const response = await deeplink({
ename: "@alice",
session,
signature: "sig",
appVersion: "0.4.0",
});
expect(response.status).toBe(303);
const location = new URL(response.headers.get("location") ?? "");
expect(`${location.origin}${location.pathname}`).toBe(REDIRECT_URI);
const code = location.searchParams.get("code");
expect(code).toBeTruthy();

const token = await tokenRequest({
code: code ?? "",
redirect_uri: REDIRECT_URI,
code_verifier: CODE_VERIFIER,
});
expect(token.status).toBe(200);
});

it("tells the waiting login page too", async () => {
const session = await openSession();
const events = bridge.watch(session);
await deeplink({
ename: "@alice",
session,
signature: "sig",
appVersion: "0.4.0",
});
expect(events.events[0]?.type).toBe("redirect");
});

it("shows a page, not a redirect, when the signature is wrong", async () => {
bridge.setVerifyResult({ valid: false });
const session = await openSession();
const response = await deeplink({
ename: "@alice",
session,
signature: "forged",
appVersion: "0.4.0",
});
expect(response.status).toBe(401);
expect(response.headers.get("location")).toBeNull();
expect(await response.text()).toContain("could not be verified");
});

it("cannot reuse a session", async () => {
const session = await openSession();
const fields = {
ename: "@alice",
session,
signature: "sig",
appVersion: "0.4.0",
};
expect((await deeplink(fields)).status).toBe(303);
expect((await deeplink(fields)).status).toBe(400);
});
});
230 changes: 140 additions & 90 deletions services/w3ds-oidc-bridge/src/w3ds/callback.ts
Original file line number Diff line number Diff line change
@@ -1,119 +1,169 @@
import { randomBytes } from "node:crypto";
import type { RequestHandler } from "express";
import type { BridgeContext } from "../context.js";
import { renderErrorPage } from "../oidc/pages.js";
import { isWalletVersionAtLeast } from "./wallet-version.js";

/** 256 bits of CSPRNG, url-safe. The code is a bearer credential for 60 seconds. */
function mintCode(): string {
return randomBytes(32).toString("base64url");
}

type WalletLogin =
| { ok: true; redirect: string }
| { ok: false; status: number; error: string; message: string };

/**
* Where the wallet POSTs after the person approves.
* Verifies a wallet's signed session and mints the authorization code.
*
* Every failure past this point is also pushed into the SSE stream. The browser
* is sitting in front of a QR code on possibly another device; the wallet
* reported the problem to us, and this is the only channel back.
* Every failure is also pushed into the SSE stream. The browser is sitting in
* front of a QR code on possibly another device; the wallet reported the
* problem to us, and this is the only channel back.
*/
export function createCallbackHandler(ctx: BridgeContext): RequestHandler {
return async (req, res) => {
const body = (req.body ?? {}) as Record<string, unknown>;
const field = (name: string): string | undefined =>
typeof body[name] === "string" && body[name]
? (body[name] as string)
: undefined;

// The protocol documentation says `w3id`; every platform controller reads
// `ename`. Accept both, the way awareness-service does.
const ename = field("ename") ?? field("w3id");
const session = field("session");
const signature = field("signature");

// Without a session id there is nobody to tell, so this one is HTTP only.
if (!session) {
res.status(400).json({ error: "session is required" });
return;
}
async function completeWalletLogin(
ctx: BridgeContext,
field: (name: string) => string | undefined,
): Promise<WalletLogin> {
const ename = field("ename") ?? field("w3id");
const session = field("session");
const signature = field("signature");

const reject = (status: number, error: string, message: string) => {
ctx.streams.publish(session, { type: "error", message });
res.status(status).json({ error, message });
if (!session) {
return {
ok: false,
status: 400,
error: "session is required",
message: "The wallet did not send a sign-in request.",
};
}

if (!ename) {
return reject(
400,
"ename is required",
"The wallet did not send an identity.",
);
}
if (!signature) {
return reject(
400,
"signature is required",
"The wallet did not send a signature.",
);
}
const reject = (
status: number,
error: string,
message: string,
): WalletLogin => {
ctx.streams.publish(session, { type: "error", message });
return { ok: false, status, error, message };
};

if (
!isWalletVersionAtLeast(
field("appVersion"),
ctx.config.minWalletVersion,
)
) {
return reject(
400,
"App version too old",
`Your eID Wallet is out of date. Update to ${ctx.config.minWalletVersion} or later and try again.`,
);
}
if (!ename) {
return reject(
400,
"ename is required",
"The wallet did not send an identity.",
);
}
if (!signature) {
return reject(
400,
"signature is required",
"The wallet did not send a signature.",
);
}

// Consumed here, so a replayed signature finds no session to attach to.
const pending = ctx.store.sessions.take(session);
if (!pending) {
return reject(
400,
"unknown or expired session",
"This sign-in request has expired. Go back to GitW3 and start again.",
);
}
if (
!isWalletVersionAtLeast(
field("appVersion"),
ctx.config.minWalletVersion,
)
) {
return reject(
400,
"App version too old",
`Your eID Wallet is out of date. Update to ${ctx.config.minWalletVersion} or later and try again.`,
);
}

// The trust anchor: the wallet signed the session id, and the Registry
// holds the key that proves who signed it.
const verification = await ctx.verifyLogin({
ename,
session,
signature,
});
if (!verification.valid) {
return reject(
401,
"Invalid signature",
"Your wallet's signature could not be verified. Please try again.",
);
}
const pending = ctx.store.sessions.take(session);
if (!pending) {
return reject(
400,
"unknown or expired session",
"This sign-in request has expired. Go back to GitW3 and start again.",
);
}

const code = mintCode();
ctx.store.codes.set(code, {
clientId: pending.clientId,
redirectUri: pending.redirectUri,
codeChallenge: pending.codeChallenge,
nonce: pending.nonce,
ename,
});
const verification = await ctx.verifyLogin({
ename,
session,
signature,
});
if (!verification.valid) {
return reject(
401,
"Invalid signature",
"Your wallet's signature could not be verified. Please try again.",
);
}

const redirect = new URL(pending.redirectUri);
redirect.searchParams.set("code", code);
if (pending.state) redirect.searchParams.set("state", pending.state);
const code = mintCode();
ctx.store.codes.set(code, {
clientId: pending.clientId,
redirectUri: pending.redirectUri,
codeChallenge: pending.codeChallenge,
nonce: pending.nonce,
ename,
});

ctx.streams.publish(session, {
type: "redirect",
url: redirect.toString(),
});
const redirect = new URL(pending.redirectUri);
redirect.searchParams.set("code", code);
if (pending.state) redirect.searchParams.set("state", pending.state);

ctx.streams.publish(session, {
type: "redirect",
url: redirect.toString(),
});
return { ok: true, redirect: redirect.toString() };
}

function stringField(source: Record<string, unknown>) {
return (name: string): string | undefined =>
typeof source[name] === "string" && source[name]
? (source[name] as string)
: undefined;
}

/** Where the wallet POSTs after the person approves (QR code flow). */
export function createCallbackHandler(ctx: BridgeContext): RequestHandler {
return async (req, res) => {
const body = (req.body ?? {}) as Record<string, unknown>;
const result = await completeWalletLogin(ctx, stringField(body));
if (!result.ok) {
res.status(result.status).json({
error: result.error,
message: result.message,
});
return;
}
res.status(200).json({ ok: true });
};
}

/**
* Where the wallet sends the phone's browser when sign-in started on the same
* phone. The wallet builds this URL as `new URL("/deeplink-login", redirect)`,
* so it lives at the origin root whatever path the bridge is mounted under.
*
* On success the browser goes straight to the client's callback with the
* code. The waiting login page is told too, which covers a wallet that opened
* a different browser from the one the sign-in started in.
*/
export function createDeeplinkHandler(ctx: BridgeContext): RequestHandler {
return async (req, res) => {
const query = req.query as Record<string, unknown>;
const result = await completeWalletLogin(ctx, stringField(query));
res.set("Cache-Control", "no-store");
res.set("Referrer-Policy", "no-referrer");
if (!result.ok) {
res.status(result.status)
.type("html")
.send(renderErrorPage("Sign-in failed", result.message));
return;
}
res.redirect(303, result.redirect);
};
}

export function createEventsHandler(ctx: BridgeContext): RequestHandler {
return (req, res) => {
const session = req.params.session;
Expand Down
Loading