From 50f9d642f8698344f364a3135dc680000b31ed9b Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Sat, 17 Oct 2026 17:51:32 +0200 Subject: [PATCH 1/2] fix(auth): expose registered users to signing backend Allow the backend service token to read registered user identities for signer selection. The backend applies its access snapshot and drive rules to this directory. --- src/auth/access-snapshot.integration.test.ts | 24 ++++++++++++++++ src/routeTree.gen.ts | 21 ++++++++++++++ src/routes/api/internal/esign-users.ts | 30 ++++++++++++++++++++ 3 files changed, 75 insertions(+) create mode 100644 src/routes/api/internal/esign-users.ts diff --git a/src/auth/access-snapshot.integration.test.ts b/src/auth/access-snapshot.integration.test.ts index 7c0e87d..39aac1b 100644 --- a/src/auth/access-snapshot.integration.test.ts +++ b/src/auth/access-snapshot.integration.test.ts @@ -138,5 +138,29 @@ describe.skipIf(!baseURL || !databaseURL || !clientId || !audience)( ]), ); }); + + it("returns registered users to the backend without requiring a first Signatures visit", async () => { + const url = `${baseURL}/api/internal/esign-users`; + expect((await fetch(url)).status).toBe(401); + expect( + ( + await fetch(url, { + headers: { Authorization: `Bearer ${await token({ scope: "backend:tg:read" })}` }, + }) + ).status, + ).toBe(401); + const response = await fetch(url, { headers: { Authorization: `Bearer ${await token()}` } }); + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toContain("no-store"); + expect(await response.json()).toEqual( + expect.arrayContaining([ + { + sub: "snapshot-integration-user", + name: "Snapshot User", + email: "snapshot@identity.invalid", + }, + ]), + ); + }); }, ); diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 0b5aad8..5b09a6c 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -32,6 +32,7 @@ import { Route as AccessRolesNewRouteImport } from './routes/access/roles/new' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' import { Route as ApiDevLoginRouteImport } from './routes/api/dev/login' import { Route as ApiInternalAccessSnapshotRouteImport } from './routes/api/internal/access-snapshot' +import { Route as ApiInternalEsignUsersRouteImport } from './routes/api/internal/esign-users' const IndexRoute = IndexRouteImport.update({ id: '/', @@ -150,6 +151,11 @@ const ApiInternalAccessSnapshotRoute = path: '/api/internal/access-snapshot', getParentRoute: () => rootRouteImport, } as any) +const ApiInternalEsignUsersRoute = ApiInternalEsignUsersRouteImport.update({ + id: '/api/internal/esign-users', + path: '/api/internal/esign-users', + getParentRoute: () => rootRouteImport, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute @@ -173,6 +179,7 @@ export interface FileRoutesByFullPath { '/api/auth/$': typeof ApiAuthSplatRoute '/api/dev/login': typeof ApiDevLoginRoute '/api/internal/access-snapshot': typeof ApiInternalAccessSnapshotRoute + '/api/internal/esign-users': typeof ApiInternalEsignUsersRoute '/access/permissions/': typeof AccessPermissionsIndexRoute '/access/roles/': typeof AccessRolesIndexRoute } @@ -195,6 +202,7 @@ export interface FileRoutesByTo { '/api/auth/$': typeof ApiAuthSplatRoute '/api/dev/login': typeof ApiDevLoginRoute '/api/internal/access-snapshot': typeof ApiInternalAccessSnapshotRoute + '/api/internal/esign-users': typeof ApiInternalEsignUsersRoute '/access/permissions': typeof AccessPermissionsIndexRoute '/access/roles': typeof AccessRolesIndexRoute } @@ -221,6 +229,7 @@ export interface FileRoutesById { '/api/auth/$': typeof ApiAuthSplatRoute '/api/dev/login': typeof ApiDevLoginRoute '/api/internal/access-snapshot': typeof ApiInternalAccessSnapshotRoute + '/api/internal/esign-users': typeof ApiInternalEsignUsersRoute '/access/permissions/': typeof AccessPermissionsIndexRoute '/access/roles/': typeof AccessRolesIndexRoute } @@ -248,6 +257,7 @@ export interface FileRouteTypes { | '/api/auth/$' | '/api/dev/login' | '/api/internal/access-snapshot' + | '/api/internal/esign-users' | '/access/permissions/' | '/access/roles/' fileRoutesByTo: FileRoutesByTo @@ -270,6 +280,7 @@ export interface FileRouteTypes { | '/api/auth/$' | '/api/dev/login' | '/api/internal/access-snapshot' + | '/api/internal/esign-users' | '/access/permissions' | '/access/roles' id: @@ -295,6 +306,7 @@ export interface FileRouteTypes { | '/api/auth/$' | '/api/dev/login' | '/api/internal/access-snapshot' + | '/api/internal/esign-users' | '/access/permissions/' | '/access/roles/' fileRoutesById: FileRoutesById @@ -311,6 +323,7 @@ export interface RootRouteChildren { ApiAuthSplatRoute: typeof ApiAuthSplatRoute ApiDevLoginRoute: typeof ApiDevLoginRoute ApiInternalAccessSnapshotRoute: typeof ApiInternalAccessSnapshotRoute + ApiInternalEsignUsersRoute: typeof ApiInternalEsignUsersRoute } declare module '@tanstack/react-router' { @@ -476,6 +489,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiInternalAccessSnapshotRouteImport parentRoute: typeof rootRouteImport } + '/api/internal/esign-users': { + id: '/api/internal/esign-users' + path: '/api/internal/esign-users' + fullPath: '/api/internal/esign-users' + preLoaderRoute: typeof ApiInternalEsignUsersRouteImport + parentRoute: typeof rootRouteImport + } } } @@ -544,6 +564,7 @@ const rootRouteChildren: RootRouteChildren = { ApiAuthSplatRoute: ApiAuthSplatRoute, ApiDevLoginRoute: ApiDevLoginRoute, ApiInternalAccessSnapshotRoute: ApiInternalAccessSnapshotRoute, + ApiInternalEsignUsersRoute: ApiInternalEsignUsersRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/src/routes/api/internal/esign-users.ts b/src/routes/api/internal/esign-users.ts new file mode 100644 index 0000000..2424eec --- /dev/null +++ b/src/routes/api/internal/esign-users.ts @@ -0,0 +1,30 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { asc } from "drizzle-orm"; +import { snapshotClientId } from "@/auth/access-snapshot-token"; +import { projectionForClient } from "@/auth/access-projections"; +import { db } from "@/db"; +import { user } from "@/db/schema"; +import { env } from "@/env"; + +const headers = { "Cache-Control": "private, no-store", Vary: "Authorization" }; + +/** Registered identities for the backend's e-signing picker; access is checked by the backend's live snapshot. */ +export const Route = createFileRoute("/api/internal/esign-users")({ + server: { + handlers: { + GET: async ({ request }) => { + if (!env.OAUTH_BACKEND_CLIENT_ID || !env.OAUTH_INTERNAL_RESOURCE_URI) + return Response.json({ error: "Unavailable" }, { status: 503, headers }); + const clientId = await snapshotClientId(request.headers.get("authorization")); + if (!clientId) return Response.json({ error: "Unauthorized" }, { status: 401, headers }); + if (projectionForClient(clientId) !== "backend") + return Response.json({ error: "Forbidden" }, { status: 403, headers }); + const people = await db + .select({ sub: user.id, name: user.name, email: user.email }) + .from(user) + .orderBy(asc(user.name), asc(user.id)); + return Response.json(people, { headers }); + }, + }, + }, +}); From 74c844cb20f887e2ad7afa2869852684af329fce Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Sat, 17 Oct 2026 17:55:40 +0200 Subject: [PATCH 2/2] chore(auth): format dev persona fixture Keeps the repository-wide Vite+ check green for the signer directory change. --- src/dev/personas.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/dev/personas.ts b/src/dev/personas.ts index 0cec4ff..7d0bab2 100644 --- a/src/dev/personas.ts +++ b/src/dev/personas.ts @@ -135,8 +135,8 @@ export function listPersonas(): DevPersonaSummary[] { description, ...(id === DEV_ADMIN_ID && !env.IDP_ADMIN_USER_IDS.includes(DEV_ADMIN_ID) ? { - warning: `Add ${DEV_ADMIN_ID} to IDP_ADMIN_USER_IDS in .env.local to make this Master Admin.`, - } + warning: `Add ${DEV_ADMIN_ID} to IDP_ADMIN_USER_IDS in .env.local to make this Master Admin.`, + } : {}), })); } @@ -144,7 +144,7 @@ export function listPersonas(): DevPersonaSummary[] { type Writer = Parameters[0]>[0]; /** Thrown instead of silently moving an identity between people. */ -export class DevFixtureConflict extends Error { } +export class DevFixtureConflict extends Error {} /** Finds the fixture role by its stable ID, so a key renamed in the role editor still matches; * then by key, so a role created by hand under that key is reused rather than collided with. */