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
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/handlers/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
44 changes: 8 additions & 36 deletions packages/core/src/handlers/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
],
};
}
14 changes: 14 additions & 0 deletions packages/express/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
13 changes: 9 additions & 4 deletions packages/express/src/createServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
);
Expand Down Expand Up @@ -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),
);
Expand Down
31 changes: 31 additions & 0 deletions packages/express/src/handlers/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import {
getCredentialCountHandler,
listAllSessionsHandler,
listUserSessionsHandler,
recoverUserForDeviceReplacementHandler,
revokeAllUserSessionsHandler,
revokeUserSessionHandler,
} from "@seamless-auth/core/handlers/admin";

import { buildServiceAuthorization } from "../internal/buildAuthorization";
Expand Down Expand Up @@ -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,
Expand All @@ -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),
);
17 changes: 0 additions & 17 deletions packages/express/src/handlers/finishRegister.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand All @@ -22,26 +21,10 @@ export async function finishRegister(

const authorization = buildServiceAuthorization(req, opts);

const bootstrapToken = req.cookies?.["seamless_bootstrap_token"];

const headers: Record<string, string> = {};

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,
{
Expand Down
Loading