Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
20 changes: 18 additions & 2 deletions packages/cloud-api/auth/steward-nonce-exchange/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,14 @@
* 3. This route forwards to Steward `POST /auth/oauth/exchange`, which
* consumes the code and returns `{ token, refreshToken, expiresAt }`.
* 4. We verify the JWT (same path as `/api/auth/steward-session`), sync the
* user, set the HttpOnly cookies, and return `{ ok, userId }`.
* user, set the HttpOnly cookies, and return `{ ok, userId }`. The
* elizaos.ai hardware checkout origin also receives the access token so
* its cross-site Stripe checkout POST can use Bearer auth; refresh stays
* cookie-only.
*
* The access and refresh tokens never enter the browser process at any point.
* The refresh token never enters the browser process; the access token is
* returned only to the hardware checkout origin that still has to authenticate
* a cross-site Stripe checkout request with Bearer auth.
*
* Origin/Referer CSRF check mirrors `/api/auth/steward-session` exactly — the
* route is callable from `*.elizacloud.ai` and `elizaos.ai` only (plus
Expand Down Expand Up @@ -99,6 +104,16 @@
};
}

function shouldReturnClientToken(
c: { req: { header: (name: string) => string | undefined } },
isProduction: boolean,
): boolean {
const origin =
originHost(c.req.header("origin")) ?? originHost(c.req.header("referer"));
if (origin === "elizaos.ai" || origin === "www.elizaos.ai") return true;
return !isProduction && origin !== null && LOCAL_DEV_ORIGIN_HOSTS.has(origin);
}

// ─── Helpers ──────────────────────────────────────────────────────────────

function stewardSecretConfigured(env: StewardVerifyEnv): boolean {
Expand Down Expand Up @@ -211,194 +226,195 @@

const app = new Hono<AppEnv>();

app.post("/", async (c) => {
const isProduction = c.env.NODE_ENV === "production";
const originCheck = checkOrigin(c, isProduction);
if (!originCheck.ok) {
logExchange("forbidden-origin");
logger.warn("[steward-nonce-exchange] rejected cross-origin POST", {
detail: originCheck.reason,
});
return c.json(errorBody("Forbidden", "forbidden_origin"), 403);
}

const body = (await c.req.json().catch(() => ({}))) as {
code?: unknown;
redirectUri?: unknown;
redirect_uri?: unknown;
tenantId?: unknown;
tenant_id?: unknown;
};

const code = typeof body.code === "string" ? body.code.trim() : "";
const redirectUri =
typeof body.redirectUri === "string"
? body.redirectUri.trim()
: typeof body.redirect_uri === "string"
? body.redirect_uri.trim()
: "";
const rawTenant =
typeof body.tenantId === "string"
? body.tenantId.trim()
: typeof body.tenant_id === "string"
? body.tenant_id.trim()
: "";
const tenantId = rawTenant.length > 0 ? rawTenant : null;

if (!code) {
logExchange("missing-code");
return c.json(errorBody("code required", "missing_code"), 400);
}
if (!redirectUri) {
logExchange("missing-redirect-uri");
return c.json(errorBody("redirectUri required", "missing_code"), 400);
}
if (!stewardSecretConfigured(c.env)) {
logExchange("server-secret-missing");
return c.json(
errorBody(
"Steward verification not configured on server",
"server_secret_missing",
),
503,
);
}

const stewardBaseUrl = resolveStewardBaseUrl(c.env);
if (!stewardBaseUrl) {
logExchange("upstream-not-configured");
return c.json(
errorBody(
"Steward upstream not configured",
"steward_upstream_unavailable",
),
503,
);
}

const exchange = await callStewardExchange(stewardBaseUrl, {
code,
redirect_uri: redirectUri,
tenant_id: tenantId,
});

if (exchange.kind === "transport") {
logExchange("upstream-transport-error");
logger.error("[steward-nonce-exchange] upstream transport failure", {
message: exchange.message,
});
return c.json(
errorBody("Steward upstream unavailable", "steward_upstream_unavailable"),
502,
);
}

if (exchange.kind === "error") {
const upstreamCode = exchange.data.code;
// Pass through the Steward error codes verbatim when they're in our known
// set; otherwise default to `code_invalid` so the client wipes URL state
// and re-prompts sign-in.
const mapped: StewardSessionErrorCode =
upstreamCode === "code_expired" ||
upstreamCode === "code_redirect_mismatch" ||
upstreamCode === "code_tenant_mismatch" ||
upstreamCode === "code_invalid"
? upstreamCode
: "code_invalid";
logExchange(`upstream-${mapped}`);
// Steward returns 401 for all of these. Anything else we collapse to
// 502 so the client can disambiguate "your code is bad" from "Steward
// is unhealthy" without us widening the Hono status union.
const status: 401 | 502 = exchange.status === 401 ? 401 : 502;
return c.json(
errorBody(exchange.data.error || "Code exchange failed", mapped),
status,
);
}

const { token, refreshToken } = exchange.data;

const claims = await verifyStewardTokenCached(c.env, token);
if (!claims) {
logExchange("invalid-token-after-exchange");
return c.json(errorBody("Invalid token", "invalid_token"), 401);
}

let cloudUser: Awaited<ReturnType<typeof syncUserFromSteward>>;
try {
cloudUser = await syncUserFromSteward({
stewardUserId: claims.userId,
email: claims.email,
walletAddress: claims.walletAddress ?? claims.address,
walletChainType: claims.walletChain,
});
} catch (error) {
logExchange("sync-failed");
logger.error(
"[steward-nonce-exchange] Failed to sync Steward user before setting cookie",
{ stewardUserId: claims.userId, error },
);
return c.json(
errorBody("Could not sync Steward user", "steward_user_sync_failed"),
500,
);
}

const ttl = claims.expiration
? Math.max(0, claims.expiration - Math.floor(Date.now() / 1000))
: null;
const secure = c.env.NODE_ENV === "production";
const domain = cookieDomainForHost(c.req.header("host"));

setCookie(c, STEWARD_TOKEN_COOKIE, token, {
httpOnly: true,
secure,
sameSite: "Lax",
path: "/",
...(domain ? { domain } : {}),
...(typeof ttl === "number" ? { maxAge: ttl } : {}),
});

if (typeof refreshToken === "string" && refreshToken.length > 0) {
setCookie(c, STEWARD_REFRESH_TOKEN_COOKIE, refreshToken, {
httpOnly: true,
secure,
sameSite: "Lax",
path: "/",
...(domain ? { domain } : {}),
maxAge: STEWARD_REFRESH_COOKIE_MAX_AGE,
});
}

setCookie(c, STEWARD_AUTHED_COOKIE, "1", {
httpOnly: false,
secure,
sameSite: "Lax",
path: "/",
...(domain ? { domain } : {}),
maxAge: 60 * 60 * 24 * 7,
});

logExchange("ok");
// Returning `token` (and `refreshToken`) here so the SPA can mirror it into
// localStorage. The HttpOnly cookies above are the canonical session; the
// localStorage copy is what @stwd/react's `useAuth()` and the SPA's
// `readStewardSessionFromStorage()` actually read on `/dashboard` route
// mount to decide `isAuthenticated`. Without this, OAuth users land back
// on `/login` after a successful exchange (wallet/SIWE keeps working only
// because the Steward SDK writes its own localStorage copy). The original
// "tokens never enter JS" design intent is aspirational — until the SPA
// auth check trusts the steward-authed marker cookie alone, the JWT has
// to be reachable from JS.
return c.json({
ok: true,
userId: cloudUser.id,
stewardUserId: claims.userId,
...(shouldReturnClientToken(c, isProduction) ? { token } : {}),
expiresAt: exchange.data.expiresAt,
expiresIn: exchange.data.expiresIn,
token,
refreshToken,
});
});

Check notice on line 418 in packages/cloud-api/auth/steward-nonce-exchange/route.ts

View check run for this annotation

codefactor.io / CodeFactor

packages/cloud-api/auth/steward-nonce-exchange/route.ts#L229-L418

Complex Method

export default app;
5 changes: 3 additions & 2 deletions packages/os-homepage/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
-->
<meta name="referrer" content="no-referrer" />
<!--
Snapshot then strip any `#token=...` from the URL before any other JS
Snapshot then strip any token-bearing hash from the URL before any other JS
runs (React, analytics, Sentry, etc.) so the access token never reaches
anything that might log `location.href`. Tokens are then read by
consumeStewardTokensFromHash() from `__stewardOAuthHash`.
Expand All @@ -22,7 +22,8 @@
(() => {
try {
const h = window.location.hash;
if (h && h.indexOf("token=") !== -1) {
const params = h ? new URLSearchParams(h.replace(/^#/, "")) : null;
if (params && (params.has("token") || params.has("access_token"))) {
window.__stewardOAuthHash = h;
history.replaceState(
null,
Expand Down
50 changes: 36 additions & 14 deletions packages/os-homepage/src/CheckoutPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ const stewardTenantId = STEWARD_TENANT_ID;
const stewardSessionEndpoint = `${cloudApiUrl.replace(/\/$/, "")}${STEWARD_SESSION_ENDPOINT}`;
const stewardNonceExchangeEndpoint = `${cloudApiUrl.replace(/\/$/, "")}${STEWARD_NONCE_EXCHANGE_ENDPOINT}`;

type StewardTokenPayload = {
token: string;
refreshToken: string | null;
};

function getDefaultProduct(): Product {
const fallback =
hardwareProducts.find((product) => product.sku === "elizaos-usb") ??
Expand Down Expand Up @@ -66,6 +71,24 @@ function getStoredStewardToken() {
return readStoredStewardToken();
}

function readStewardTokenParams(
params: URLSearchParams,
): StewardTokenPayload | null {
const token = params.get("token") ?? params.get("access_token");
if (!token) return null;
return {
token,
refreshToken: params.get("refreshToken") ?? params.get("refresh_token"),
};
}

function removeStewardTokenParams(params: URLSearchParams): void {
params.delete("token");
params.delete("access_token");
params.delete("refreshToken");
params.delete("refresh_token");
}

function consumeStewardCodeFromQuery(): string | null {
if (typeof window === "undefined") return null;
const params = new URLSearchParams(window.location.search);
Expand Down Expand Up @@ -93,17 +116,16 @@ function consumeStewardTokensFromHash(): {
}
if (!hash || hash.length < 2) return null;
const params = new URLSearchParams(hash.replace(/^#/, ""));
const token = params.get("token");
if (!token) return null;
const refreshToken = params.get("refreshToken");
const tokens = readStewardTokenParams(params);
if (!tokens) return null;
if (!snapshotted) {
window.history.replaceState(
null,
"",
`${window.location.pathname}${window.location.search}`,
);
}
return { token, refreshToken };
return tokens;
}

function ProductImage({
Expand Down Expand Up @@ -220,8 +242,11 @@ export function CheckoutPage() {
redirectUri: buildOAuthRedirectUri(product),
tenantId: stewardTenantId,
})
.then(() => {
setIsAuthed(true);
.then((session) => {
if (session.token) {
writeStoredStewardToken(session.token);
}
setIsAuthed(Boolean(session.token) || hasStewardAuthedCookie());
Comment on lines +245 to +249

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 setIsAuthed will silently fail if token is absent and cookie hasn't been written yet

setIsAuthed(Boolean(session.token) || hasStewardAuthedCookie()) sets the user as unauthenticated when session.token is absent and the steward-authed cookie hasn't been applied by the browser. Cookie availability after a cross-origin credentials: "include" response is generally synchronous once .then() fires, but this is a departure from the previous unconditional setIsAuthed(true) on any 2xx. If the server-side gate on shouldReturnClientToken is ever fixed to withhold the token from non-checkout origins, callers from those origins will land on the unauthenticated state after a fully successful code exchange.

Fix in Claude Code Fix in Codex Fix in Cursor

})
.catch((error: unknown) => {
setMessage(
Expand All @@ -236,11 +261,9 @@ export function CheckoutPage() {

const fromHash = consumeStewardTokensFromHash();
const params = new URLSearchParams(window.location.search);
const queryToken = params.get("token");
const queryRefreshToken = params.get("refreshToken");
const token = fromHash?.token ?? queryToken;
const refreshToken =
fromHash?.refreshToken ?? queryRefreshToken ?? undefined;
const fromQuery = readStewardTokenParams(params);
const token = fromHash?.token ?? fromQuery?.token;
const refreshToken = fromHash?.refreshToken ?? fromQuery?.refreshToken;
if (!token) {
setIsAuthed(Boolean(getStoredStewardToken()) || hasStewardAuthedCookie());
return;
Expand All @@ -254,9 +277,8 @@ export function CheckoutPage() {
})
.then(() => {
setIsAuthed(true);
if (queryToken || queryRefreshToken) {
params.delete("token");
params.delete("refreshToken");
if (fromQuery) {
removeStewardTokenParams(params);
const query = params.toString();
window.history.replaceState(
null,
Expand Down
75 changes: 73 additions & 2 deletions packages/os-homepage/tests/checkout-controls.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { expect, type Page, test } from "playwright/test";

async function installCheckoutMocks(page: Page) {
const requests: Array<{ url: string; body: unknown }> = [];
const requests: Array<{
url: string;
body: unknown;
headers: Record<string, string>;
}> = [];

await page.route("https://api.elizacloud.ai/**", async (route) => {
const request = route.request();
Expand All @@ -12,14 +16,25 @@ async function installCheckoutMocks(page: Page) {
} catch {
body = null;
}
requests.push({ url: request.url(), body });
requests.push({ url: request.url(), body, headers: request.headers() });

if (url.pathname === "/api/auth/steward-session") {
return route.fulfill({
json: { success: true, user: { id: "steward-user-1" } },
});
}

if (url.pathname === "/api/auth/steward-nonce-exchange") {
return route.fulfill({
json: {
ok: true,
userId: "cloud-user-1",
stewardUserId: "steward-user-1",
token: "steward-token-from-code",
},
});
}

if (url.pathname === "/api/stripe/create-checkout-session") {
return route.fulfill({
json: {
Expand Down Expand Up @@ -89,9 +104,65 @@ test("checkout accepts a Steward token and posts the selected product to Stripe"
(request) =>
new URL(request.url).pathname === "/api/stripe/create-checkout-session",
);
expect(checkoutRequest?.headers.authorization).toBe(
"Bearer steward-token-1",
);
expect(checkoutRequest?.body).toMatchObject({
hardwareSku: "elizaos-phone",
hardwareColor: "Blue glass",
returnUrl: "billing",
});
});

test("checkout exchanges a query code and uses the returned bearer for Stripe", async ({
page,
}) => {
const requests = await installCheckoutMocks(page);

await page.goto("/checkout?code=oauth-code-1&sku=elizaos-phone");
await expect(page.getByRole("button", { name: "Pay deposit" })).toBeVisible();
await expect(page).toHaveURL(/\/checkout\?sku=elizaos-phone$/);

const exchangeRequest = requests.find(
(request) =>
new URL(request.url).pathname === "/api/auth/steward-nonce-exchange",
);
expect(exchangeRequest?.body).toMatchObject({
code: "oauth-code-1",
redirectUri: "http://127.0.0.1:4455/checkout?sku=elizaos-phone",
tenantId: "elizacloud",
});

await page.getByRole("button", { name: "Pay deposit" }).click();
await expect(page).toHaveURL(/\/checkout\/success\?sku=elizaos-phone$/);

const checkoutRequest = requests.find(
(request) =>
new URL(request.url).pathname === "/api/stripe/create-checkout-session",
);
expect(checkoutRequest?.headers.authorization).toBe(
"Bearer steward-token-from-code",
);
});

test("checkout accepts OAuth-style hash token names and strips the fragment", async ({
page,
}) => {
const requests = await installCheckoutMocks(page);

await page.goto(
"/checkout?sku=elizaos-phone#access_token=steward-token-2&refresh_token=refresh-token-2",
);
await expect(page.getByRole("button", { name: "Pay deposit" })).toBeVisible();
await expect
.poll(() => page.evaluate(() => window.location.hash))
.toBe("");

const sessionRequest = requests.find(
(request) => new URL(request.url).pathname === "/api/auth/steward-session",
);
expect(sessionRequest?.body).toMatchObject({
token: "steward-token-2",
refreshToken: "refresh-token-2",
});
});
4 changes: 3 additions & 1 deletion packages/shared/src/steward-session-client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,7 @@ export interface StewardNonceExchangeRequest {
}

export interface StewardNonceExchangeResponse extends StewardSessionResponse {
token?: string;
expiresIn?: number;
expiresAt?: number;
/**
Expand All @@ -325,7 +326,8 @@ export interface ExchangeStewardCodeOpts extends SyncOpts {
* POSTs the one-time OAuth code to the cloud-api nonce-exchange endpoint.
* The route calls Steward `POST /auth/oauth/exchange` server-side, sets the
* HttpOnly steward-token + steward-refresh-token cookies, and returns the
* Eliza Cloud user id. Throws `StewardSessionError` on non-2xx.
* Eliza Cloud user id. Some cross-origin checkout callers may also receive a
* browser bearer token. Throws `StewardSessionError` on non-2xx.
*/
export async function exchangeStewardCode(
code: string,
Expand Down
Loading