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
70 changes: 42 additions & 28 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,20 @@ import { Toaster } from "sonner";
import { CreditsProvider } from "@/lib/providers/CreditsProvider";
import { PostHogProvider } from "@/lib/providers/PostHogProvider";
import PrivyProvider from "@/lib/providers/PrivyProvider";
import { StewardAuthProvider } from "@/lib/providers/StewardProvider";
import { getRobotsMetadata } from "@/lib/seo";

const stewardAuthEnabled = process.env.NEXT_PUBLIC_STEWARD_AUTH_ENABLED === "true";

/**
* Conditionally wraps children in StewardAuthProvider when enabled.
* Both Privy and Steward providers can coexist, managing separate auth state.
*/
function MaybeStewardProvider({ children }: { children: React.ReactNode }) {
if (!stewardAuthEnabled) return <>{children}</>;
return <StewardAuthProvider>{children}</StewardAuthProvider>;
}

// DM Mono for landing page
const dmMono = DM_Mono({
subsets: ["latin"],
Expand Down Expand Up @@ -140,34 +152,36 @@ export default function RootLayout({
className={`${sfPro.variable} ${dmMono.variable} ${inter.variable} antialiased selection:bg-[#FF5800] selection:text-white`}
>
<PrivyProvider>
<PostHogProvider>
<CreditsProvider>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<NextTopLoader showSpinner={false} color="#FF5800" />
{children}
<Toaster
richColors
theme="dark"
position="top-right"
toastOptions={{
style: {
background: "rgba(0, 0, 0, 0.8)",
border: "1px solid rgba(255, 255, 255, 0.1)",
color: "white",
backdropFilter: "blur(12px)",
borderRadius: "0px",
},
className: "font-sf-pro",
}}
/>
</ThemeProvider>
</CreditsProvider>
</PostHogProvider>
<MaybeStewardProvider>
<PostHogProvider>
<CreditsProvider>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<NextTopLoader showSpinner={false} color="#FF5800" />
{children}
<Toaster
richColors
theme="dark"
position="top-right"
toastOptions={{
style: {
background: "rgba(0, 0, 0, 0.8)",
border: "1px solid rgba(255, 255, 255, 0.1)",
color: "white",
backdropFilter: "blur(12px)",
borderRadius: "0px",
},
className: "font-sf-pro",
}}
/>
</ThemeProvider>
</CreditsProvider>
</PostHogProvider>
</MaybeStewardProvider>
</PrivyProvider>
{shouldEnableVercelAnalytics ? <Analytics /> : null}
</body>
Expand Down
41 changes: 41 additions & 0 deletions app/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,18 @@

import { BrandButton, Input } from "@elizaos/cloud-ui";
import { useLogin, useLoginWithEmail, useLoginWithOAuth, usePrivy } from "@privy-io/react-auth";
import { StewardLogin } from "@stwd/react";
import { StewardProvider } from "@stwd/react";
import { ArrowLeft, Chrome, Github, Loader2, Mail, Wallet } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import LandingHeader from "@/packages/ui/src/components/layout/landing-header";

const STEWARD_AUTH_ENABLED = process.env.NEXT_PUBLIC_STEWARD_AUTH_ENABLED === "true";
const STEWARD_AUTH_BASE_URL = process.env.NEXT_PUBLIC_STEWARD_AUTH_BASE_URL || "https://api.steward.fi";
const STEWARD_TENANT_ID = process.env.NEXT_PUBLIC_STEWARD_TENANT_ID || undefined;

// Discord SVG Icon Component
const DiscordIcon = ({ className }: { className?: string }) => (
<svg
Expand Down Expand Up @@ -502,6 +508,41 @@ function LoginPageContent() {
: "Sign in to your Eliza Cloud account"}
</p>
</div>
{/* Steward Auth Section (feature-flagged) */}
{STEWARD_AUTH_ENABLED && !showCodeInput && (
<div className="space-y-4">
<StewardProvider
auth={{ baseUrl: STEWARD_AUTH_BASE_URL }}
tenantId={STEWARD_TENANT_ID}
>
<StewardLogin
variant="inline"
showPasskey
showEmail
title="Sign in with Steward"
onSuccess={() => {
toast.success("Signed in with Steward!");
const redirectUrl = getSafeReturnTo(searchParams);
router.replace(redirectUrl);
}}
onError={(err) => {
toast.error(err?.message || "Steward login failed");
}}
/>
</StewardProvider>

{/* Divider between Steward and Privy options */}
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-white/10" />
</div>
<div className="relative flex justify-center text-xs">
<span className="bg-neutral-900 px-3 text-neutral-500">or</span>
</div>
</div>
</div>
)}

{/* Email/Code Login Section */}
{!showCodeInput ? (
// Email Input
Expand Down
8 changes: 6 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,6 @@
"@fal-ai/server-proxy": "^1.1.2",
"@modelcontextprotocol/sdk": "^1.27.1",
"@monaco-editor/react": "^4.7.0",
"monaco-editor": "^0.52.2",
"@neondatabase/serverless": "^1.0.2",
"@octokit/rest": "^22.0.1",
"@privy-io/react-auth": "^3.11.0",
Expand Down Expand Up @@ -134,6 +133,7 @@
"@sendgrid/client": "^8.1.6",
"@sendgrid/helpers": "^8.0.0",
"@sendgrid/mail": "^8.1.6",
"@simplewebauthn/browser": "^13.3.0",
"@smithy/smithy-client": "^4.12.3",
"@solana-program/memo": "^0.10.0",
"@solana-program/system": "^0.10.0",
Expand All @@ -145,7 +145,8 @@
"@solana/web3.js": "^1.98.4",
"@stripe/react-stripe-js": "^5.4.1",
"@stripe/stripe-js": "^8.6.4",
"@stwd/sdk": "^0.3.0",
"@stwd/react": "0.6.4",
"@stwd/sdk": "0.7.2",
"@tabler/icons-react": "^3.36.1",
"@tailwindcss/postcss": "^4.1.18",
"@tailwindcss/typography": "^0.5.19",
Expand Down Expand Up @@ -192,6 +193,7 @@
"libphonenumber-js": "^1.12.35",
"lucide-react": "^0.562.0",
"mcp-handler": "^1.0.7",
"monaco-editor": "^0.52.2",
"motion": "^12.29.0",
"nanoid": "^5.1.6",
"next": "^16.1.6",
Expand Down
58 changes: 55 additions & 3 deletions packages/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,13 @@ import {
invalidatePrivyTokenCache,
verifyAuthTokenCached,
} from "./auth/privy-client";
import {
invalidateStewardTokenCache,
verifyStewardTokenCached,
} from "./auth/steward-client";
import { syncUserFromPrivy } from "./privy-sync";
// TODO: Import syncUserFromSteward once steward-sync module is created
// import { syncUserFromSteward } from "./steward-sync";

// Re-export Organization type for convenience
export type { Organization };
Expand Down Expand Up @@ -53,8 +59,11 @@ export async function invalidateUserSessionCache(sessionToken: string): Promise<
* @param sessionToken - The Privy auth token to invalidate
*/
export async function invalidateSessionCaches(sessionToken: string): Promise<void> {
await invalidatePrivyTokenCache(sessionToken);
logger.debug("[AUTH] Invalidated all session caches (Privy + user)");
await Promise.all([
invalidatePrivyTokenCache(sessionToken),
invalidateStewardTokenCache(sessionToken),
]);
logger.debug("[AUTH] Invalidated all session caches (Privy + Steward + user)");
}

/**
Expand Down Expand Up @@ -507,8 +516,10 @@ export async function requireAuthOrApiKey(request: NextRequest): Promise<AuthRes
throw new AuthenticationError("Invalid authorization header");
}

// If the bearer token looks like a JWT, try to validate it as a Privy token first
// If the bearer token looks like a JWT, try to validate it as a Privy token first,
// then fall back to Steward JWT verification
if (looksLikeJwt(bearerValue)) {
// 1. Try Privy token verification
const verifiedClaims = await verifyAuthTokenCached(bearerValue);

if (verifiedClaims) {
Expand All @@ -533,6 +544,42 @@ export async function requireAuthOrApiKey(request: NextRequest): Promise<AuthRes
session_token: bearerValue,
};
}

// 2. Try Steward JWT verification
const stewardClaims = await verifyStewardTokenCached(bearerValue);

if (stewardClaims) {
// Look up user by Steward ID
const user = await usersService.getByStewardId(stewardClaims.userId);

if (user) {
if (!user.is_active) {
throw new ForbiddenError("User account is inactive");
}

if (!user.organization?.is_active) {
throw new ForbiddenError("Organization is inactive");
}

return {
user,
authMethod: "session",
session_token: bearerValue,
};
}

// TODO: JIT sync from Steward (mirrors Privy JIT sync above)
// Once syncUserFromSteward is implemented, uncomment:
// const syncedUser = await syncUserFromSteward(stewardClaims);
// if (syncedUser) {
// return { user: syncedUser, authMethod: "session", session_token: bearerValue };
// }

logger.warn("[AUTH] Steward JWT valid but no matching user", {
stewardUserId: stewardClaims.userId.substring(0, 20),
});
throw new AuthenticationError("User not found");
}
}

// Try as API key (fallback for non-JWT tokens or if JWT validation failed)
Expand Down Expand Up @@ -686,3 +733,8 @@ export {
invalidatePrivyTokenCache,
verifyAuthTokenCached,
} from "./auth/privy-client";

export {
invalidateStewardTokenCache,
verifyStewardTokenCached,
} from "./auth/steward-client";
Loading
Loading