Skip to content
Merged

Dev #45

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
11 changes: 11 additions & 0 deletions .changeset/adapter-non-json-response.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@seamless-auth/core": patch
"@seamless-auth/express": patch
---

Don't crash on non-JSON upstream responses. `authFetch` now parses response bodies
defensively, so a plain-text error (e.g. a rate-limited `429 Too many requests`) or an
empty body (`204`) no longer throws in handlers that read the body before checking the
status — which previously surfaced as an unhandled rejection that took down the adapter
process. Non-JSON bodies are returned as `{ message: <text> }`; empty bodies as
`undefined`. Fixes #41.
13 changes: 13 additions & 0 deletions .changeset/registration-session-cookie.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@seamless-auth/core": minor
"@seamless-auth/express": patch
---

Issue a session on OTP-based registration. Registration now starts with just an
email, and verifying the registration email OTP completes sign-up and returns a
session. The adapter previously proxied `/otp/verify-email-otp` and
`/otp/verify-phone-otp` without setting cookies, so browser users finished
registration unauthenticated. A new `verifyRegistrationOtpHandler` (core) plus a
`verifyRegistrationOtp` express handler now set the session cookies on these
routes (tolerating a phone-first step that returns no session yet), mirroring the
login OTP verify handlers.
6 changes: 6 additions & 0 deletions .changeset/sharp-breads-stop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@seamless-auth/express": patch
"@seamless-auth/core": patch
---

fix: updates core implementation to supply the authorization value during polling for magic links
6 changes: 6 additions & 0 deletions .changeset/tough-nights-stop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@seamless-auth/express": patch
"@seamless-auth/core": patch
---

Fixes for deleting users as an admin, and internal auth events summary route token handling
12 changes: 12 additions & 0 deletions .github/workflows/conformance.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Run the cross-repo conformance matrix on PRs, testing this repo's change against
# the rest of the ecosystem. The reusable workflow lives in seamless-cli.
name: conformance

on:
pull_request:

jobs:
verify:
uses: fells-code/seamless-cli/.github/workflows/verify-conformance.yml@main
with:
server-ref: ${{ github.event.pull_request.head.sha }}
30 changes: 29 additions & 1 deletion packages/core/src/authFetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,37 @@ export async function authFetch(
: {}),
};

return fetch(url, {
const response = await fetch(url, {
method: options.method,
headers,
body: options.body ? JSON.stringify(options.body) : undefined,
});

return makeJsonTolerant(response);
}

// Upstream responses aren't always JSON: a rate-limited request comes back as plain
// text ("Too many requests…") and a 204 has no body. Native Response.json() throws on
// both, which would crash callers that parse the body before checking the status. Make
// json() tolerant so callers always get a value — parsed JSON, { message: text } for a
// non-JSON body, or undefined for an empty one.
function makeJsonTolerant(response: Response): Response {
if (typeof response.text !== "function") {
return response;
}

const readText = response.text.bind(response);
response.json = async () => {
const text = await readText();
if (!text) {
return undefined;
}
try {
return JSON.parse(text);
} catch {
return { message: text };
}
};

return response;
}
157 changes: 97 additions & 60 deletions packages/core/src/ensureCookies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,10 @@ const COOKIE_REQUIREMENTS: Record<
"/step-up/webauthn/start": { name: "accessCookieName", required: true },
"/step-up/webauthn/finish": { name: "accessCookieName", required: true },
"/internal/metrics/dashboard": { name: "accessCookieName", required: true },
"/internal/auth-events/summary": {
name: "accessCookieName",
required: true,
},
"/internal/auth-events/timeseries": {
name: "accessCookieName",
required: true,
Expand Down Expand Up @@ -157,6 +161,78 @@ const COOKIE_REQUIREMENTS: Record<
},
};

async function refreshRequiredCookie(
cookieName: string,
refreshCookie: string | undefined,
opts: EnsureCookiesOptions,
): Promise<EnsureCookiesResult | null> {
if (!refreshCookie) {
return null;
}

const refreshed = await refreshAccessToken(refreshCookie, {
authServerUrl: opts.authServerUrl,
cookieSecret: opts.cookieSecret,
serviceSecret: opts.serviceSecret,
issuer: opts.issuer,
audience: opts.audience,
keyId: opts.keyId,
forwardedClientIp: opts.forwardedClientIp,
});

if (!refreshed?.token) {
return {
type: "error",
status: 401,
error: "Refresh failed",
clearCookies: [
cookieName,
opts.registrationCookieName,
opts.refreshCookieName,
],
};
}

return {
type: "ok",
user: {
sub: refreshed.sub,
...(refreshed.sessionId === undefined
? {}
: { sessionId: refreshed.sessionId }),
token: refreshed.token,
roles: refreshed.roles,
},
setCookies: [
{
name: cookieName,
value: {
sub: refreshed.sub,
...(refreshed.sessionId === undefined
? {}
: { sessionId: refreshed.sessionId }),
token: refreshed.token,
roles: refreshed.roles,
email: refreshed.email,
phone: refreshed.phone,
organizationId: refreshed.organizationId ?? null,
},
ttl: refreshed.ttl,
domain: opts.cookieDomain,
},
{
name: opts.refreshCookieName,
value: {
sub: refreshed.sub,
refreshToken: refreshed.refreshToken,
},
ttl: refreshed.refreshTtl,
domain: opts.cookieDomain,
},
],
};
}

export async function ensureCookies(
input: EnsureCookiesInput,
opts: EnsureCookiesOptions,
Expand Down Expand Up @@ -187,84 +263,45 @@ export async function ensureCookies(
const refreshCookie = input.cookies[opts.refreshCookieName];

if (required && !cookieValue) {
if (!refreshCookie) {
const refreshed = await refreshRequiredCookie(cookieName, refreshCookie, opts);

if (!refreshed) {
return {
type: "error",
status: 400,
error: `Missing required cookie "${cookieName}"`,
};
}

const refreshed = await refreshAccessToken(refreshCookie, {
authServerUrl: opts.authServerUrl,
cookieSecret: opts.cookieSecret,
serviceSecret: opts.serviceSecret,
issuer: opts.issuer,
audience: opts.audience,
keyId: opts.keyId,
forwardedClientIp: opts.forwardedClientIp,
});
return refreshed;
}

if (!refreshed?.token) {
if (cookieValue) {
const payload = verifyCookieJwt(cookieValue, opts.cookieSecret);
if (!payload) {
return {
type: "error",
status: 401,
error: "Refresh failed",
clearCookies: [
cookieName,
opts.registrationCookieName,
opts.refreshCookieName,
],
error: `Invalid or expired ${cookieName} cookie`,
};
}

return {
type: "ok",
user: {
sub: refreshed.sub,
...(refreshed.sessionId === undefined
? {}
: { sessionId: refreshed.sessionId }),
token: refreshed.token,
roles: refreshed.roles,
},
setCookies: [
{
name: cookieName,
value: {
sub: refreshed.sub,
...(refreshed.sessionId === undefined
? {}
: { sessionId: refreshed.sessionId }),
token: refreshed.token,
roles: refreshed.roles,
email: refreshed.email,
phone: refreshed.phone,
organizationId: refreshed.organizationId ?? null,
},
ttl: refreshed.ttl,
domain: opts.cookieDomain,
},
{
name: opts.refreshCookieName,
value: {
sub: refreshed.sub,
refreshToken: refreshed.refreshToken,
},
ttl: refreshed.refreshTtl,
domain: opts.cookieDomain,
},
],
};
}
const token = typeof payload.token === "string" ? payload.token : undefined;

if (cookieValue) {
const payload = verifyCookieJwt(cookieValue, opts.cookieSecret);
if (!payload) {
if (required && !token && cookieName === opts.accessCookieName) {
const refreshed = await refreshRequiredCookie(cookieName, refreshCookie, opts);

if (refreshed) {
return refreshed;
}
}

if (required && !token) {
return {
type: "error",
status: 401,
error: `Invalid or expired ${cookieName} cookie`,
clearCookies: [cookieName],
};
}

Expand All @@ -275,7 +312,7 @@ export async function ensureCookies(
...(typeof payload.sessionId === "string"
? { sessionId: payload.sessionId }
: {}),
...(typeof payload.token === "string" ? { token: payload.token } : {}),
...(token === undefined ? {} : { token }),
roles: payload.roles as string[] | undefined,
},
};
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/handlers/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export const getUsersHandler = (opts: BaseOpts) =>
export const createUserHandler = (opts: WithBody) =>
request("POST", "/admin/users", opts);

export const deleteUserHandler = (opts: BaseOpts) =>
export const deleteUserHandler = (opts: WithBody) =>
request("DELETE", "/admin/users", opts);

export const updateUserHandler = (userId: string, opts: WithBody) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export interface PollMagicLinkConfirmationOptions {
cookieDomain?: string;
accessCookieName: string;
refreshCookieName: string;
serviceAuthorization?: string;
}

export interface PollMagicLinkConfirmationResult {
Expand All @@ -34,9 +35,9 @@ export async function pollMagicLinkConfirmationHandler(
method: "GET",
authorization: input.authorization,
forwardedClientIp: input.forwardedClientIp,
serviceAuthorization: opts.serviceAuthorization,
});

// 👇 Pending state (important for polling UX)
if (up.status === 204) {
return {
status: 204,
Expand All @@ -53,15 +54,13 @@ export async function pollMagicLinkConfirmationHandler(
};
}

// 👇 Web mode: auth server already handled cookies
if (!data?.token || !data?.refreshToken || !data?.sub) {
return {
status: up.status,
body: data,
};
}

// 🔐 Verify signed response (same as WebAuthn flow)
const verifiedAccessToken = await verifySignedAuthResponse(
data.token,
opts.authServerUrl,
Expand Down
Loading
Loading