diff --git a/README.md b/README.md index a140611..fa7d5d5 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,7 @@ OAuth support is intentionally split across the same trust boundary as the rest - the adapter stores only the resulting SeamlessAuth cookies Provider access tokens are not stored by the adapter, returned to the frontend, or placed in cookies. +The adapter also does not handle provider client secrets; those remain on the Seamless Auth API host. Mounted Express routes include: @@ -166,6 +167,18 @@ Configure providers on the Seamless Auth API using `oauth_providers` and `LOGIN_ Each provider references its client secret by environment variable name, for example `clientSecretEnv: "GOOGLE_CLIENT_SECRET"`. +## Admin Hardening Routes + +The Express adapter exposes the v1 admin recovery and session hygiene routes under the mounted auth +path: + +- `DELETE /auth/admin/sessions/by-id/:id` +- `DELETE /auth/admin/sessions/:userId/revoke-all` +- `POST /auth/admin/users/:userId/recovery/device-replacement` + +The device-replacement route is enforced by the Seamless Auth API and requires a fresh step-up +session before sessions, passkeys, or TOTP credentials are reset. + --- ## Extensibility diff --git a/packages/core/src/handlers/admin.ts b/packages/core/src/handlers/admin.ts index 5b97b39..cf54d30 100644 --- a/packages/core/src/handlers/admin.ts +++ b/packages/core/src/handlers/admin.ts @@ -92,5 +92,14 @@ export const listAllSessionsHandler = (opts: WithQuery) => export const listUserSessionsHandler = (userId: string, opts: BaseOpts) => request("GET", `/admin/sessions/${userId}`, opts); +export const revokeUserSessionHandler = (id: string, opts: BaseOpts) => + request("DELETE", `/admin/sessions/by-id/${id}`, opts); + export const revokeAllUserSessionsHandler = (userId: string, opts: BaseOpts) => request("DELETE", `/admin/sessions/${userId}/revoke-all`, opts); + +export const recoverUserForDeviceReplacementHandler = ( + userId: string, + opts: WithBody, +) => + request("POST", `/admin/users/${userId}/recovery/device-replacement`, opts); diff --git a/packages/core/src/handlers/register.ts b/packages/core/src/handlers/register.ts index f1e07b6..f0007a0 100644 --- a/packages/core/src/handlers/register.ts +++ b/packages/core/src/handlers/register.ts @@ -53,44 +53,16 @@ export async function registerHandler( }; } - const rawCookies = - (up.headers as any).getSetCookie?.() || - up.headers.get?.("set-cookie")?.split(",") || - []; - - let bootstrapCookie; - - for (const cookie of rawCookies) { - if (cookie.startsWith("seamless_bootstrap_token=")) { - const value = cookie.split(";")[0].split("=")[1]; - - bootstrapCookie = { - name: "seamless_bootstrap_token", - value: { sub: value }, - ttl: "900", - domain: opts.cookieDomain, - }; - - break; - } - } - - const setCookies = [ - { - name: opts.registrationCookieName, - value: { sub: data.sub }, - ttl: data.ttl, - domain: opts.cookieDomain, - }, - ]; - - if (bootstrapCookie) { - setCookies.push(bootstrapCookie); - } - return { status: 200, body: data, - setCookies, + setCookies: [ + { + name: opts.registrationCookieName, + value: { sub: data.sub }, + ttl: data.ttl, + domain: opts.cookieDomain, + }, + ], }; } diff --git a/packages/express/README.md b/packages/express/README.md index 8457e00..b92b3fa 100644 --- a/packages/express/README.md +++ b/packages/express/README.md @@ -230,6 +230,20 @@ Provider access tokens are never stored in adapter cookies or returned to the fr --- +### Admin Hardening Routes + +When mounted under `/auth`, the adapter proxies the admin hardening endpoints used by the +Seamless Auth dashboard: + +- `DELETE /auth/admin/sessions/by-id/:id` +- `DELETE /auth/admin/sessions/:userId/revoke-all` +- `POST /auth/admin/users/:userId/recovery/device-replacement` + +The device-replacement endpoint requires the current admin session to have fresh step-up +authentication in the Seamless Auth API. + +--- + ### `requireAuth(options?)` Express middleware that verifies a signed access cookie and attaches the decoded user payload to `req.user`. diff --git a/packages/express/src/createServer.ts b/packages/express/src/createServer.ts index 9b4a61a..0fb4c9e 100644 --- a/packages/express/src/createServer.ts +++ b/packages/express/src/createServer.ts @@ -113,10 +113,9 @@ function routeParam(req: Request, name: string): string { /** * Creates an Express Router that proxies all authentication traffic to a Seamless Auth server. * - * This helper wires your API backend to a Seamless Auth instance running in - * "server mode." It automatically forwards login, registration, WebAuthn, - * logout, token refresh, and session validation routes to the auth server - * and handles all cookie management required for a seamless login flow. + * This helper wires your API backend to a Seamless Auth instance. It automatically forwards + * login, registration, WebAuthn, logout, token refresh, and session validation routes to the + * auth server and handles all cookie management required for a seamless login flow. * * ### Responsibilities * - Proxies all `/auth/*` routes to the upstream Seamless Auth server @@ -462,6 +461,9 @@ export function createSeamlessAuthServer( r.patch("/admin/users/:userId", (req, res) => admin.updateUser(req, res, resolvedOpts), ); + r.post("/admin/users/:userId/recovery/device-replacement", (req, res) => + admin.recoverUserForDeviceReplacement(req, res, resolvedOpts), + ); r.get("/admin/users/:userId", (req, res) => admin.getUserDetail(req, res, resolvedOpts), ); @@ -542,6 +544,9 @@ export function createSeamlessAuthServer( r.get("/admin/sessions/:userId", (req, res) => admin.listUserSessions(req, res, resolvedOpts), ); + r.delete("/admin/sessions/by-id/:id", (req, res) => + admin.revokeUserSession(req, res, resolvedOpts), + ); r.delete("/admin/sessions/:userId/revoke-all", (req, res) => admin.revokeAllUserSessions(req, res, resolvedOpts), ); diff --git a/packages/express/src/handlers/admin.ts b/packages/express/src/handlers/admin.ts index ac082aa..81837e8 100644 --- a/packages/express/src/handlers/admin.ts +++ b/packages/express/src/handlers/admin.ts @@ -10,7 +10,9 @@ import { getCredentialCountHandler, listAllSessionsHandler, listUserSessionsHandler, + recoverUserForDeviceReplacementHandler, revokeAllUserSessionsHandler, + revokeUserSessionHandler, } from "@seamless-auth/core/handlers/admin"; import { buildServiceAuthorization } from "../internal/buildAuthorization"; @@ -168,6 +170,20 @@ export const listUserSessions = async ( } as any), ); +export const revokeUserSession = async ( + req: Request, + res: Response, + opts: SeamlessAuthServerOptions, +) => + handle( + res, + await revokeUserSessionHandler(req.params.id as string, { + authServerUrl: opts.authServerUrl, + authorization: buildServiceAuthorization(req, opts), + forwardedClientIp: buildForwardedClientIp(req), + } as any), + ); + export const revokeAllUserSessions = async ( req: Request, res: Response, @@ -181,3 +197,18 @@ export const revokeAllUserSessions = async ( forwardedClientIp: buildForwardedClientIp(req), } as any), ); + +export const recoverUserForDeviceReplacement = async ( + req: Request, + res: Response, + opts: SeamlessAuthServerOptions, +) => + handle( + res, + await recoverUserForDeviceReplacementHandler(req.params.userId as string, { + authServerUrl: opts.authServerUrl, + authorization: buildServiceAuthorization(req, opts), + forwardedClientIp: buildForwardedClientIp(req), + body: req.body, + } as any), + ); diff --git a/packages/express/src/handlers/finishRegister.ts b/packages/express/src/handlers/finishRegister.ts index 6424b1e..61c1b87 100644 --- a/packages/express/src/handlers/finishRegister.ts +++ b/packages/express/src/handlers/finishRegister.ts @@ -4,7 +4,6 @@ import { setSessionCookie } from "../internal/cookie"; import { buildServiceAuthorization } from "../internal/buildAuthorization"; import { buildForwardedClientIp } from "../internal/buildForwardedClientIp"; import { SeamlessAuthServerOptions } from "../createServer"; -import { verifyCookieJwt } from "@seamless-auth/core"; export async function finishRegister( req: Request & { cookiePayload?: any }, @@ -22,26 +21,10 @@ export async function finishRegister( const authorization = buildServiceAuthorization(req, opts); - const bootstrapToken = req.cookies?.["seamless_bootstrap_token"]; - - const headers: Record = {}; - - if (bootstrapToken) { - const payload = verifyCookieJwt(bootstrapToken, opts.cookieSecret); - if (!payload || !payload.sub) { - res.status(401).json({ - error: "Invalid or expired session", - }); - return; - } - headers["cookie"] = `seamless_bootstrap_token=${payload.sub}`; - } - const result = await finishRegisterHandler( { body: req.body, authorization, - headers, forwardedClientIp: buildForwardedClientIp(req), } as any, {