From 728e4ac3d3778f2460733ca51a859bb803d0cc25 Mon Sep 17 00:00:00 2001 From: Jack Carter <128555021+SunsetDrifter@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:39:06 +0200 Subject: [PATCH 1/4] fix: cap honored Retry-After and decompose client retry loop (#17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bound total call time against a hostile/misbehaving upstream: every honored delay is clamped to MAX_BACKOFF_MS, so a large Retry-After can no longer stall a request past NETBIRD_TIMEOUT_MS. Decompose request into fetchOnce/decideRetry/backoff — one retry decision, explicit throw-on-exhausted-retries, and the redundant catch-block clearTimeout removed. Test drives a 429 with Retry-After: 86400 and asserts the cap. --- src/netbird/client.ts | 183 ++++++++++++++++++++++++++++-------------- test/client.test.ts | 54 ++++++++++++- 2 files changed, 177 insertions(+), 60 deletions(-) diff --git a/src/netbird/client.ts b/src/netbird/client.ts index 8210a79..16011a5 100644 --- a/src/netbird/client.ts +++ b/src/netbird/client.ts @@ -35,6 +35,27 @@ interface RequestOptions { /** Cheap authenticated read used to check whether a token can authenticate. */ const USERS_PATH = "/api/users"; +/** + * Upper bound on any single backoff sleep. The backoff runs OUTSIDE the + * per-request AbortController/timeout guard, so an allowlisted-but-hostile + * upstream could otherwise answer 429/5xx with an enormous `Retry-After` + * (e.g. 24h) and hang the call far past NETBIRD_TIMEOUT_MS. Clamping every + * honored delay to this cap keeps total call time bounded (<= maxRetries * + * MAX_BACKOFF_MS) regardless of what the upstream sends. + */ +export const MAX_BACKOFF_MS = 30_000; + +/** One fetch attempt's result: either the HTTP response, or the thrown transport error. */ +type FetchOutcome = + | { readonly kind: "response"; readonly res: Response } + | { readonly kind: "error"; readonly error: unknown }; + +/** What to do after classifying an attempt's outcome. */ +type RetryDecision = + | { readonly action: "retry" } + | { readonly action: "return"; readonly value: T } + | { readonly action: "throw"; readonly error: NetBirdApiError }; + /** Outcome of a token-verification call: valid, rejected, or indeterminate. */ export type TokenVerification = "ok" | "invalid" | "unknown"; @@ -102,75 +123,119 @@ export class NetBirdClient { const url = this.buildUrl(path, options.query); let attempt = 0; - // Retry loop: 429 and 5xx are retried with backoff; other errors bubble up. + // Retry loop: 429/5xx and transport errors are retried with backoff. Each + // iteration's outcome is classified in one place (decideRetry), which owns + // the delay/log/sleep and makes the terminal "throw once exhausted" + // explicit rather than a fall-through. while (true) { await this.opts.rateLimiter.acquire(); - - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), this.opts.timeoutMs); - let res: Response; - try { - res = await this.fetchImpl(url, { - method, - headers: { - Authorization: `Token ${this.opts.auth.token}`, - Accept: "application/json", - ...(options.body !== undefined ? { "Content-Type": "application/json" } : {}), - }, - body: options.body !== undefined ? JSON.stringify(options.body) : undefined, - signal: controller.signal, - }); - } catch (err) { - clearTimeout(timeout); - if (attempt < this.maxRetries) { - const delay = backoffMs(attempt); - this.opts.logger.warn("netbird request failed, retrying", { - method, - path, - attempt, - delay, - }); - await sleep(delay); + const outcome = await this.fetchOnce(url, method, options); + const decision = await this.decideRetry(outcome, attempt, method, path); + switch (decision.action) { + case "retry": attempt++; continue; - } - throw new NetBirdApiError( - `Network error calling NetBird ${method} ${path}: ${(err as Error).message}`, - 0, - ); - } finally { - clearTimeout(timeout); + case "return": + return decision.value; + case "throw": + throw decision.error; } + } + } - if (res.status === 429 || res.status >= 500) { - if (attempt < this.maxRetries) { - const retryAfter = parseRetryAfter(res.headers.get("retry-after")); - const delay = retryAfter ?? backoffMs(attempt); - this.opts.logger.warn("netbird throttled/5xx, backing off", { - method, - path, - status: res.status, - attempt, - delay, - }); - await sleep(delay); - attempt++; - continue; - } - } + /** + * Perform exactly one fetch under the request timeout, surfacing the outcome + * as data. The AbortController/timeout is always cleared in `finally` (no + * duplicate clear on the error path). + */ + private async fetchOnce( + url: string, + method: string, + options: RequestOptions, + ): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.opts.timeoutMs); + try { + const res = await this.fetchImpl(url, { + method, + headers: { + Authorization: `Token ${this.opts.auth.token}`, + Accept: "application/json", + ...(options.body !== undefined ? { "Content-Type": "application/json" } : {}), + }, + body: options.body !== undefined ? JSON.stringify(options.body) : undefined, + signal: controller.signal, + }); + return { kind: "response", res }; + } catch (err) { + return { kind: "error", error: err }; + } finally { + clearTimeout(timeout); + } + } - const text = await res.text(); - const parsed = text ? safeJson(text) : undefined; + /** + * Classify one attempt's outcome into retry | return | throw. Retryable + * outcomes (transport error, or 429/5xx) still within the retry budget own + * the whole backoff step here — capped-delay computation, logging, and the + * sleep — and return "retry". Everything else is terminal and explicit. + */ + private async decideRetry( + outcome: FetchOutcome, + attempt: number, + method: string, + path: string, + ): Promise> { + const canRetry = attempt < this.maxRetries; - if (!res.ok) { - throw new NetBirdApiError( - `NetBird ${method} ${path} failed with ${res.status}`, - res.status, - parsed ?? text, - ); + if (outcome.kind === "error") { + if (canRetry) { + await this.backoff(backoffMs(attempt), "netbird request failed, retrying", { + method, + path, + attempt, + }); + return { action: "retry" }; } - return parsed as T; + const msg = `Network error calling NetBird ${method} ${path}: ${(outcome.error as Error).message}`; + return { action: "throw", error: new NetBirdApiError(msg, 0) }; } + + const { res } = outcome; + if ((res.status === 429 || res.status >= 500) && canRetry) { + const retryAfter = parseRetryAfter(res.headers.get("retry-after")); + // Clamp the honored delay: never trust an upstream Retry-After beyond the cap. + const delay = Math.min(retryAfter ?? backoffMs(attempt), MAX_BACKOFF_MS); + await this.backoff(delay, "netbird throttled/5xx, backing off", { + method, + path, + status: res.status, + attempt, + }); + return { action: "retry" }; + } + + const text = await res.text(); + const parsed = text ? safeJson(text) : undefined; + if (!res.ok) { + const error = new NetBirdApiError( + `NetBird ${method} ${path} failed with ${res.status}`, + res.status, + parsed ?? text, + ); + return { action: "throw", error }; + } + return { action: "return", value: parsed as T }; + } + + /** Single place that logs a pending retry and sleeps for the (already capped) delay. */ + private async backoff( + delay: number, + message: string, + context: Record, + ): Promise { + this.opts.logger.warn(message, { ...context, delay }); + await sleep(delay); } } diff --git a/test/client.test.ts b/test/client.test.ts index 14b7c3f..fc3a47d 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi } from "vitest"; -import { NetBirdClient, NetBirdApiError } from "../src/netbird/client.js"; +import { NetBirdClient, NetBirdApiError, MAX_BACKOFF_MS } from "../src/netbird/client.js"; import { RateLimiter } from "../src/netbird/rateLimiter.js"; +import type { Logger } from "../src/logger.js"; const silentLogger = { debug: () => {}, @@ -68,6 +69,57 @@ describe("NetBirdClient", () => { expect(calls).toBe(2); }); + it("caps an oversized Retry-After so a hostile upstream can't stall the call", async () => { + // A compromised-but-allowlisted upstream could answer 429 with a huge + // Retry-After (e.g. 24h) and hang the call far past NETBIRD_TIMEOUT_MS, + // since the backoff sleep runs outside the request timeout guard. The cap + // must clamp the honored delay to MAX_BACKOFF_MS. Fake timers let us drive + // the (clamped) sleep instantly and inspect the delay the client chose. + vi.useFakeTimers(); + try { + let calls = 0; + const warnDelays: number[] = []; + const capturingLogger: Logger = { + debug: () => {}, + info: () => {}, + warn: (_msg, meta) => { + const delay = meta?.delay; + if (typeof delay === "number") warnDelays.push(delay); + }, + error: () => {}, + }; + const fetchImpl = vi.fn(async () => { + calls++; + if (calls === 1) { + // 86400s == 24h — three-plus orders of magnitude past any sane cap. + return new Response("", { status: 429, headers: { "retry-after": "86400" } }); + } + return jsonResponse({ ok: true }); + }) as unknown as typeof fetch; + + const client = new NetBirdClient({ + auth: { token: "test-pat", baseUrl: "https://api.netbird.io" }, + logger: capturingLogger, + rateLimiter: new RateLimiter(1000), + timeoutMs: 5000, + fetchImpl, + maxRetries: 4, + }); + + const pending = client.get("/api/peers"); + await vi.runAllTimersAsync(); + const result = await pending; + + expect(result).toEqual({ ok: true }); + expect(calls).toBe(2); + // The honored delay must be clamped to the cap, not the 86_400_000ms sent. + expect(warnDelays[0]).toBe(MAX_BACKOFF_MS); + expect(warnDelays[0]).toBeLessThan(86_400_000); + } finally { + vi.useRealTimers(); + } + }); + it("throws NetBirdApiError on 4xx (non-429)", async () => { const fetchImpl = vi.fn(async () => jsonResponse({ message: "not found" }, { status: 404 }), From 2de9404d9f77929d7377d0d721d8ab03b45fa242 Mon Sep 17 00:00:00 2001 From: Jack Carter <128555021+SunsetDrifter@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:39:06 +0200 Subject: [PATCH 2/4] fix: reject scope widening on OAuth refresh grant (#18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit exchangeRefreshToken now rejects any requested scope not in the originally granted set (InvalidScopeError), per RFC 6749 §6; no-scope and subset refreshes still succeed. exchangeAuthorizationCode is unchanged (its scopes come from the stored code). Tests cover all three. --- src/oauth/core.ts | 25 +++++++++++++++++-- test/oauthCore.test.ts | 56 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 78 insertions(+), 3 deletions(-) diff --git a/src/oauth/core.ts b/src/oauth/core.ts index 4223ffc..9adbf23 100644 --- a/src/oauth/core.ts +++ b/src/oauth/core.ts @@ -1,7 +1,11 @@ import { createHash, timingSafeEqual } from "node:crypto"; import type { OAuthClientInformationFull, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; -import { InvalidGrantError, InvalidTokenError } from "@modelcontextprotocol/sdk/server/auth/errors.js"; +import { + InvalidGrantError, + InvalidScopeError, + InvalidTokenError, +} from "@modelcontextprotocol/sdk/server/auth/errors.js"; import { normalizeBaseUrl } from "../config.js"; import type { Logger } from "../logger.js"; import { AuthContext, AuthError } from "../auth/context.js"; @@ -282,7 +286,7 @@ export class OAuthCore { if (!rec || rec.clientId !== clientId) { throw new InvalidGrantError("unknown refresh token"); } - const grantedScopes = scopes && scopes.length ? scopes : rec.scopes; + const grantedScopes = narrowRefreshScopes(scopes, rec.scopes); const { accessToken, refreshToken: newRefresh } = this.store.issueTokens( { netbirdToken: rec.netbirdToken, baseUrl: rec.baseUrl }, clientId, @@ -361,6 +365,23 @@ export class OAuthCore { } } +/** + * RFC 6749 §6: a refresh request may narrow scope but MUST NOT widen it. An + * omitted or empty requested set is treated as the full originally granted set; + * any requested scope outside `granted` is a widening attempt and is rejected + * with invalid_scope rather than silently honoured. + */ +function narrowRefreshScopes(requested: string[] | undefined, granted: string[]): string[] { + if (!requested || requested.length === 0) return granted; + const widened = requested.filter((scope) => !granted.includes(scope)); + if (widened.length) { + throw new InvalidScopeError( + `refresh cannot widen scope; not originally granted: ${widened.join(" ")}`, + ); + } + return requested; +} + /** * Recompute the S256 code challenge from the supplied verifier and compare it, * in constant time, to the challenge bound to the authorization code. A missing diff --git a/test/oauthCore.test.ts b/test/oauthCore.test.ts index 3cf89c9..35b264a 100644 --- a/test/oauthCore.test.ts +++ b/test/oauthCore.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi } from "vitest"; import { OAuthCore, type OAuthCoreOptions } from "../src/oauth/core.js"; import { DEFAULT_MAX_REQUESTS_PER_MINUTE, DEFAULT_REQUEST_TIMEOUT_MS } from "../src/config.js"; import { renderLoginPage, type LoginPageParams } from "../src/oauth/loginPage.js"; -import { InvalidGrantError } from "@modelcontextprotocol/sdk/server/auth/errors.js"; +import { InvalidGrantError, InvalidScopeError } from "@modelcontextprotocol/sdk/server/auth/errors.js"; import type { OAuthClientInformationFull } from "@modelcontextprotocol/sdk/shared/auth.js"; import { silentLogger, pkcePair, TEST_ALLOWED_API_HOSTS } from "./helpers.js"; @@ -321,6 +321,60 @@ describe("OAuthCore.exchangeAuthorizationCode — PKCE re-verification (defence }); }); +describe("OAuthCore.exchangeRefreshToken — scope must not widen (RFC 6749 §6)", () => { + async function mintRefreshToken( + core: OAuthCore, + client: OAuthClientInformationFull, + grantedScope: string, + ): Promise { + const { verifier, challenge } = pkcePair(); + const decision = await core.completeLogin({ + clientId: client.client_id, + redirectUri: client.redirect_uris[0], + codeChallenge: challenge, + scope: grantedScope, + netbirdToken: "pat-abc", + netbirdApiUrl: "https://self.hosted", + }); + const code = new URL((decision as { location: string }).location).searchParams.get("code")!; + const tokens = core.exchangeAuthorizationCode( + client.client_id, + code, + verifier, + client.redirect_uris[0], + ); + return tokens.refresh_token!; + } + + it("rejects a refresh that requests a scope outside the originally granted set", async () => { + const core = newCore(); + const client = registerClient(core); + const refreshToken = await mintRefreshToken(core, client, "netbird"); + + expect(() => + core.exchangeRefreshToken(client.client_id, refreshToken, ["netbird", "admin"]), + ).toThrow(InvalidScopeError); + }); + + it("preserves the granted scopes when the refresh requests no scope", async () => { + const core = newCore(); + const client = registerClient(core); + const refreshToken = await mintRefreshToken(core, client, "netbird read"); + + const tokens = core.exchangeRefreshToken(client.client_id, refreshToken); + expect(tokens.scope).toBe("netbird read"); + }); + + it("accepts a subset refresh and narrows to exactly the requested subset", async () => { + const core = newCore(); + const client = registerClient(core); + const refreshToken = await mintRefreshToken(core, client, "netbird read"); + + const tokens = core.exchangeRefreshToken(client.client_id, refreshToken, ["read"]); + expect(tokens.scope).toBe("read"); + }); +}); + describe("login page XSS escaping (via the core's decision output)", () => { it("escapes a hostile state value reflected in a login challenge", () => { const core = newCore(); From f31c1d7fa6ad274a065a1b754aeae831b7b78659 Mon Sep 17 00:00:00 2001 From: Jack Carter <128555021+SunsetDrifter@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:39:06 +0200 Subject: [PATCH 3/4] refactor: quarantine the tool-registration cast in one primitive (#19) Single private register() owns the guard wrap and the lone 'as unknown as ToolCallback' cast; registerRead/Mutation/Delete become cast-free config builders. Behavior unchanged. --- src/tools/registry.ts | 132 ++++++++++++++++++++++++++---------------- 1 file changed, 81 insertions(+), 51 deletions(-) diff --git a/src/tools/registry.ts b/src/tools/registry.ts index 4023be2..e9808d7 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import type { McpServer, ToolCallback } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { ZodRawShapeCompat, ShapeOutput } from "@modelcontextprotocol/sdk/server/zod-compat.js"; -import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import type { CallToolResult, ToolAnnotations } from "@modelcontextprotocol/sdk/types.js"; import type { NetBirdClient } from "../netbird/client.js"; import { NetBirdApiError } from "../netbird/client.js"; import type { ServerConfig } from "../config.js"; @@ -158,28 +158,64 @@ function withConfirmField( return { ...inputSchema, confirm: confirmSchema }; } -/** Register a read-only tool: a straight GET, optionally with query params or a response transform. */ -export function registerRead>( +/** + * Everything the shared primitive needs to register one tool: the advertised + * metadata/schema and a plain handler. The handler returns the tool result + * directly — the primitive owns the guard wrap, so handlers stay guard-free. + */ +interface RegisterSpec { + name: string; + title: string; + description: string; + inputSchema: Args; + annotations: ToolAnnotations; + handle: (args: ShapeOutput) => Promise; +} + +/** + * The single primitive every public register* function funnels through. It owns + * the `guard` wrap (NetBird-error-to-tool-error translation) and the one + * boundary cast the SDK's generic callback type forces. Keeping both here means + * the public builders below are cast-free config assemblers, and the unsafe + * `as unknown as ToolCallback` lives in exactly one place. + */ +function register( server: McpServer, deps: ToolDeps, - manifest: ReadManifest, + spec: RegisterSpec, ): void { server.registerTool( - manifest.name, + spec.name, { - title: manifest.title, - description: manifest.description, - inputSchema: manifest.inputSchema ?? ({} as Args), - annotations: { readOnlyHint: true }, + title: spec.title, + description: spec.description, + inputSchema: spec.inputSchema, + annotations: spec.annotations, }, (async (args: ShapeOutput) => - guard(deps.logger, async () => { - const data = await deps.client.get(manifest.path(args), manifest.query?.(args)); - return ok(manifest.transformResponse ? manifest.transformResponse(data, args) : data); - })) as unknown as ToolCallback, + guard(deps.logger, () => spec.handle(args))) as unknown as ToolCallback, ); } +/** Register a read-only tool: a straight GET, optionally with query params or a response transform. */ +export function registerRead>( + server: McpServer, + deps: ToolDeps, + manifest: ReadManifest, +): void { + register(server, deps, { + name: manifest.name, + title: manifest.title, + description: manifest.description, + inputSchema: manifest.inputSchema ?? ({} as Args), + annotations: { readOnlyHint: true }, + handle: async (args) => { + const data = await deps.client.get(manifest.path(args), manifest.query?.(args)); + return ok(manifest.transformResponse ? manifest.transformResponse(data, args) : data); + }, + }); +} + /** * Register a mutating (create/update) tool. Owns the whole guardrail: no * confirm means a preview and zero API calls; confirm sends the body — with @@ -190,26 +226,23 @@ export function registerMutation( deps: ToolDeps, manifest: MutationManifest, ): void { - server.registerTool( - manifest.name, - { - title: manifest.title, - description: manifest.description, - inputSchema: withConfirmField(manifest.inputSchema, MUTATION_CONFIRM), - annotations: { readOnlyHint: false, destructiveHint: false }, + register(server, deps, { + name: manifest.name, + title: manifest.title, + description: manifest.description, + inputSchema: withConfirmField(manifest.inputSchema, MUTATION_CONFIRM), + annotations: { readOnlyHint: false, destructiveHint: false }, + handle: async (args) => { + const body = stripUndefined(manifest.buildBody(args)); + if (!isConfirmed(args)) return preview(manifest.previewAction(args), body); + const path = manifest.path(args); + const response = + manifest.method === "POST" + ? await deps.client.post(path, body) + : await deps.client.put(path, body); + return ok(response); }, - (async (args: ShapeOutput) => - guard(deps.logger, async () => { - const body = stripUndefined(manifest.buildBody(args)); - if (!isConfirmed(args)) return preview(manifest.previewAction(args), body); - const path = manifest.path(args); - const response = - manifest.method === "POST" - ? await deps.client.post(path, body) - : await deps.client.put(path, body); - return ok(response); - })) as unknown as ToolCallback, - ); + }); } /** @@ -228,24 +261,21 @@ export function registerDelete( const inputSchema = withConfirmField(manifest.inputSchema, DELETE_CONFIRM); if (!deps.config.enableDestructive) return; - server.registerTool( - manifest.name, - { - title: manifest.title, - description: manifest.description, - inputSchema, - annotations: { readOnlyHint: false, destructiveHint: true }, + register(server, deps, { + name: manifest.name, + title: manifest.title, + description: manifest.description, + inputSchema, + annotations: { readOnlyHint: false, destructiveHint: true }, + handle: async (args) => { + const idValue = (args as Record)[manifest.idField]; + if (!isConfirmed(args)) { + return preview(`Would DELETE ${manifest.label} ${idValue}.`, { + [manifest.idField]: idValue, + }); + } + await deps.client.delete(manifest.path(args)); + return ok({ status: "deleted", [manifest.idField]: idValue }); }, - (async (args: ShapeOutput) => - guard(deps.logger, async () => { - const idValue = (args as Record)[manifest.idField]; - if (!isConfirmed(args)) { - return preview(`Would DELETE ${manifest.label} ${idValue}.`, { - [manifest.idField]: idValue, - }); - } - await deps.client.delete(manifest.path(args)); - return ok({ status: "deleted", [manifest.idField]: idValue }); - })) as unknown as ToolCallback, - ); + }); } From 212a4e47ef37314daa3511e1384759292324ce8c Mon Sep 17 00:00:00 2001 From: Jack Carter <128555021+SunsetDrifter@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:39:06 +0200 Subject: [PATCH 4/4] refactor: pass login decision straight to renderLoginPage (#20) The decision object is already structurally LoginPageParams (OAuthChallenge/OAuthLoginError = { kind } & LoginPrefill), so drop the hand-rebuilt six-field prefill in sendPage. Behavior-preserving. --- src/oauth/provider.ts | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/src/oauth/provider.ts b/src/oauth/provider.ts index 1515f3b..e5cba01 100644 --- a/src/oauth/provider.ts +++ b/src/oauth/provider.ts @@ -17,7 +17,7 @@ import { type OAuthChallenge, type OAuthLoginError, } from "./core.js"; -import { renderLoginPage, type LoginPageParams } from "./loginPage.js"; +import { renderLoginPage } from "./loginPage.js"; export type ProviderOptions = OAuthCoreOptions; @@ -152,21 +152,19 @@ export class NetBirdOAuthProvider implements OAuthServerProvider { this.core.revoke(request.token, client.client_id); } - /** Renders whatever page a login-challenge or login-error decision calls for. */ + /** + * Renders whatever page a login-challenge or login-error decision calls for. + * The decision is already structurally a LoginPageParams (its challenge/error + * types are `{ kind } & LoginPrefill`, and LoginPrefill = LoginPageParams), so + * it is handed straight to the renderer — renderLoginPage reads only the + * prefill fields and ignores the discriminant. + */ private sendPage(res: Response, decision: OAuthChallenge | OAuthLoginError): void { - const prefill: LoginPageParams = { - clientId: decision.clientId, - redirectUri: decision.redirectUri, - state: decision.state, - codeChallenge: decision.codeChallenge, - scope: decision.scope, - resource: decision.resource, - }; res.setHeader("Content-Type", "text/html; charset=utf-8"); if (decision.kind === "error") { - res.status(400).send(renderLoginPage(prefill, decision.reason)); + res.status(400).send(renderLoginPage(decision, decision.reason)); return; } - res.send(renderLoginPage(prefill)); + res.send(renderLoginPage(decision)); } }