diff --git a/apps/app-auth-demo/.env.example b/apps/app-auth-demo/.env.example new file mode 100644 index 00000000..9e6878e6 --- /dev/null +++ b/apps/app-auth-demo/.env.example @@ -0,0 +1,14 @@ +PORT=3000 +MEMWAL_WEB_URL=http://localhost:5173 +MEMWAL_API_URL=http://localhost:8000 +APP_LABEL=Demo App + +# Optional static credentials created by the Walrus Memory dashboard's hosted +# app clients manager. If unset, the demo can only self-register when the +# relayer explicitly enables APP_AUTH_PUBLIC_CLIENT_REGISTRATION_ENABLED=true. +# MEMWAL_CLIENT_ID=dev_localhost +# MEMWAL_CLIENT_SECRET=dev_localhost_secret + +# Required on Railway/deployed hosts so callback/fallback URLs use the public +# HTTPS origin and the state cookie is marked Secure. +# APP_BASE_URL=https://your-demo-app.example.com diff --git a/apps/app-auth-demo/Dockerfile b/apps/app-auth-demo/Dockerfile new file mode 100644 index 00000000..eac0ec8b --- /dev/null +++ b/apps/app-auth-demo/Dockerfile @@ -0,0 +1,21 @@ +# ============================================================ +# Walrus Memory App Auth Demo — Dockerfile +# +# Build from monorepo root: +# docker build -f apps/app-auth-demo/Dockerfile -t memwal-app-auth-demo . +# +# Root Directory on Railway must be "/" (repo root). +# ============================================================ + +FROM node:22-alpine AS runtime + +WORKDIR /app/apps/app-auth-demo + +COPY apps/app-auth-demo/package.json ./ +COPY apps/app-auth-demo/src ./src + +ENV NODE_ENV=production +ENV PORT=3000 +EXPOSE 3000 + +CMD ["node", "src/server.js"] diff --git a/apps/app-auth-demo/README.md b/apps/app-auth-demo/README.md new file mode 100644 index 00000000..b89bb774 --- /dev/null +++ b/apps/app-auth-demo/README.md @@ -0,0 +1,98 @@ +# Walrus Memory App Auth Demo + +Tiny backend-served demo app for testing `Connect Walrus Memory` from a third-party app. + +## Local + +```bash +pnpm dev:app-auth-demo +``` + +Default local config: + +```txt +PORT=3000 +MEMWAL_WEB_URL=http://localhost:5173 +MEMWAL_API_URL=http://localhost:8000 +APP_LABEL=Demo App +``` + +By default the demo can auto-register on first connect against a dev relayer, +but the production-like path is to create a hosted app client from the Walrus +Memory dashboard and set `MEMWAL_CLIENT_ID` / `MEMWAL_CLIENT_SECRET` on this +backend. Public self-registration rejects localhost and `*.memwal.ai` URLs; for +local demos, use the optional static `dev_localhost` client instead. + +## Deployed Demo App + +Deploy this demo app with: + +```txt +PORT=3000 +APP_BASE_URL=https://my-demo-app.example.com +MEMWAL_WEB_URL=https://dev.memwal.ai +MEMWAL_API_URL=https://relayer.dev.memwal.ai +APP_LABEL=Demo App +``` + +`APP_BASE_URL` is required on Railway and other deployed hosts. It makes the demo generate public HTTPS callback/fallback URLs behind the platform proxy and marks the state cookie `Secure`. + +The relayer must also be configured for the intended scale: + +- Staging/dev demo: prefer the dashboard's `hosted app clients` section so this app uses static backend credentials. +- Temporary self-registering demo: set `APP_AUTH_PUBLIC_CLIENT_REGISTRATION_ENABLED=true` on the relayer so this app can auto-register. +- Production: leave public registration disabled. An operator signs into the dashboard with `APP_AUTH_ADMIN_TOKEN`, creates the client, and gives the `client_id` / one-time `client_secret` to the dapp developer. + +Railway service config is included at `apps/app-auth-demo/railway.json` and uses `apps/app-auth-demo/Dockerfile`. Set Railway Root Directory to the repo root (`/`) so the Dockerfile path resolves correctly. + +Google Console does not need every dApp callback URL. Google/Enoki auth is handled by Walrus Memory, so Google Console only needs Walrus Memory origins/callbacks such as `https://dev.memwal.ai` and `https://memwal.ai`. + +## How Other Dapps Access It + +Third-party apps do not integrate Enoki directly. They register their backend +app once, redirect the browser to Walrus Memory hosted connect, and exchange the +returned code server-side. + +Register the app from the Walrus Memory dashboard: + +1. Open the dashboard. +2. Under `install`, open `hosted app clients`. +3. Sign in with `APP_AUTH_ADMIN_TOKEN`. +4. Create the dapp with exact callback/fallback URLs. +5. Copy the one-time `client_id` / `client_secret` into the dapp backend env. + +End users never create app clients. Store the returned `client_id` and +`client_secret` in your backend env. The client is active immediately unless an +operator later blocks it. Then send users to: + +```txt +https://dev.memwal.ai/connect/app?client_id=CLIENT_ID&redirect_uri=https%3A%2F%2Fmy-dapp.example.com%2Fapi%2Fmemwal%2Fcallback&state=RANDOM_STATE&label=My%20Dapp&intent=sdk_delegate&fallback_uri=https%3A%2F%2Fmy-dapp.example.com%2Fmemwal%2Ferror +``` + +Walrus Memory handles Google/Enoki or wallet auth on `dev.memwal.ai`. Your dapp +only receives `code + state`, then exchanges the code with HTTP Basic auth: + +```bash +curl -X POST "$MEMWAL_API_URL/api/app-auth/token" \ + -u "$CLIENT_ID:$CLIENT_SECRET" \ + -H 'content-type: application/json' \ + --data '{ + "grant_type": "authorization_code", + "code": "CODE_FROM_CALLBACK", + "redirect_uri": "https://my-dapp.example.com/api/memwal/callback", + "state": "ORIGINAL_RANDOM_STATE" + }' +``` + +## Copy-Paste Vercel/Railway Shape + +Use these environment variables in the deployed app: + +```txt +APP_BASE_URL=https://your-app.railway.app +MEMWAL_WEB_URL=https://dev.memwal.ai +MEMWAL_API_URL=https://relayer.dev.memwal.ai +APP_LABEL=Your App +MEMWAL_CLIENT_ID=app_... +MEMWAL_CLIENT_SECRET=mwas_... +``` diff --git a/apps/app-auth-demo/package.json b/apps/app-auth-demo/package.json new file mode 100644 index 00000000..77aa28f1 --- /dev/null +++ b/apps/app-auth-demo/package.json @@ -0,0 +1,14 @@ +{ + "name": "@memwal/app-auth-demo", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "node src/server.js", + "build": "node --check src/server.js", + "start": "node src/server.js" + }, + "engines": { + "node": ">=22" + } +} diff --git a/apps/app-auth-demo/railway.json b/apps/app-auth-demo/railway.json new file mode 100644 index 00000000..85001249 --- /dev/null +++ b/apps/app-auth-demo/railway.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://railway.com/railway.schema.json", + "build": { + "builder": "DOCKERFILE", + "dockerfilePath": "apps/app-auth-demo/Dockerfile", + "watchPatterns": [ + "apps/app-auth-demo/**" + ] + }, + "deploy": { + "runtime": "V2", + "numReplicas": 1, + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 10 + } +} diff --git a/apps/app-auth-demo/src/server.js b/apps/app-auth-demo/src/server.js new file mode 100644 index 00000000..7f1d73b5 --- /dev/null +++ b/apps/app-auth-demo/src/server.js @@ -0,0 +1,518 @@ +import { createServer } from 'node:http' +import { randomBytes, timingSafeEqual } from 'node:crypto' + +const PORT = Number(process.env.PORT || 3000) +const MEMWAL_WEB_URL = process.env.MEMWAL_WEB_URL || 'http://localhost:5173' +const MEMWAL_API_URL = process.env.MEMWAL_API_URL || 'http://localhost:8000' +const STATIC_MEMWAL_CLIENT_ID = (process.env.MEMWAL_CLIENT_ID || '').trim() +const STATIC_MEMWAL_CLIENT_SECRET = (process.env.MEMWAL_CLIENT_SECRET || '').trim() +const APP_LABEL = safeAppLabel(process.env.APP_LABEL || 'Demo App') +const APP_BASE_URL = normalizeAppBaseUrl(process.env.APP_BASE_URL) +const COOKIE_NAME = 'memwal_demo_state' + +let dynamicClient = null +let dynamicClientPromise = null + +function safeAppLabel(raw) { + const label = String(raw || '').trim() || 'Demo App' + const normalized = label.replace(/[^a-z0-9]/gi, '').toLowerCase() + if (normalized.includes('memwal') || normalized.includes('walrusmemory')) { + return 'Demo App' + } + return label +} + +function normalizeAppBaseUrl(raw) { + if (!raw || !raw.trim()) return '' + const url = new URL(raw) + url.hash = '' + url.search = '' + url.pathname = '' + if (url.protocol !== 'https:' && !['localhost', '127.0.0.1'].includes(url.hostname)) { + throw new Error('APP_BASE_URL must use https unless it is localhost') + } + return url.toString().replace(/\/$/, '') +} + +function htmlEscape(value) { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') +} + +function parseCookies(req) { + const out = new Map() + for (const pair of (req.headers.cookie || '').split(';')) { + const [rawName, ...rawValue] = pair.trim().split('=') + if (!rawName) continue + out.set(rawName, decodeURIComponent(rawValue.join('='))) + } + return out +} + +function requestOrigin(req) { + if (APP_BASE_URL) return APP_BASE_URL + const host = req.headers.host || `localhost:${PORT}` + return `http://${host}` +} + +function callbackUrl(req) { + return `${requestOrigin(req)}/api/memwal/callback` +} + +function errorUrl(req) { + return `${requestOrigin(req)}/memwal/error` +} + +function randomState() { + return randomBytes(24).toString('base64url') +} + +function sameState(left, right) { + const a = Buffer.from(left || '') + const b = Buffer.from(right || '') + return a.length === b.length && timingSafeEqual(a, b) +} + +function writeHtml(res, status, body) { + res.writeHead(status, { + 'content-type': 'text/html; charset=utf-8', + 'cache-control': 'no-store', + }) + res.end(body) +} + +function redirect(res, location, headers = {}) { + res.writeHead(302, { location, ...headers }) + res.end() +} + +function usesSecureCookies(req) { + if (APP_BASE_URL) { + return new URL(APP_BASE_URL).protocol === 'https:' + } + return requestOrigin(req).startsWith('https://') +} + +function stateCookie(state, req) { + const secure = usesSecureCookies(req) ? '; Secure' : '' + return `${COOKIE_NAME}=${encodeURIComponent(state)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=600${secure}` +} + +function modeLabel() { + return APP_BASE_URL ? 'Deployed app' : 'Local app' +} + +function configuredClientId() { + return STATIC_MEMWAL_CLIENT_ID || dynamicClient?.client_id || 'auto-register on first connect' +} + +function hasStaticClient() { + return Boolean(STATIC_MEMWAL_CLIENT_ID && STATIC_MEMWAL_CLIENT_SECRET) +} + +async function registerDynamicClient(req) { + const response = await fetch(new URL('/api/app-auth/clients', MEMWAL_API_URL), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + display_name: APP_LABEL, + redirect_uris: [callbackUrl(req)], + fallback_uris: [errorUrl(req)], + }), + }) + const payload = await response.json().catch(() => ({})) + if (!response.ok) { + throw new Error(payload.error || `client registration failed (${response.status})`) + } + if (!payload.client_id || !payload.client_secret) { + throw new Error('client registration did not return credentials') + } + if (payload.status && payload.status !== 'active') { + throw new Error(`client registration returned ${payload.status} credentials`) + } + return { + origin: requestOrigin(req), + client_id: payload.client_id, + client_secret: payload.client_secret, + } +} + +async function appClient(req) { + if (hasStaticClient()) { + return { + origin: requestOrigin(req), + client_id: STATIC_MEMWAL_CLIENT_ID, + client_secret: STATIC_MEMWAL_CLIENT_SECRET, + } + } + + const origin = requestOrigin(req) + if (dynamicClient?.origin === origin) return dynamicClient + + if (!dynamicClientPromise || dynamicClientPromise.origin !== origin) { + dynamicClientPromise = registerDynamicClient(req) + dynamicClientPromise.origin = origin + } + dynamicClient = await dynamicClientPromise + return dynamicClient +} + +function page({ title, eyebrow, body, result, req }) { + const resultHtml = result + ? `
${htmlEscape(JSON.stringify(result, null, 2))}
` + : '' + const callback = req ? callbackUrl(req) : `${requestOrigin({ headers: {} })}/api/memwal/callback` + const fallback = req ? errorUrl(req) : `${requestOrigin({ headers: {} })}/memwal/error` + + return ` + + + + + ${htmlEscape(title)} + + + +
+
Demo App
+ Third-party backend sample +
+
+
+
${htmlEscape(eyebrow)}
+ ${body} +
+ +
+ +` +} + +function home(req, res) { + const previewClientId = STATIC_MEMWAL_CLIENT_ID || dynamicClient?.client_id + const previewHref = previewClientId + ? `${MEMWAL_WEB_URL}/connect/app?client_id=${encodeURIComponent(previewClientId)}&redirect_uri=${encodeURIComponent(callbackUrl(req))}&state=preview_state&label=${encodeURIComponent(APP_LABEL)}&intent=sdk_delegate&fallback_uri=${encodeURIComponent(errorUrl(req))}` + : '/connect/memwal' + const previewText = previewClientId ? 'Preview auth URL' : 'Register and preview' + + writeHtml(res, 200, page({ + title: 'Walrus Memory App Auth Demo', + eyebrow: 'Local backend app', + body: ` +

Connect Walrus Memory from another app

+

This demo behaves like a third-party app with its own backend. It registers itself with Walrus Memory, sends the browser to hosted connect, then exchanges the returned one-time code server-side.

+
+ Connect Walrus Memory + ${htmlEscape(previewText)} +
+ `, + req, + })) +} + +async function startConnect(req, res) { + try { + const client = await appClient(req) + const state = randomState() + const authUrl = new URL('/connect/app', MEMWAL_WEB_URL) + authUrl.searchParams.set('client_id', client.client_id) + authUrl.searchParams.set('redirect_uri', callbackUrl(req)) + authUrl.searchParams.set('state', state) + authUrl.searchParams.set('label', APP_LABEL) + authUrl.searchParams.set('intent', 'sdk_delegate') + authUrl.searchParams.set('fallback_uri', errorUrl(req)) + + redirect(res, authUrl.toString(), { + 'set-cookie': stateCookie(state, req), + }) + } catch (err) { + writeHtml(res, 502, page({ + title: 'Walrus Memory client registration failed', + eyebrow: 'Registration failed', + body: ` +

Client registration failed

+

${htmlEscape(err instanceof Error ? err.message : String(err))}

+
Back
+ `, + req, + })) + } +} + +async function handleCallback(req, res, url) { + const cookies = parseCookies(req) + const expectedState = cookies.get(COOKIE_NAME) + const state = url.searchParams.get('state') || '' + const code = url.searchParams.get('code') || '' + + if (!expectedState || !sameState(expectedState, state)) { + writeHtml(res, 400, page({ + title: 'Walrus Memory callback failed', + eyebrow: 'State mismatch', + body: ` +

State check failed

+

The callback did not match the state stored by this demo backend.

+
Back
+ `, + req, + })) + return + } + + if (!code) { + writeHtml(res, 400, page({ + title: 'Walrus Memory callback missing code', + eyebrow: 'Missing code', + body: ` +

No code returned

+

Walrus Memory did not return an authorization code.

+
Back
+ `, + req, + })) + return + } + + try { + const client = await appClient(req) + const tokenUrl = new URL('/api/app-auth/token', MEMWAL_API_URL) + const auth = Buffer.from(`${client.client_id}:${client.client_secret}`).toString('base64') + const response = await fetch(tokenUrl, { + method: 'POST', + headers: { + authorization: `Basic ${auth}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + grant_type: 'authorization_code', + code, + redirect_uri: callbackUrl(req), + state, + }), + }) + const payload = await response.json().catch(() => ({})) + if (!response.ok) { + throw new Error(payload.error || `token exchange failed (${response.status})`) + } + + writeHtml(res, 200, page({ + title: 'Walrus Memory connected', + eyebrow: 'Connected', + result: payload, + body: ` +

Walrus Memory connected

+

The browser only received a short-lived code. This backend exchanged it for account and delegate reference data.

+
Run again
+ `, + req, + })) + } catch (err) { + writeHtml(res, 502, page({ + title: 'Walrus Memory token exchange failed', + eyebrow: 'Exchange failed', + body: ` +

Token exchange failed

+

${htmlEscape(err instanceof Error ? err.message : String(err))}

+
Back
+ `, + req, + })) + } +} + +function handleError(req, res, url) { + writeHtml(res, 200, page({ + title: 'Walrus Memory connect cancelled', + eyebrow: 'Walrus Memory returned an error', + result: Object.fromEntries(url.searchParams.entries()), + body: ` +

Connect was not completed

+

Walrus Memory redirected to this safe fallback route with an error and the original state.

+
Back
+ `, + req, + })) +} + +const server = createServer((req, res) => { + const url = new URL(req.url || '/', requestOrigin(req)) + if (req.method === 'GET' && url.pathname === '/') return home(req, res) + if (req.method === 'GET' && url.pathname === '/connect/memwal') return void startConnect(req, res) + if (req.method === 'GET' && url.pathname === '/api/memwal/callback') return void handleCallback(req, res, url) + if (req.method === 'GET' && url.pathname === '/memwal/error') return handleError(req, res, url) + + writeHtml(res, 404, page({ + title: 'Not found', + eyebrow: '404', + body: ` +

Not found

+

This demo route does not exist.

+
Back
+ `, + req, + })) +}) + +server.listen(PORT, () => { + console.log(`Walrus Memory app-auth demo running at http://localhost:${PORT}`) +}) diff --git a/apps/app/.env.example b/apps/app/.env.example index 53cd05bf..fd300083 100644 --- a/apps/app/.env.example +++ b/apps/app/.env.example @@ -11,6 +11,11 @@ VITE_ENOKI_API_KEY=enoki_public_9aac56cf5c1e5b1254d1fa09bb6e9f0c # Google OAuth Client ID (from Google Cloud Console) VITE_GOOGLE_CLIENT_ID=386692102434-pn0enkrr12r5q3arflsfrrvb14rvhs10.apps.googleusercontent.com +# Optional explicit OAuth callback. Defaults to ${window.location.origin}/. +# Use the root URL unless the exact callback is registered in Google Console. +# Local dev can leave this blank. +VITE_ENOKI_REDIRECT_URL= + # Walrus Memory Server URL (also handles /sponsor and /sponsor/execute for gasless tx) VITE_MEMWAL_SERVER_URL=https://relayer.dev.memwal.ai diff --git a/apps/app/src/App.tsx b/apps/app/src/App.tsx index 41192f32..c546782f 100644 --- a/apps/app/src/App.tsx +++ b/apps/app/src/App.tsx @@ -17,7 +17,7 @@ import { import { isEnokiNetwork, registerEnokiWallets } from '@mysten/enoki' import { getJsonRpcFullnodeUrl } from '@mysten/sui/jsonRpc' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' +import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom' import { config } from './config' import LandingPage from './pages/LandingPage' @@ -25,6 +25,7 @@ import Dashboard from './pages/Dashboard' import SetupWizard from './pages/SetupWizard' import Playground from './pages/Playground' import ConnectMcp from './pages/ConnectMcp' +import ConnectApp from './pages/ConnectApp' import '@mysten/dapp-kit/dist/index.css' @@ -179,7 +180,10 @@ function RegisterEnokiWallets() { const { unregister } = registerEnokiWallets({ apiKey: config.enokiApiKey, providers: { - google: { clientId: config.googleClientId }, + google: { + clientId: config.googleClientId, + redirectUrl: config.enokiRedirectUrl || `${window.location.origin}/`, + }, }, client, network, @@ -191,6 +195,81 @@ function RegisterEnokiWallets() { return null } +function EnokiCallback() { + return ( +
+

Finishing sign in...

+
+ ) +} + +function LocalAppAuthCallback() { + const { search } = useLocation() + const params = new URLSearchParams(search) + const code = params.get('code') + const state = params.get('state') + const error = params.get('error') + + return ( +
+
+

+ Local Walrus Memory callback +

+

+ This local test page only shows the browser callback result. The app backend must exchange the code server-side. +

+
+
+
+ code +
+
+ {code || '-'} +
+
+
+
+ state +
+
+ {state || '-'} +
+
+
+
+ error +
+
+ {error || '-'} +
+
+
+
+
+ ) +} + // ============================================================ // App content — route based on auth + key state // ============================================================ @@ -214,6 +293,24 @@ function AppContent() { delegateKey ? : } /> } /> + } /> + } /> + {/* ENG-1783 review N1 (2026-05-26): LocalAppAuthCallback is for local + third-party dev only (when the demo app shares an origin with this + Vite dev server). Registering these routes in production builds + would let a malicious app register `memwal.ai/api/memwal/callback` + as an allowed redirect_uri — the consent screen would say "Return + to memwal.ai" (which looks safe to users), the code would land + here and silently render the query string, and the attacker would + observe nothing in the address bar. Code exchange still requires + client_secret so they can't escalate, but the UX confusion is the + phishing primitive — gating to DEV removes it entirely. */} + {import.meta.env.DEV && ( + <> + } /> + } /> + + )} } /> ) diff --git a/apps/app/src/config.ts b/apps/app/src/config.ts index b14f7734..63d34350 100644 --- a/apps/app/src/config.ts +++ b/apps/app/src/config.ts @@ -4,6 +4,7 @@ export const config = { enokiApiKey: import.meta.env.VITE_ENOKI_API_KEY as string || '', googleClientId: import.meta.env.VITE_GOOGLE_CLIENT_ID as string || '', + enokiRedirectUrl: import.meta.env.VITE_ENOKI_REDIRECT_URL as string || '', memwalPackageId: import.meta.env.VITE_MEMWAL_PACKAGE_ID as string || '0xcf6ad755a1cdff7217865c796778fabe5aa399cb0cf2eba986f4b582047229c6', memwalRegistryId: import.meta.env.VITE_MEMWAL_REGISTRY_ID as string || diff --git a/apps/app/src/index.css b/apps/app/src/index.css index 2dae54f2..e5ae9a98 100644 --- a/apps/app/src/index.css +++ b/apps/app/src/index.css @@ -1164,6 +1164,225 @@ h1, h2, h3 { background: var(--bg-card-hover); } +/* ========== Hosted App Client Manager ========== */ + +.app-auth-manager { + background: #ffffff; +} + +.app-auth-error { + background: rgba(239, 68, 68, 0.10); + border: 2px solid var(--danger-border); + border-radius: var(--radius-md); + color: var(--danger); + font-size: 0.84rem; + font-weight: 700; + padding: 10px 14px; + margin-bottom: 14px; +} + +.app-auth-login { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 16px; + align-items: center; +} + +.app-auth-login-icon { + width: 48px; + height: 48px; + display: grid; + place-items: center; + border: var(--border); + border-radius: var(--radius-md); + background: var(--color-violet); + box-shadow: var(--neo-shadow-xs); +} + +.app-auth-login-title, +.app-auth-form-title, +.app-auth-client-title { + font-size: 1rem; + font-weight: 850; + color: var(--text-primary); +} + +.app-auth-login-subtitle, +.app-auth-client-id { + color: var(--text-muted); + font-size: 0.82rem; +} + +.app-auth-login-controls { + grid-column: 1 / -1; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: center; +} + +.app-auth-summary { + display: grid; + grid-template-columns: repeat(2, minmax(0, 120px)) auto; + gap: 12px; + align-items: stretch; + margin-bottom: 16px; +} + +.app-auth-summary > div { + border: var(--border); + border-radius: var(--radius-md); + background: var(--color-yellow); + box-shadow: var(--neo-shadow-xs); + padding: 10px 12px; +} + +.app-auth-summary span { + display: block; + font-size: 1.15rem; + font-weight: 900; +} + +.app-auth-summary small { + color: var(--text-secondary); + font-size: 0.72rem; + font-weight: 750; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.app-auth-secret { + margin-bottom: 16px; +} + +.app-auth-form { + border: var(--border); + border-radius: var(--radius-md); + background: #FAF8F5; + box-shadow: var(--neo-shadow-sm); + padding: 16px; + margin-bottom: 16px; +} + +.app-auth-form-title { + margin-bottom: 12px; +} + +.app-auth-textarea { + min-height: 84px; + resize: vertical; + line-height: 1.45; +} + +.app-auth-form-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; +} + +.app-auth-client-list { + display: flex; + flex-direction: column; + gap: 14px; +} + +.app-auth-client { + border: var(--border); + border-radius: var(--radius-md); + background: #ffffff; + box-shadow: var(--neo-shadow-sm); + padding: 16px; +} + +.app-auth-client-main { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: flex-start; +} + +.app-auth-client-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} + +.app-auth-status { + display: inline-flex; + align-items: center; + gap: 4px; + margin-left: 8px; + padding: 2px 8px; + border: 1.5px solid #000000; + border-radius: 999px; + font-size: 0.68rem; + font-weight: 850; + text-transform: uppercase; + vertical-align: middle; +} + +.app-auth-status--active { + background: #dcfce7; + color: #166534; +} + +.app-auth-status--blocked { + background: #fee2e2; + color: #991b1b; +} + +.app-auth-uri-block { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + margin-top: 14px; +} + +.app-auth-uri-block > div { + min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.app-auth-uri-block strong { + font-size: 0.7rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.app-auth-uri-block code { + display: block; + padding: 8px 10px; + border: 1px solid rgba(0, 0, 0, 0.18); + border-radius: var(--radius-sm); + background: #FAF8F5; + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 0.72rem; + line-height: 1.4; + overflow-wrap: anywhere; +} + +@media (max-width: 760px) { + .app-auth-login-controls, + .app-auth-summary, + .app-auth-uri-block { + grid-template-columns: 1fr; + } + + .app-auth-client-main { + flex-direction: column; + } + + .app-auth-client-actions { + justify-content: flex-start; + } +} + /* ========== Delegate Key List ========== */ .key-list { diff --git a/apps/app/src/pages/AppAuthClientManager.tsx b/apps/app/src/pages/AppAuthClientManager.tsx new file mode 100644 index 00000000..422d2968 --- /dev/null +++ b/apps/app/src/pages/AppAuthClientManager.tsx @@ -0,0 +1,518 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { + Ban, + CheckCircle2, + Copy, + KeyRound, + LogIn, + LogOut, + Plus, + RefreshCw, + RotateCcw, + Save, + ShieldCheck, + Unlock, +} from 'lucide-react' +import { config } from '../config' + +interface AdminSession { + token: string + expiresAt: number +} + +interface AppAuthClient { + client_id: string + display_name: string + allowed_redirect_uris: string[] + fallback_uri: string | null + allowed_fallback_uris: string[] + status: 'active' | 'blocked' + created_at: string + updated_at: string +} + +interface ClientFormState { + displayName: string + redirectUris: string + fallbackUris: string +} + +interface SecretResult { + clientId: string + clientSecret: string +} + +const STORAGE_KEY = 'memwal_app_auth_admin_session' + +const emptyForm: ClientFormState = { + displayName: '', + redirectUris: '', + fallbackUris: '', +} + +function loadSession(): AdminSession | null { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return null + const session = JSON.parse(raw) as AdminSession + if (!session.token || Date.now() > session.expiresAt) { + localStorage.removeItem(STORAGE_KEY) + return null + } + return session + } catch { + localStorage.removeItem(STORAGE_KEY) + return null + } +} + +function saveSession(token: string, expiresAt: string) { + const parsed = Date.parse(expiresAt) + const session: AdminSession = { + token, + expiresAt: Number.isFinite(parsed) ? parsed : Date.now() + 2 * 60 * 60 * 1000, + } + localStorage.setItem(STORAGE_KEY, JSON.stringify(session)) + return session +} + +function parseUriList(value: string): string[] { + return value + .split(/[\n,]/) + .map((entry) => entry.trim()) + .filter(Boolean) +} + +function formFromClient(client: AppAuthClient): ClientFormState { + return { + displayName: client.display_name, + redirectUris: client.allowed_redirect_uris.join('\n'), + fallbackUris: client.allowed_fallback_uris.join('\n'), + } +} + +function shortId(value: string) { + return value.length > 22 ? `${value.slice(0, 12)}...${value.slice(-8)}` : value +} + +export default function AppAuthClientManager() { + const [session, setSession] = useState(() => loadSession()) + const [adminToken, setAdminToken] = useState('') + const [clients, setClients] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const [copied, setCopied] = useState(null) + const [showCreate, setShowCreate] = useState(false) + const [createForm, setCreateForm] = useState(emptyForm) + const [editingClientId, setEditingClientId] = useState(null) + const [editForm, setEditForm] = useState(emptyForm) + const [secretResult, setSecretResult] = useState(null) + + const activeClients = useMemo( + () => clients.filter((client) => client.status === 'active').length, + [clients], + ) + + const logout = useCallback(() => { + localStorage.removeItem(STORAGE_KEY) + setSession(null) + setClients([]) + setSecretResult(null) + setError('') + }, []) + + const api = useCallback(async (path: string, init: RequestInit = {}): Promise => { + if (!session?.token) throw new Error('admin login required') + const res = await fetch(`${config.memwalServerUrl}${path}`, { + ...init, + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${session.token}`, + ...(init.headers || {}), + }, + }) + const data = await res.json().catch(() => ({})) + if (!res.ok) { + if (res.status === 401) logout() + throw new Error(typeof data?.error === 'string' ? data.error : `request failed (${res.status})`) + } + return data as T + }, [logout, session?.token]) + + const loadClients = useCallback(async () => { + if (!session?.token) return + setLoading(true) + setError('') + try { + const data = await api<{ clients: AppAuthClient[] }>('/api/admin/app-auth/clients') + setClients(data.clients) + } catch (err) { + setError(err instanceof Error ? err.message : 'failed to load app clients') + } finally { + setLoading(false) + } + }, [api, session?.token]) + + useEffect(() => { + void loadClients() + }, [loadClients]) + + const login = useCallback(async () => { + setLoading(true) + setError('') + try { + const res = await fetch(`${config.memwalServerUrl}/api/admin/app-auth/login`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ admin_token: adminToken }), + }) + const data = await res.json().catch(() => ({})) + if (!res.ok) { + throw new Error(typeof data?.error === 'string' ? data.error : 'admin login failed') + } + const next = saveSession(data.token, data.expires_at) + setSession(next) + setAdminToken('') + } catch (err) { + setError(err instanceof Error ? err.message : 'admin login failed') + } finally { + setLoading(false) + } + }, [adminToken]) + + const copy = useCallback(async (text: string, label: string) => { + await navigator.clipboard.writeText(text) + setCopied(label) + setTimeout(() => setCopied(null), 1600) + }, []) + + const createClient = useCallback(async () => { + setLoading(true) + setError('') + setSecretResult(null) + try { + const data = await api<{ + client_id: string + client_secret: string + }>('/api/admin/app-auth/clients', { + method: 'POST', + body: JSON.stringify({ + display_name: createForm.displayName, + redirect_uris: parseUriList(createForm.redirectUris), + fallback_uris: parseUriList(createForm.fallbackUris), + }), + }) + setSecretResult({ clientId: data.client_id, clientSecret: data.client_secret }) + setCreateForm(emptyForm) + setShowCreate(false) + await loadClients() + } catch (err) { + setError(err instanceof Error ? err.message : 'failed to create app client') + } finally { + setLoading(false) + } + }, [api, createForm, loadClients]) + + const updateClient = useCallback(async (client: AppAuthClient) => { + setLoading(true) + setError('') + try { + await api(`/api/admin/app-auth/clients/${encodeURIComponent(client.client_id)}`, { + method: 'PATCH', + body: JSON.stringify({ + display_name: editForm.displayName, + redirect_uris: parseUriList(editForm.redirectUris), + fallback_uris: parseUriList(editForm.fallbackUris), + status: client.status, + }), + }) + setEditingClientId(null) + await loadClients() + } catch (err) { + setError(err instanceof Error ? err.message : 'failed to update app client') + } finally { + setLoading(false) + } + }, [api, editForm, loadClients]) + + const setClientStatus = useCallback(async (client: AppAuthClient, status: 'active' | 'blocked') => { + setLoading(true) + setError('') + try { + const action = status === 'blocked' ? 'block' : 'unblock' + await api(`/api/admin/app-auth/clients/${encodeURIComponent(client.client_id)}/${action}`, { + method: 'POST', + }) + await loadClients() + } catch (err) { + setError(err instanceof Error ? err.message : 'failed to update app client status') + } finally { + setLoading(false) + } + }, [api, loadClients]) + + const rotateSecret = useCallback(async (client: AppAuthClient) => { + if (!confirm(`rotate secret for ${client.display_name}? existing backend env values will stop working.`)) return + setLoading(true) + setError('') + setSecretResult(null) + try { + const data = await api<{ client_id: string; client_secret: string }>( + `/api/admin/app-auth/clients/${encodeURIComponent(client.client_id)}/rotate-secret`, + { method: 'POST' }, + ) + setSecretResult({ clientId: data.client_id, clientSecret: data.client_secret }) + await loadClients() + } catch (err) { + setError(err instanceof Error ? err.message : 'failed to rotate client secret') + } finally { + setLoading(false) + } + }, [api, loadClients]) + + const beginEdit = (client: AppAuthClient) => { + setEditingClientId(client.client_id) + setEditForm(formFromClient(client)) + } + + return ( +
+
+
+
hosted app clients
+
+ admin-managed credentials for third-party apps connecting to Walrus Memory +
+
+ {session && ( +
+ + +
+ )} +
+ +
+

+ production posture: app clients are created by an authenticated operator. end users only click connect from a dapp. +

+
+ + {error &&
{error}
} + + {!session ? ( +
+
+
+
sign in to manage clients
+
+ use the operator token to create a short-lived admin session +
+
+
+ setAdminToken(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter' && adminToken.trim()) void login() + }} + /> + +
+
+ ) : ( + <> +
+
+ {clients.length} + total apps +
+
+ {activeClients} + active +
+ +
+ + {secretResult && ( +
+
client secret shown once
+
+ MEMWAL_CLIENT_ID={secretResult.clientId} +
+ MEMWAL_CLIENT_SECRET={secretResult.clientSecret} +
+
+ + +
+
+ )} + + {showCreate && ( + setShowCreate(false)} + onSubmit={createClient} + /> + )} + + {loading && clients.length === 0 ? ( +
loading hosted app clients...
+ ) : clients.length === 0 ? ( +
no hosted app clients yet
+ ) : ( +
+ {clients.map((client) => ( +
+
+
+
+ {client.display_name} + + {client.status === 'active' ? : } + {client.status} + +
+
+ {shortId(client.client_id)} +
+
+
+ + + + {client.status === 'active' ? ( + + ) : ( + + )} +
+
+ +
+
+ redirect + {client.allowed_redirect_uris.map((uri) => {uri})} +
+
+ fallback + {client.allowed_fallback_uris.length > 0 + ? client.allowed_fallback_uris.map((uri) => {uri}) + : none} +
+
+ + {editingClientId === client.client_id && ( + setEditingClientId(null)} + onSubmit={() => updateClient(client)} + /> + )} +
+ ))} +
+ )} + + )} +
+ ) +} + +function ClientForm({ + title, + form, + setForm, + loading, + primaryLabel, + onCancel, + onSubmit, +}: { + title: string + form: ClientFormState + setForm: (next: ClientFormState) => void + loading: boolean + primaryLabel: string + onCancel: () => void + onSubmit: () => void +}) { + return ( +
+
{title}
+
+ + setForm({ ...form, displayName: event.target.value })} + /> +
+
+ +