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}
+
+
+ Config
+
+
Mode ${htmlEscape(modeLabel())}
+
App base URL ${htmlEscape(APP_BASE_URL || 'request host')}
+
Client ${htmlEscape(configuredClientId())}
+
Walrus Memory web ${htmlEscape(MEMWAL_WEB_URL)}
+
Walrus Memory API ${htmlEscape(MEMWAL_API_URL)}
+
Callback ${htmlEscape(callback)}
+
Fallback ${htmlEscape(fallback)}
+
+ ${resultHtml}
+
+
+
+`
+}
+
+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.
+
+ `,
+ 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))}
+
+ `,
+ 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.
+
+ `,
+ 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.
+
+ `,
+ 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.
+
+ `,
+ 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))}
+
+ `,
+ 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.
+
+ `,
+ 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.
+
+ `,
+ 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 && (
+
+
+ refresh
+
+
+ logout
+
+
+ )}
+
+
+
+
+ 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()
+ }}
+ />
+
+ sign in
+
+
+
+ ) : (
+ <>
+
+
+ {clients.length}
+ total apps
+
+
+ {activeClients}
+ active
+
+
setShowCreate((value) => !value)}>
+ new app
+
+
+
+ {secretResult && (
+
+
client secret shown once
+
+ MEMWAL_CLIENT_ID={secretResult.clientId}
+
+ MEMWAL_CLIENT_SECRET={secretResult.clientSecret}
+
+
+ copy(`MEMWAL_CLIENT_ID=${secretResult.clientId}\nMEMWAL_CLIENT_SECRET=${secretResult.clientSecret}`, 'client-secret')}
+ >
+ {copied === 'client-secret' ? 'copied!' : 'copy env'}
+
+ setSecretResult(null)}>
+ done
+
+
+
+ )}
+
+ {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)}
+
+
+
+ copy(client.client_id, client.client_id)}>
+ {copied === client.client_id ? 'copied!' : 'copy id'}
+
+ rotateSecret(client)} disabled={loading}>
+ rotate
+
+ beginEdit(client)}>
+ edit
+
+ {client.status === 'active' ? (
+ setClientStatus(client, 'blocked')} disabled={loading}>
+ block
+
+ ) : (
+ setClientStatus(client, 'active')} disabled={loading}>
+ unblock
+
+ )}
+
+
+
+
+
+ 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}
+
+ display name
+ setForm({ ...form, displayName: event.target.value })}
+ />
+
+
+ redirect URIs
+
+
+ fallback URIs
+
+
+
+ cancel
+
+
+ {primaryLabel}
+
+
+
+ )
+}
diff --git a/apps/app/src/pages/ConnectApp.tsx b/apps/app/src/pages/ConnectApp.tsx
new file mode 100644
index 00000000..099a433c
--- /dev/null
+++ b/apps/app/src/pages/ConnectApp.tsx
@@ -0,0 +1,672 @@
+/**
+ * Connect App — hosted Walrus Memory app-auth flow for third-party backend apps.
+ *
+ * Flow:
+ * 1. Read /connect/app query params and ask the relayer to create a
+ * short-lived app-auth session.
+ * 2. User signs in with Google/Enoki or a Sui wallet.
+ * 3. Browser registers the server-generated delegate public key on-chain.
+ * 4. Browser tells the relayer the tx/account result.
+ * 5. Relayer verifies on-chain state and returns a safe app redirect URL.
+ *
+ * The browser never receives the delegate private key, token exchange secret,
+ * bearer credential, or any long-lived app credential.
+ */
+
+import { useCallback, useEffect, useMemo, useState, type CSSProperties } from 'react'
+import {
+ ConnectModal,
+ useConnectWallet,
+ useCurrentAccount,
+ useSuiClient,
+ useWallets,
+} from '@mysten/dapp-kit'
+import { isEnokiWallet, type EnokiWallet, type AuthProvider } from '@mysten/enoki'
+import { Transaction } from '@mysten/sui/transactions'
+import { Link, useSearchParams } from 'react-router-dom'
+import { useSponsoredTransaction } from '../hooks/useSponsoredTransaction'
+import { config } from '../config'
+
+type Step = 'loading' | 'consent' | 'registering' | 'redirecting' | 'error'
+type Provider = 'wallet' | 'google'
+
+const MAX_DELEGATE_KEYS = 20
+const MAX_DELEGATE_KEYS_MESSAGE =
+ `This Walrus Memory account already has ${MAX_DELEGATE_KEYS} delegate keys. ` +
+ 'Open the dashboard and remove an unused key, then retry Connect App.'
+
+interface AppAuthSession {
+ session_id: string
+ client: {
+ client_id: string
+ display_name: string
+ }
+ redirect_host: string
+ label: string
+ expires_at: string
+ delegate: {
+ public_key: string
+ sui_address: string
+ }
+}
+
+interface AppAuthRedirectResponse {
+ redirect_url: string
+}
+
+function hexToBytes(hex: string): number[] {
+ const clean = hex.startsWith('0x') ? hex.slice(2) : hex
+ const out: number[] = []
+ for (let i = 0; i < clean.length; i += 2) {
+ out.push(parseInt(clean.slice(i, i + 2), 16))
+ }
+ return out
+}
+
+function isAddDelegateAbort(err: unknown, abortCode: number): boolean {
+ const message = err instanceof Error ? err.message : String(err)
+ return message.includes(`abort code: ${abortCode}`) && message.includes('add_delegate_key')
+}
+
+async function resolveAccountId(
+ suiClient: ReturnType,
+ ownerAddress: string,
+): Promise {
+ try {
+ const registryObj = await suiClient.getObject({
+ id: config.memwalRegistryId,
+ options: { showContent: true },
+ })
+ if (registryObj?.data?.content && 'fields' in registryObj.data.content) {
+ const fields = registryObj.data.content.fields as any
+ const tableId = fields?.accounts?.fields?.id?.id
+ if (tableId) {
+ const dynField = await suiClient.getDynamicFieldObject({
+ parentId: tableId,
+ name: { type: 'address', value: ownerAddress },
+ })
+ if (dynField?.data?.content && 'fields' in dynField.data.content) {
+ return (dynField.data.content.fields as any).value as string
+ }
+ }
+ }
+ } catch {
+ return null
+ }
+ return null
+}
+
+async function fetchDelegateKeyCount(
+ suiClient: ReturnType,
+ accountId: string,
+): Promise {
+ const accountObj = await suiClient.getObject({
+ id: accountId,
+ options: { showContent: true },
+ })
+ if (accountObj?.data?.content && 'fields' in accountObj.data.content) {
+ const fields = accountObj.data.content.fields as any
+ return Array.isArray(fields?.delegate_keys) ? fields.delegate_keys.length : 0
+ }
+ return 0
+}
+
+async function apiPost(path: string, body: unknown): Promise {
+ const res = await fetch(`${config.memwalServerUrl}${path}`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(body),
+ })
+ if (!res.ok) {
+ let message = `request failed (${res.status})`
+ try {
+ const data = await res.json()
+ if (typeof data?.error === 'string') message = data.error
+ } catch {
+ // keep status fallback
+ }
+ throw new Error(message)
+ }
+ return res.json() as Promise
+}
+
+export default function ConnectApp() {
+ const [params] = useSearchParams()
+ const currentAccount = useCurrentAccount()
+ const suiClient = useSuiClient()
+ const wallets = useWallets()
+ const { mutate: connect } = useConnectWallet()
+ const { mutateAsync: signAndExecute } = useSponsoredTransaction()
+
+ const [step, setStep] = useState('loading')
+ const [session, setSession] = useState(null)
+ const [errorMsg, setErrorMsg] = useState('')
+ const [walletPickerOpen, setWalletPickerOpen] = useState(false)
+ const [provider, setProvider] = useState('wallet')
+ const [delegateKeyCount, setDelegateKeyCount] = useState(null)
+ const [checkingDelegateKeys, setCheckingDelegateKeys] = useState(false)
+
+ const request = useMemo(() => ({
+ client_id: params.get('client_id') ?? '',
+ redirect_uri: params.get('redirect_uri') ?? '',
+ state: params.get('state') ?? '',
+ label: params.get('label') || undefined,
+ intent: params.get('intent') ?? '',
+ fallback_uri: params.get('fallback_uri') || undefined,
+ }), [params])
+
+ const enokiWallets = wallets.filter(isEnokiWallet)
+ const walletsByProvider = enokiWallets.reduce(
+ (map, wallet) => map.set(wallet.provider, wallet),
+ new Map(),
+ )
+ const googleWallet = walletsByProvider.get('google')
+ const hasGoogle = !!(config.enokiApiKey && config.googleClientId && googleWallet)
+ const hasMaxDelegateKeys = delegateKeyCount !== null && delegateKeyCount >= MAX_DELEGATE_KEYS
+
+ useEffect(() => {
+ let cancelled = false
+ setStep('loading')
+ setErrorMsg('')
+
+ apiPost('/api/app-auth/start', request)
+ .then((data) => {
+ if (cancelled) return
+ setSession(data)
+ setStep('consent')
+ })
+ .catch((err) => {
+ if (cancelled) return
+ setErrorMsg(err instanceof Error ? err.message : String(err))
+ setStep('error')
+ })
+
+ return () => {
+ cancelled = true
+ setSession(null)
+ }
+ }, [request])
+
+ useEffect(() => {
+ let cancelled = false
+
+ if (!currentAccount) {
+ setDelegateKeyCount(null)
+ setCheckingDelegateKeys(false)
+ return () => {
+ cancelled = true
+ }
+ }
+
+ setCheckingDelegateKeys(true)
+ setDelegateKeyCount(null)
+
+ ;(async () => {
+ try {
+ const accountId = await resolveAccountId(suiClient, currentAccount.address)
+ if (!accountId) {
+ if (!cancelled) setDelegateKeyCount(0)
+ return
+ }
+ const count = await fetchDelegateKeyCount(suiClient, accountId)
+ if (!cancelled) setDelegateKeyCount(count)
+ } catch {
+ if (!cancelled) setDelegateKeyCount(null)
+ } finally {
+ if (!cancelled) setCheckingDelegateKeys(false)
+ }
+ })()
+
+ return () => {
+ cancelled = true
+ }
+ }, [currentAccount, suiClient])
+
+ const registerDelegate = useCallback(async (): Promise<{ accountId: string, digest: string }> => {
+ if (!session) throw new Error('missing app auth session')
+ if (!currentAccount) throw new Error('connect a wallet first')
+
+ let accountId = await resolveAccountId(suiClient, currentAccount.address)
+ const publicKeyBytes = hexToBytes(session.delegate.public_key)
+
+ if (!accountId) {
+ const createTx = new Transaction()
+ createTx.moveCall({
+ target: `${config.memwalPackageId}::account::create_account`,
+ arguments: [
+ createTx.object(config.memwalRegistryId),
+ createTx.object('0x6'),
+ ],
+ })
+ const createResult = await signAndExecute({ transaction: createTx })
+ await suiClient.waitForTransaction({ digest: createResult.digest })
+
+ const txDetails = await suiClient.getTransactionBlock({
+ digest: createResult.digest,
+ options: { showObjectChanges: true },
+ })
+ const createdObj = txDetails.objectChanges?.find(
+ (change) => change.type === 'created'
+ && 'objectType' in change
+ && change.objectType.includes('MemWalAccount')
+ )
+ if (createdObj && 'objectId' in createdObj) {
+ accountId = createdObj.objectId
+ }
+ }
+
+ if (!accountId) {
+ throw new Error('could not resolve or create a Walrus Memory account for this wallet')
+ }
+
+ const addTx = new Transaction()
+ addTx.moveCall({
+ target: `${config.memwalPackageId}::account::add_delegate_key`,
+ arguments: [
+ addTx.object(accountId),
+ addTx.pure('vector', publicKeyBytes),
+ addTx.pure('address', session.delegate.sui_address),
+ addTx.pure('string', session.label),
+ addTx.object('0x6'),
+ ],
+ })
+ let addResult
+ try {
+ addResult = await signAndExecute({ transaction: addTx })
+ } catch (txErr) {
+ if (isAddDelegateAbort(txErr, 2)) {
+ throw new Error(MAX_DELEGATE_KEYS_MESSAGE)
+ }
+ if (isAddDelegateAbort(txErr, 0)) {
+ throw new Error(
+ `This wallet (${currentAccount.address.slice(0, 10)}...${currentAccount.address.slice(-6)}) is not the owner of Walrus Memory account ${accountId.slice(0, 10)}...${accountId.slice(-6)}. ` +
+ `Switch to the wallet that created this Walrus Memory account, then retry Connect App.`,
+ )
+ }
+ throw txErr
+ }
+ await suiClient.waitForTransaction({ digest: addResult.digest })
+ return { accountId, digest: addResult.digest }
+ }, [currentAccount, session, signAndExecute, suiClient])
+
+ const handleAuthorize = useCallback(async () => {
+ if (!session) return
+ if (hasMaxDelegateKeys) {
+ setErrorMsg(MAX_DELEGATE_KEYS_MESSAGE)
+ setStep('error')
+ return
+ }
+ if (!currentAccount) {
+ setProvider('wallet')
+ setWalletPickerOpen(true)
+ return
+ }
+
+ setStep('registering')
+ setErrorMsg('')
+
+ try {
+ const { accountId, digest } = await registerDelegate()
+ const complete = await apiPost('/api/app-auth/complete', {
+ session_id: session.session_id,
+ account_id: accountId,
+ owner_address: currentAccount.address,
+ provider,
+ tx_digest: digest,
+ })
+ setStep('redirecting')
+ window.location.assign(complete.redirect_url)
+ } catch (err) {
+ setErrorMsg(err instanceof Error ? err.message : String(err))
+ setStep('error')
+ }
+ }, [currentAccount, hasMaxDelegateKeys, provider, registerDelegate, session])
+
+ const handleGoogleConnect = useCallback(() => {
+ if (!googleWallet) return
+ setProvider('google')
+ connect({ wallet: googleWallet })
+ }, [connect, googleWallet])
+
+ const redirectWithError = useCallback(async (error: string) => {
+ if (!session) return
+ try {
+ const redirect = await apiPost('/api/app-auth/cancel', {
+ session_id: session.session_id,
+ error,
+ })
+ window.location.assign(redirect.redirect_url)
+ } catch (err) {
+ setErrorMsg(err instanceof Error ? err.message : String(err))
+ setStep('error')
+ }
+ }, [session])
+
+ return (
+
+
+
+
+ MemWal
+ Connect App
+
+
+
+
+
+ {step === 'loading' && (
+
+ Secure request
+ Checking app request
+ Walrus Memory is validating this connection request.
+
+ )}
+
+ {step === 'consent' && session && (
+
+ Third-party app access
+ Connect Walrus Memory
+
+ {session.client.display_name} wants to connect to your Walrus Memory account.
+
+
+
+
+
App
+
{session.client.display_name}
+
+
+
Return origin
+
{session.redirect_host}
+
+
+
Delegate label
+
{session.label}
+
+
+
Delegate public key
+
+ {session.delegate.public_key.slice(0, 16)}…{session.delegate.public_key.slice(-12)}
+
+
+
+
+ {!currentAccount ? (
+
+ {hasGoogle && (
+
+ Continue with Google
+
+ )}
+ setProvider('wallet')}>
+ Connect Sui wallet
+
+ )}
+ open={walletPickerOpen}
+ onOpenChange={setWalletPickerOpen}
+ />
+
+ ) : (
+ <>
+
+ Wallet
+ {currentAccount.address.slice(0, 8)}…{currentAccount.address.slice(-6)}
+
+ {checkingDelegateKeys && (
+ Checking delegate key capacity...
+ )}
+ {hasMaxDelegateKeys && (
+
+ {MAX_DELEGATE_KEYS_MESSAGE}
+
+ )}
+
+
+ {checkingDelegateKeys ? 'Checking account' : 'Authorize app'}
+
+ redirectWithError('access_denied')}>
+ Cancel
+
+
+ >
+ )}
+
+ )}
+
+ {step === 'registering' && (
+
+ On-chain setup
+ Registering delegate
+
+ Walrus Memory is adding an app-specific delegate key on-chain.
+
+
+ )}
+
+ {step === 'redirecting' && (
+
+ Connected
+ Connected
+ Returning to the app.
+
+ )}
+
+ {step === 'error' && (
+
+ Connection failed
+ Connection failed
+ {errorMsg || 'This app connection request could not be completed.'}
+ {session ? (
+
+ setStep('consent')}>
+ Try again
+
+ redirectWithError('delegate_setup_failed')}>
+ Return to app
+
+
+ ) : (
+ Walrus Memory did not redirect because the app request was not safe.
+ )}
+
+ )}
+
+
+
+ )
+}
+
+const pageStyle: CSSProperties = {
+ minHeight: '100vh',
+ background: '#FAF8F5',
+ color: '#1a1a1a',
+}
+
+const appNavBrandStyle: CSSProperties = {
+ gap: 12,
+}
+
+const memwalWordmarkStyle: CSSProperties = {
+ color: '#000',
+ fontSize: 26,
+ fontWeight: 900,
+ lineHeight: 1,
+}
+
+const mcpNavTitleStyle: CSSProperties = {
+ color: '#000',
+ fontSize: '1rem',
+ fontWeight: 700,
+ lineHeight: 1,
+ transform: 'translateY(8px)',
+}
+
+const mainStyle: CSSProperties = {
+ maxWidth: 640,
+ margin: '40px auto',
+ padding: '0 24px',
+ display: 'flex',
+ flexDirection: 'column',
+ gap: 24,
+}
+
+const cardStyle: CSSProperties = {
+ background: '#fff',
+ border: '2px solid #000',
+ borderRadius: 12,
+ padding: 28,
+ boxShadow: '4px 4px 0 #000',
+}
+
+const h1Style: CSSProperties = {
+ margin: '0 0 12px',
+ fontSize: 22,
+ fontWeight: 800,
+ letterSpacing: 0,
+}
+
+const eyebrowStyle: CSSProperties = {
+ margin: '0 0 14px',
+ color: '#525252',
+ fontSize: 12,
+ fontWeight: 700,
+ letterSpacing: 0,
+ textTransform: 'uppercase',
+}
+
+const dangerEyebrowStyle: CSSProperties = {
+ ...eyebrowStyle,
+ color: '#dc2626',
+}
+
+const bodyStyle: CSSProperties = {
+ color: '#525252',
+ lineHeight: 1.55,
+ margin: '0 0 22px',
+}
+
+const subtleStyle: CSSProperties = {
+ color: '#525252',
+ lineHeight: 1.55,
+ margin: 0,
+}
+
+const errorStyle: CSSProperties = {
+ color: '#dc2626',
+ lineHeight: 1.55,
+}
+
+const detailGridStyle: CSSProperties = {
+ display: 'grid',
+ gap: 12,
+ border: '2px solid #000',
+ borderRadius: 8,
+ padding: 16,
+ margin: '20px 0',
+ background: '#FAF8F5',
+}
+
+const detailLabelStyle: CSSProperties = {
+ color: '#525252',
+ fontSize: 12,
+ fontWeight: 700,
+ textTransform: 'uppercase',
+ letterSpacing: 0,
+}
+
+const detailValueStyle: CSSProperties = {
+ color: '#1a1a1a',
+ fontSize: 14,
+ overflowWrap: 'anywhere',
+ marginTop: 3,
+}
+
+const connectedStyle: CSSProperties = {
+ display: 'flex',
+ alignItems: 'center',
+ gap: 8,
+ color: '#1a1a1a',
+ fontFamily: 'var(--font-mono)',
+ fontSize: 13,
+ margin: '10px 0 18px',
+}
+
+const connectedLabelStyle: CSSProperties = {
+ color: '#525252',
+ fontFamily: 'Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
+ fontSize: 12,
+ fontWeight: 700,
+ textTransform: 'uppercase',
+}
+
+const capacityNoteStyle: CSSProperties = {
+ border: '1px solid #a3a3a3',
+ borderRadius: 8,
+ background: '#FAF8F5',
+ color: '#525252',
+ fontSize: 13,
+ lineHeight: 1.45,
+ margin: '0 0 16px',
+ padding: '10px 12px',
+}
+
+const capacityErrorStyle: CSSProperties = {
+ border: '2px solid #dc2626',
+ borderRadius: 8,
+ background: '#fef2f2',
+ color: '#991b1b',
+ fontSize: 13,
+ fontWeight: 700,
+ lineHeight: 1.45,
+ margin: '0 0 16px',
+ padding: '10px 12px',
+}
+
+const buttonRowStyle: CSSProperties = {
+ display: 'flex',
+ flexWrap: 'wrap',
+ gap: 10,
+}
+
+const primaryButtonStyle: CSSProperties = {
+ display: 'inline-flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ border: '2px solid #000',
+ background: '#e8ff57',
+ color: '#1a1a1a',
+ borderRadius: 8,
+ boxShadow: '4px 4px 0 #000',
+ padding: '12px 16px',
+ fontWeight: 900,
+ cursor: 'pointer',
+}
+
+const disabledPrimaryButtonStyle: CSSProperties = {
+ background: '#d4d4d4',
+ color: '#737373',
+ cursor: 'not-allowed',
+ boxShadow: 'none',
+}
+
+const secondaryButtonStyle: CSSProperties = {
+ display: 'inline-flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ border: '2px solid #000',
+ background: '#fff',
+ color: '#1a1a1a',
+ borderRadius: 8,
+ padding: '12px 16px',
+ fontWeight: 800,
+ cursor: 'pointer',
+}
diff --git a/apps/app/src/pages/Dashboard.tsx b/apps/app/src/pages/Dashboard.tsx
index 936a4b5e..2c9dd551 100644
--- a/apps/app/src/pages/Dashboard.tsx
+++ b/apps/app/src/pages/Dashboard.tsx
@@ -24,6 +24,7 @@ SyntaxHighlighter.registerLanguage('bash', bash)
import { useDelegateKey } from '../App'
import { config } from '../config'
import memwalLogo from '../assets/memwal-logo.svg'
+import AppAuthClientManager from './AppAuthClientManager'
// ============================================================
// Types
@@ -840,9 +841,11 @@ const result = await generateText({
{pkgManager === 'npm' ? 'npm install @mysten-incubation/memwal' :
pkgManager === 'pnpm' ? 'pnpm add @mysten-incubation/memwal' :
pkgManager === 'yarn' ? 'yarn add @mysten-incubation/memwal' :
- 'bun add @mysten-incubation/memwal'}
+ 'bun add @mysten-incubation/memwal'}
+
+
>
)
diff --git a/package.json b/package.json
index a1f7cd30..099201a6 100644
--- a/package.json
+++ b/package.json
@@ -9,6 +9,7 @@
"dev:noter": "pnpm --filter noter dev",
"dev:chatbot": "pnpm --filter chatbot dev",
"dev:app": "pnpm --filter app dev",
+ "dev:app-auth-demo": "pnpm --filter @memwal/app-auth-demo dev",
"dev:researcher": "pnpm --filter researcher dev",
"dev:docs": "pnpm --filter memwal-docs dev",
"build:docs": "pnpm --filter memwal-docs build",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e47f580c..9d472ac9 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -145,6 +145,8 @@ importers:
specifier: ^1.6.4
version: 1.6.4(@algolia/client-search@5.49.2)(@types/node@24.12.0)(@types/react@19.2.14)(axios@1.13.2)(idb-keyval@6.2.2)(jwt-decode@4.0.0)(lightningcss@1.31.1)(nprogress@0.2.0)(postcss@8.5.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(search-insights@2.17.3)(terser@5.46.1)(typescript@5.9.3)
+ apps/app-auth-demo: {}
+
apps/chatbot:
dependencies:
'@ai-sdk/gateway':
diff --git a/services/server/.env.example b/services/server/.env.example
index 431c1edf..771f28b6 100644
--- a/services/server/.env.example
+++ b/services/server/.env.example
@@ -107,7 +107,60 @@ SPONSOR_RATE_LIMIT_PER_HOUR=30
# ALLOWED_ORIGINS=https://staging.memwal.ai,https://noter.demo.staging.memwal.ai,https://chatbot.demo.staging.memwal.ai,https://researcher.demo.staging.memwal.ai
#
# Local dev:
-ALLOWED_ORIGINS=http://localhost:3000
+ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173
+
+# Hosted Walrus Memory app auth
+# Real third-party apps are managed from the dashboard's "hosted app clients"
+# section. The public app-auth flow uses these credentials, but end users never
+# create clients or see client_secret.
+#
+# Production posture:
+# - Leave public registration disabled.
+# - Operators sign into the dashboard with APP_AUTH_ADMIN_TOKEN.
+# - The dashboard calls /api/admin/app-auth/* to create, edit, block/unblock,
+# and rotate client credentials.
+# - Public create/edit/delete is not a production UX.
+#
+# Staging/demo posture:
+# - Prefer the same admin-managed UI on dev/staging.
+# - APP_AUTH_PUBLIC_CLIENT_REGISTRATION_ENABLED=true is only for temporary
+# self-registering demo validation, not the default staged deployment.
+#
+# APP_AUTH_CLIENTS_JSON remains as an optional legacy/static client list.
+# Public self-registration requires exact HTTPS callback/fallback URLs and
+# rejects localhost plus memwal.ai / *.memwal.ai origins. Localhost wildcard
+# support is dev-only for static clients and controlled separately below.
+# The built-in non-mainnet fallback registers:
+# client_id: demo_dapp
+# client_secret: demo_dapp_secret
+# redirect_uri: https://example.invalid/api/memwal/callback
+# Mainnet has no built-in fallback clients. Never use the demo secret outside
+# local/dev tests.
+# APP_AUTH_CLIENTS_JSON=[{"client_id":"demo_dapp","client_secret_sha256":"5619a8cdf18ecc129cf7301e0272f0bb4d04144ec1e72e2882de620436a5c577","display_name":"Demo Dapp","allowed_redirect_uris":["https://example.invalid/api/memwal/callback"],"fallback_uri":"https://example.invalid/memwal/error","allowed_fallback_uris":["https://example.invalid/memwal/error"]}]
+# Public dynamic client registration is staging/demo only. Production should
+# keep this unset/false and use the admin-managed dashboard instead.
+# APP_AUTH_PUBLIC_CLIENT_REGISTRATION_ENABLED=false
+# Admin token used by the dashboard to create a short-lived admin session.
+# Login attempts are rate-limited per IP.
+# Admin APIs:
+# POST /api/admin/app-auth/login
+# GET /api/admin/app-auth/clients
+# POST /api/admin/app-auth/clients
+# PATCH /api/admin/app-auth/clients/{client_id}
+# POST /api/admin/app-auth/clients/{client_id}/rotate-secret
+# POST /api/admin/app-auth/clients/{client_id}/block
+# POST /api/admin/app-auth/clients/{client_id}/unblock
+# APP_AUTH_ADMIN_TOKEN=
+# Local/testnet-only convenience client for demos on arbitrary localhost ports.
+# Registers client_id=dev_localhost / client_secret=dev_localhost_secret with:
+# http://localhost:*/api/memwal/callback
+# http://localhost:*/memwal/error
+# Ignored on mainnet.
+# APP_AUTH_ENABLE_DEV_LOCALHOST_WILDCARDS=true
+
+# Used to encrypt server-held app-auth delegate private keys at rest. If unset,
+# the server derives the app-auth encryption key from SIDECAR_AUTH_TOKEN.
+# APP_AUTH_DELEGATE_ENCRYPTION_KEY=replace-with-long-random-secret
# ─────────────────────────────────────────────────────────────────────
# Benchmark mode (RAG-quality benchmarks only — see benchmarks/README.md)
diff --git a/services/server/Cargo.lock b/services/server/Cargo.lock
index 4ca5876c..3ef286ba 100644
--- a/services/server/Cargo.lock
+++ b/services/server/Cargo.lock
@@ -2,6 +2,41 @@
# It is not intended for manual editing.
version = 4
+[[package]]
+name = "aead"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
+dependencies = [
+ "crypto-common",
+ "generic-array",
+]
+
+[[package]]
+name = "aes"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
+dependencies = [
+ "cfg-if",
+ "cipher",
+ "cpufeatures",
+]
+
+[[package]]
+name = "aes-gcm"
+version = "0.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
+dependencies = [
+ "aead",
+ "aes",
+ "cipher",
+ "ctr",
+ "ghash",
+ "subtle",
+]
+
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -230,6 +265,15 @@ dependencies = [
"serde_core",
]
+[[package]]
+name = "blake2"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
+dependencies = [
+ "digest",
+]
+
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -287,6 +331,16 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "cipher"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
+dependencies = [
+ "crypto-common",
+ "inout",
+]
+
[[package]]
name = "combine"
version = "4.6.7"
@@ -388,9 +442,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
+ "rand_core 0.6.4",
"typenum",
]
+[[package]]
+name = "ctr"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
+dependencies = [
+ "cipher",
+]
+
[[package]]
name = "curve25519-dalek"
version = "4.1.3"
@@ -768,6 +832,16 @@ dependencies = [
"wasip3",
]
+[[package]]
+name = "ghash"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
+dependencies = [
+ "opaque-debug",
+ "polyval",
+]
+
[[package]]
name = "h2"
version = "0.4.13"
@@ -1121,6 +1195,15 @@ dependencies = [
"serde_core",
]
+[[package]]
+name = "inout"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
+dependencies = [
+ "generic-array",
+]
+
[[package]]
name = "ipnet"
version = "2.12.0"
@@ -1273,11 +1356,13 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
name = "memwal-server"
version = "0.1.0"
dependencies = [
+ "aes-gcm",
"apalis",
"apalis-sql",
"async-trait",
"axum",
"base64",
+ "blake2",
"chrono",
"dotenvy",
"ed25519-dalek",
@@ -1286,6 +1371,7 @@ dependencies = [
"percent-encoding",
"pgvector",
"prometheus",
+ "rand 0.8.5",
"redis",
"reqwest",
"serde",
@@ -1405,6 +1491,12 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
+[[package]]
+name = "opaque-debug"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
+
[[package]]
name = "openssl"
version = "0.10.75"
@@ -1547,6 +1639,18 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
+[[package]]
+name = "polyval"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "opaque-debug",
+ "universal-hash",
+]
+
[[package]]
name = "potential_utf"
version = "0.1.4"
@@ -2732,6 +2836,16 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+[[package]]
+name = "universal-hash"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
+dependencies = [
+ "crypto-common",
+ "subtle",
+]
+
[[package]]
name = "untrusted"
version = "0.9.0"
diff --git a/services/server/Cargo.toml b/services/server/Cargo.toml
index a9d1411b..b269bb1e 100644
--- a/services/server/Cargo.toml
+++ b/services/server/Cargo.toml
@@ -18,6 +18,9 @@ pgvector = { version = "0.4", features = ["sqlx"] }
ed25519-dalek = { version = "2", features = ["serde"] }
sha2 = "0.10"
hex = "0.4"
+aes-gcm = "0.10"
+blake2 = "0.10"
+rand = "0.8"
# SEAL encryption is handled by TS sidecar scripts (@mysten/seal)
# (no Rust crypto deps needed)
diff --git a/services/server/migrations/010_app_auth_clients.sql b/services/server/migrations/010_app_auth_clients.sql
new file mode 100644
index 00000000..057fd70d
--- /dev/null
+++ b/services/server/migrations/010_app_auth_clients.sql
@@ -0,0 +1,43 @@
+-- Hosted app-auth dynamic client registration.
+--
+-- Third-party apps cannot reasonably ask Walrus Memory operators to install
+-- APP_AUTH_CLIENTS_JSON for every deployment. This table stores confidential
+-- app clients created through /api/app-auth/clients.
+
+CREATE TABLE IF NOT EXISTS app_auth_clients (
+ client_id TEXT PRIMARY KEY,
+ client_secret_sha256 TEXT NOT NULL CHECK (client_secret_sha256 ~ '^[0-9a-f]{64}$'),
+ display_name TEXT NOT NULL CHECK (char_length(display_name) BETWEEN 1 AND 80),
+ allowed_redirect_uris TEXT[] NOT NULL CHECK (COALESCE(array_length(allowed_redirect_uris, 1), 0) BETWEEN 1 AND 10),
+ fallback_uri TEXT,
+ allowed_fallback_uris TEXT[] NOT NULL DEFAULT '{}',
+ status TEXT NOT NULL DEFAULT 'active',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ CONSTRAINT app_auth_clients_status_check CHECK (status IN ('active', 'blocked'))
+);
+
+ALTER TABLE app_auth_clients
+ ADD COLUMN IF NOT EXISTS status TEXT;
+
+UPDATE app_auth_clients
+ SET status = 'active'
+ WHERE status IS NULL;
+
+ALTER TABLE app_auth_clients
+ ALTER COLUMN status SET DEFAULT 'active',
+ ALTER COLUMN status SET NOT NULL;
+
+DO $$
+BEGIN
+ ALTER TABLE app_auth_clients
+ ADD CONSTRAINT app_auth_clients_status_check CHECK (status IN ('active', 'blocked'));
+EXCEPTION WHEN duplicate_object THEN
+ NULL;
+END $$;
+
+CREATE INDEX IF NOT EXISTS idx_app_auth_clients_created_at
+ ON app_auth_clients (created_at DESC);
+
+CREATE INDEX IF NOT EXISTS idx_app_auth_clients_status
+ ON app_auth_clients (status);
diff --git a/services/server/src/main.rs b/services/server/src/main.rs
index d48d41af..329bbdc6 100644
--- a/services/server/src/main.rs
+++ b/services/server/src/main.rs
@@ -14,7 +14,7 @@ use axum::http::{header, HeaderValue, Method};
use axum::{
extract::DefaultBodyLimit,
middleware,
- routing::{get, post},
+ routing::{get, patch, post},
Router,
};
use std::net::SocketAddr;
@@ -23,6 +23,7 @@ use tower_http::cors::{AllowOrigin, CorsLayer};
use apalis::prelude::*;
use apalis_sql::postgres::PostgresStorage;
+use sqlx::postgres::PgPoolOptions;
use engine::{MemoryEngine, PlaintextEngine, WalrusSealEngine};
use jobs::{
@@ -71,6 +72,14 @@ async fn main() {
config.sponsor_rate_limit.per_minute,
config.sponsor_rate_limit.per_hour,
);
+ tracing::info!(
+ " app auth public client registration: {}",
+ if config.app_auth_public_client_registration_enabled {
+ "enabled"
+ } else {
+ "disabled"
+ },
+ );
if config.rate_limit.bench_bypass_enabled {
// Storage quota is unaffected — this only skips the request-rate
// buckets. The warning is split across lines so each one is grep-able
@@ -180,13 +189,29 @@ async fn main() {
// Setup Apalis job queue — auto-creates `apalis_jobs` table if not present
// Uses the same DATABASE_URL as the main DB; no extra infrastructure needed.
- let apalis_pool = sqlx::PgPool::connect(&config.database_url)
+ let apalis_pool = PgPoolOptions::new()
+ .max_connections(10)
+ .acquire_timeout(std::time::Duration::from_secs(10))
+ .connect(&config.database_url)
.await
.expect("Failed to connect to PostgreSQL for Apalis");
// setup() is defined only on PostgresStorage<()> — creates schema tables.
- PostgresStorage::<()>::setup(&apalis_pool)
- .await
- .expect("Apalis postgres migration failed");
+ match tokio::time::timeout(
+ std::time::Duration::from_secs(15),
+ PostgresStorage::<()>::setup(&apalis_pool),
+ )
+ .await
+ {
+ Ok(Ok(())) => {}
+ Ok(Err(err)) => {
+ panic!("Apalis postgres migration failed: {}", err);
+ }
+ Err(_) => {
+ tracing::error!(
+ "Apalis postgres migration timed out after 15s; continuing with existing tables"
+ );
+ }
+ }
let job_storage: PostgresStorage = PostgresStorage::new(apalis_pool.clone());
let remember_job_storage: PostgresStorage =
PostgresStorage::new(apalis_pool.clone());
@@ -561,6 +586,82 @@ async fn main() {
.layer(DefaultBodyLimit::max(2 * 1024 * 1024)),
);
+ // Hosted app auth routes — OAuth-style connect flow for third-party web
+ // apps. Browser-facing endpoints create/complete a short-lived session;
+ // the token endpoint is server-to-server and requires HTTP Basic client
+ // authentication.
+ let app_auth_routes = Router::new()
+ .route(
+ "/api/app-auth/clients",
+ post(routes::app_auth_create_client)
+ .layer(DefaultBodyLimit::max(16 * 1024))
+ .layer(middleware::from_fn_with_state(
+ state.clone(),
+ rate_limit::app_auth_clients_rate_limit_middleware,
+ )),
+ )
+ .route(
+ "/api/app-auth/register",
+ post(routes::app_auth_register)
+ .layer(DefaultBodyLimit::max(16 * 1024))
+ .layer(middleware::from_fn_with_state(
+ state.clone(),
+ rate_limit::app_auth_clients_rate_limit_middleware,
+ )),
+ )
+ .route(
+ "/api/admin/app-auth/login",
+ post(routes::app_auth_admin_login)
+ .layer(DefaultBodyLimit::max(4 * 1024))
+ .layer(middleware::from_fn_with_state(
+ state.clone(),
+ rate_limit::app_auth_admin_login_rate_limit_middleware,
+ )),
+ )
+ .route(
+ "/api/admin/app-auth/clients",
+ get(routes::app_auth_admin_list_clients)
+ .post(routes::app_auth_admin_create_client)
+ .layer(DefaultBodyLimit::max(16 * 1024)),
+ )
+ .route(
+ "/api/admin/app-auth/clients/{client_id}",
+ patch(routes::app_auth_admin_update_client).layer(DefaultBodyLimit::max(16 * 1024)),
+ )
+ .route(
+ "/api/admin/app-auth/clients/{client_id}/rotate-secret",
+ post(routes::app_auth_rotate_client_secret).layer(DefaultBodyLimit::max(4 * 1024)),
+ )
+ .route(
+ "/api/admin/app-auth/clients/{client_id}/block",
+ post(routes::app_auth_block_client).layer(DefaultBodyLimit::max(4 * 1024)),
+ )
+ .route(
+ "/api/admin/app-auth/clients/{client_id}/unblock",
+ post(routes::app_auth_unblock_client).layer(DefaultBodyLimit::max(4 * 1024)),
+ )
+ .route(
+ "/api/app-auth/start",
+ post(routes::app_auth_start)
+ .layer(DefaultBodyLimit::max(16 * 1024))
+ .layer(middleware::from_fn_with_state(
+ state.clone(),
+ rate_limit::app_auth_start_rate_limit_middleware,
+ )),
+ )
+ .route(
+ "/api/app-auth/complete",
+ post(routes::app_auth_complete).layer(DefaultBodyLimit::max(16 * 1024)),
+ )
+ .route(
+ "/api/app-auth/cancel",
+ post(routes::app_auth_cancel).layer(DefaultBodyLimit::max(16 * 1024)),
+ )
+ .route(
+ "/api/app-auth/token",
+ post(routes::app_auth_token).layer(DefaultBodyLimit::max(4 * 1024)),
+ );
+
// Public routes
// HIGH-13: /health and /config accept no body — cap at 16 KiB to reject
// oversized unauthenticated requests before they reach any handler.
@@ -585,7 +686,8 @@ async fn main() {
get(observability::metrics).layer(DefaultBodyLimit::max(16 * 1024)),
)
.merge(sponsor_routes)
- .merge(mcp_routes);
+ .merge(mcp_routes)
+ .merge(app_auth_routes);
// CORS — restrict to configured origins.
// Safe default is deny-all (no Access-Control-Allow-Origin header returned),
@@ -611,7 +713,7 @@ async fn main() {
tracing::info!(" CORS origins: {}", config.allowed_origins);
CorsLayer::new()
.allow_origin(AllowOrigin::list(origins))
- .allow_methods([Method::GET, Method::POST, Method::OPTIONS])
+ .allow_methods([Method::GET, Method::POST, Method::PATCH, Method::OPTIONS])
.allow_headers([
header::CONTENT_TYPE,
header::AUTHORIZATION,
@@ -624,6 +726,7 @@ async fn main() {
"x-delegate-key".parse::().unwrap(),
"x-request-id".parse::().unwrap(),
"x-correlation-id".parse::().unwrap(),
+ "x-admin-token".parse::().unwrap(),
// ENG-1697: SessionKey envelope replacing x-delegate-key
"x-seal-session".parse::().unwrap(),
// MCP headers — caller's Walrus Memory account id + optional default namespace.
diff --git a/services/server/src/rate_limit.rs b/services/server/src/rate_limit.rs
index 85846b6b..db364319 100644
--- a/services/server/src/rate_limit.rs
+++ b/services/server/src/rate_limit.rs
@@ -1,13 +1,13 @@
use axum::{
extract::{Request, State},
- http::StatusCode,
+ http::{header, HeaderMap, StatusCode},
middleware::Next,
response::Response,
};
use percent_encoding::percent_decode_str;
use std::sync::Arc;
-use crate::types::{AppError, AppState, AuthInfo};
+use crate::types::{AppError, AppState, AuthInfo, SecretBytes};
// ============================================================
// Sponsor Rate Limit Result
@@ -364,6 +364,43 @@ fn rate_limiter_unavailable_response() -> Response {
.unwrap()
}
+fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
+ if left.len() != right.len() {
+ return false;
+ }
+ let mut diff = 0u8;
+ for (a, b) in left.iter().zip(right.iter()) {
+ diff |= a ^ b;
+ }
+ diff == 0
+}
+
+fn app_auth_admin_token_from_headers(headers: &HeaderMap) -> Option<&str> {
+ headers
+ .get("x-admin-token")
+ .and_then(|value| value.to_str().ok())
+ .or_else(|| {
+ headers
+ .get(header::AUTHORIZATION)
+ .and_then(|value| value.to_str().ok())
+ .and_then(|value| value.strip_prefix("Bearer "))
+ })
+}
+
+fn is_valid_app_auth_admin_token(headers: &HeaderMap, configured: Option<&SecretBytes>) -> bool {
+ let Some(configured) = configured else {
+ return false;
+ };
+ let Some(presented) = app_auth_admin_token_from_headers(headers) else {
+ return false;
+ };
+ if presented.trim().is_empty() {
+ return false;
+ }
+
+ constant_time_eq(configured.as_slice(), presented.as_bytes())
+}
+
// ============================================================
// Rate Limit Middleware
// ============================================================
@@ -973,6 +1010,385 @@ pub async fn sponsor_rate_limit_middleware(
next.run(request).await
}
+/// Rate limiting middleware for anonymous hosted app-auth session creation.
+///
+/// `/api/app-auth/start` creates Redis state and a fresh delegate keypair, so it
+/// gets the same per-IP budget as sponsor routes while using separate buckets.
+pub async fn app_auth_start_rate_limit_middleware(
+ State(state): State>,
+ request: Request,
+ next: Next,
+) -> Response {
+ if state.config.rate_limit.bench_bypass_enabled {
+ return next.run(request).await;
+ }
+
+ let ip: Option = request
+ .headers()
+ .get("x-forwarded-for")
+ .and_then(|v| v.to_str().ok())
+ .and_then(|s| s.split(',').next())
+ .map(|s| s.trim().to_string())
+ .or_else(|| {
+ request
+ .extensions()
+ .get::>()
+ .map(|ci| ci.0.ip().to_string())
+ });
+
+ let ip = match ip {
+ Some(ip) => ip,
+ None => {
+ tracing::warn!(
+ "app_auth_start_rate_limit_middleware: cannot determine client IP, denying"
+ );
+ return rate_limiter_unavailable_response();
+ }
+ };
+
+ let config = &state.config.sponsor_rate_limit;
+ let mut redis = state.redis.clone();
+ let now = chrono::Utc::now().timestamp_millis() as f64;
+
+ let min_key = format!("rate:app_auth:start:ip:min:{}", ip);
+ let hr_key = format!("rate:app_auth:start:ip:hr:{}", ip);
+ let min_window_start = now - 60_000.0;
+ let hr_window_start = now - 3_600_000.0;
+
+ let mut redis_down = false;
+
+ match check_and_record_window(
+ &mut redis,
+ &min_key,
+ min_window_start,
+ now,
+ config.per_minute,
+ 1,
+ 120,
+ )
+ .await
+ {
+ Ok(WindowCheckResult::Denied) => {
+ tracing::warn!(
+ "app-auth/start rate limit [IP/min]: ip={} denied (limit={})",
+ ip,
+ config.per_minute
+ );
+ return rate_limit_response("app_auth_start_ip_burst", config.per_minute, "min", 60);
+ }
+ Err(e) => {
+ tracing::warn!("app_auth_start_rate_limit_middleware: Redis error (minute bucket): {} — switching to in-memory fallback", e);
+ redis_down = true;
+ }
+ Ok(WindowCheckResult::Allowed) => {}
+ }
+
+ if !redis_down {
+ match check_and_record_window(
+ &mut redis,
+ &hr_key,
+ hr_window_start,
+ now + 0.1,
+ config.per_hour,
+ 1,
+ 3700,
+ )
+ .await
+ {
+ Ok(WindowCheckResult::Denied) => {
+ tracing::warn!(
+ "app-auth/start rate limit [IP/hr]: ip={} denied (limit={})",
+ ip,
+ config.per_hour
+ );
+ return rate_limit_response(
+ "app_auth_start_ip_sustained",
+ config.per_hour,
+ "hour",
+ 300,
+ );
+ }
+ Err(e) => {
+ tracing::warn!("app_auth_start_rate_limit_middleware: Redis error (hour bucket): {} — switching to in-memory fallback", e);
+ redis_down = true;
+ }
+ Ok(WindowCheckResult::Allowed) => {}
+ }
+ }
+
+ if redis_down {
+ tracing::warn!("app_auth_start_rate_limit_middleware: Redis is unreachable, using in-memory fallback for ip={}", ip);
+ crate::observability::record_rate_limit_fallback("app_auth_start_ip");
+ let mut fallback = state.fallback_rate_limit.lock().await;
+
+ if !fallback.can_consume(&min_key, 1.0, config.per_minute as f64, 60.0) {
+ return rate_limit_response("app_auth_start_ip_burst", config.per_minute, "min", 60);
+ }
+ if !fallback.can_consume(&hr_key, 1.0, config.per_hour as f64, 3600.0) {
+ return rate_limit_response(
+ "app_auth_start_ip_sustained",
+ config.per_hour,
+ "hour",
+ 300,
+ );
+ }
+
+ fallback.consume(&min_key, 1.0, config.per_minute as f64, 60.0);
+ fallback.consume(&hr_key, 1.0, config.per_hour as f64, 3600.0);
+
+ return next.run(request).await;
+ }
+
+ next.run(request).await
+}
+
+/// Rate limiting middleware for public dynamic client registration.
+///
+/// `/api/app-auth/clients` is intentionally self-serve, but each successful
+/// call creates a confidential client row. Keep this tighter than `/start`.
+pub async fn app_auth_clients_rate_limit_middleware(
+ State(state): State>,
+ request: Request,
+ next: Next,
+) -> Response {
+ const CLIENT_REGISTRATIONS_PER_HOUR: i64 = 5;
+
+ if state.config.rate_limit.bench_bypass_enabled {
+ return next.run(request).await;
+ }
+
+ if is_valid_app_auth_admin_token(
+ request.headers(),
+ state.config.app_auth_admin_token.as_ref(),
+ ) {
+ return next.run(request).await;
+ }
+
+ let ip: Option = request
+ .headers()
+ .get("x-forwarded-for")
+ .and_then(|v| v.to_str().ok())
+ .and_then(|s| s.split(',').next())
+ .map(|s| s.trim().to_string())
+ .or_else(|| {
+ request
+ .extensions()
+ .get::>()
+ .map(|ci| ci.0.ip().to_string())
+ });
+
+ let ip = match ip {
+ Some(ip) => ip,
+ None => {
+ tracing::warn!(
+ "app_auth_clients_rate_limit_middleware: cannot determine client IP, denying"
+ );
+ return rate_limiter_unavailable_response();
+ }
+ };
+
+ let mut redis = state.redis.clone();
+ let now = chrono::Utc::now().timestamp_millis() as f64;
+
+ let hr_key = format!("rate:app_auth:clients:ip:hr:{}", ip);
+ let hr_window_start = now - 3_600_000.0;
+ let mut redis_down = false;
+
+ match check_and_record_window(
+ &mut redis,
+ &hr_key,
+ hr_window_start,
+ now,
+ CLIENT_REGISTRATIONS_PER_HOUR,
+ 1,
+ 3700,
+ )
+ .await
+ {
+ Ok(WindowCheckResult::Denied) => {
+ tracing::warn!(
+ "app-auth/clients rate limit [IP/hr]: ip={} denied (limit={})",
+ ip,
+ CLIENT_REGISTRATIONS_PER_HOUR
+ );
+ return rate_limit_response(
+ "app_auth_clients_ip_sustained",
+ CLIENT_REGISTRATIONS_PER_HOUR,
+ "hour",
+ 300,
+ );
+ }
+ Err(e) => {
+ tracing::warn!("app_auth_clients_rate_limit_middleware: Redis error (hour bucket): {} — switching to in-memory fallback", e);
+ redis_down = true;
+ }
+ Ok(WindowCheckResult::Allowed) => {}
+ }
+
+ if redis_down {
+ tracing::warn!("app_auth_clients_rate_limit_middleware: Redis is unreachable, using in-memory fallback for ip={}", ip);
+ crate::observability::record_rate_limit_fallback("app_auth_clients_ip");
+ let mut fallback = state.fallback_rate_limit.lock().await;
+
+ if !fallback.can_consume(&hr_key, 1.0, CLIENT_REGISTRATIONS_PER_HOUR as f64, 3600.0) {
+ return rate_limit_response(
+ "app_auth_clients_ip_sustained",
+ CLIENT_REGISTRATIONS_PER_HOUR,
+ "hour",
+ 300,
+ );
+ }
+
+ fallback.consume(&hr_key, 1.0, CLIENT_REGISTRATIONS_PER_HOUR as f64, 3600.0);
+ return next.run(request).await;
+ }
+
+ next.run(request).await
+}
+
+/// Rate limiting middleware for hosted app-auth admin login.
+///
+/// Production client management is gated by `APP_AUTH_ADMIN_TOKEN`; keep login
+/// attempts on their own tight IP bucket so a exposed dashboard cannot be used
+/// as an unlimited token guessing surface.
+pub async fn app_auth_admin_login_rate_limit_middleware(
+ State(state): State>,
+ request: Request,
+ next: Next,
+) -> Response {
+ const ADMIN_LOGIN_ATTEMPTS_PER_MINUTE: i64 = 5;
+ const ADMIN_LOGIN_ATTEMPTS_PER_HOUR: i64 = 30;
+
+ if state.config.rate_limit.bench_bypass_enabled {
+ return next.run(request).await;
+ }
+
+ let ip: Option = request
+ .headers()
+ .get("x-forwarded-for")
+ .and_then(|v| v.to_str().ok())
+ .and_then(|s| s.split(',').next())
+ .map(|s| s.trim().to_string())
+ .or_else(|| {
+ request
+ .extensions()
+ .get::>()
+ .map(|ci| ci.0.ip().to_string())
+ });
+
+ let ip = match ip {
+ Some(ip) => ip,
+ None => {
+ tracing::warn!(
+ "app_auth_admin_login_rate_limit_middleware: cannot determine client IP, denying"
+ );
+ return rate_limiter_unavailable_response();
+ }
+ };
+
+ let mut redis = state.redis.clone();
+ let now = chrono::Utc::now().timestamp_millis() as f64;
+
+ let min_key = format!("rate:app_auth:admin_login:ip:min:{}", ip);
+ let hr_key = format!("rate:app_auth:admin_login:ip:hr:{}", ip);
+ let min_window_start = now - 60_000.0;
+ let hr_window_start = now - 3_600_000.0;
+ let mut redis_down = false;
+
+ match check_and_record_window(
+ &mut redis,
+ &min_key,
+ min_window_start,
+ now,
+ ADMIN_LOGIN_ATTEMPTS_PER_MINUTE,
+ 1,
+ 120,
+ )
+ .await
+ {
+ Ok(WindowCheckResult::Denied) => {
+ tracing::warn!(
+ "app-auth/admin-login rate limit [IP/min]: ip={} denied (limit={})",
+ ip,
+ ADMIN_LOGIN_ATTEMPTS_PER_MINUTE
+ );
+ return rate_limit_response(
+ "app_auth_admin_login_ip_burst",
+ ADMIN_LOGIN_ATTEMPTS_PER_MINUTE,
+ "min",
+ 60,
+ );
+ }
+ Err(e) => {
+ tracing::warn!("app_auth_admin_login_rate_limit_middleware: Redis error (minute bucket): {} — switching to in-memory fallback", e);
+ redis_down = true;
+ }
+ Ok(WindowCheckResult::Allowed) => {}
+ }
+
+ if !redis_down {
+ match check_and_record_window(
+ &mut redis,
+ &hr_key,
+ hr_window_start,
+ now + 0.1,
+ ADMIN_LOGIN_ATTEMPTS_PER_HOUR,
+ 1,
+ 3700,
+ )
+ .await
+ {
+ Ok(WindowCheckResult::Denied) => {
+ tracing::warn!(
+ "app-auth/admin-login rate limit [IP/hr]: ip={} denied (limit={})",
+ ip,
+ ADMIN_LOGIN_ATTEMPTS_PER_HOUR
+ );
+ return rate_limit_response(
+ "app_auth_admin_login_ip_sustained",
+ ADMIN_LOGIN_ATTEMPTS_PER_HOUR,
+ "hour",
+ 300,
+ );
+ }
+ Err(e) => {
+ tracing::warn!("app_auth_admin_login_rate_limit_middleware: Redis error (hour bucket): {} — switching to in-memory fallback", e);
+ redis_down = true;
+ }
+ Ok(WindowCheckResult::Allowed) => {}
+ }
+ }
+
+ if redis_down {
+ tracing::warn!("app_auth_admin_login_rate_limit_middleware: Redis is unreachable, using in-memory fallback for ip={}", ip);
+ crate::observability::record_rate_limit_fallback("app_auth_admin_login_ip");
+ let mut fallback = state.fallback_rate_limit.lock().await;
+
+ if !fallback.can_consume(&min_key, 1.0, ADMIN_LOGIN_ATTEMPTS_PER_MINUTE as f64, 60.0) {
+ return rate_limit_response(
+ "app_auth_admin_login_ip_burst",
+ ADMIN_LOGIN_ATTEMPTS_PER_MINUTE,
+ "min",
+ 60,
+ );
+ }
+ if !fallback.can_consume(&hr_key, 1.0, ADMIN_LOGIN_ATTEMPTS_PER_HOUR as f64, 3600.0) {
+ return rate_limit_response(
+ "app_auth_admin_login_ip_sustained",
+ ADMIN_LOGIN_ATTEMPTS_PER_HOUR,
+ "hour",
+ 300,
+ );
+ }
+
+ fallback.consume(&min_key, 1.0, ADMIN_LOGIN_ATTEMPTS_PER_MINUTE as f64, 60.0);
+ fallback.consume(&hr_key, 1.0, ADMIN_LOGIN_ATTEMPTS_PER_HOUR as f64, 3600.0);
+ return next.run(request).await;
+ }
+
+ next.run(request).await
+}
+
// ============================================================
// Unit Tests
// ============================================================
@@ -1058,6 +1474,41 @@ mod tests {
assert!(resp.headers().contains_key("retry-after"));
}
+ #[test]
+ fn app_auth_admin_token_accepts_bearer_header() {
+ let mut headers = HeaderMap::new();
+ headers.insert(
+ header::AUTHORIZATION,
+ "Bearer secret-value".parse().unwrap(),
+ );
+ let configured = SecretBytes::from_string("secret-value".to_string());
+
+ assert!(is_valid_app_auth_admin_token(&headers, Some(&configured)));
+ }
+
+ #[test]
+ fn app_auth_admin_token_accepts_admin_header() {
+ let mut headers = HeaderMap::new();
+ headers.insert("x-admin-token", "secret-value".parse().unwrap());
+ let configured = SecretBytes::from_string("secret-value".to_string());
+
+ assert!(is_valid_app_auth_admin_token(&headers, Some(&configured)));
+ }
+
+ #[test]
+ fn app_auth_admin_token_rejects_missing_or_invalid_values() {
+ let configured = SecretBytes::from_string("secret-value".to_string());
+ assert!(!is_valid_app_auth_admin_token(
+ &HeaderMap::new(),
+ Some(&configured)
+ ));
+
+ let mut headers = HeaderMap::new();
+ headers.insert(header::AUTHORIZATION, "Bearer wrong-value".parse().unwrap());
+ assert!(!is_valid_app_auth_admin_token(&headers, Some(&configured)));
+ assert!(!is_valid_app_auth_admin_token(&headers, None));
+ }
+
// ---- MED-19: Atomic Lua script structure ----
/// Verify the Lua script constant is non-empty and contains the critical
diff --git a/services/server/src/routes/app_auth.rs b/services/server/src/routes/app_auth.rs
new file mode 100644
index 00000000..d4f0ee6c
--- /dev/null
+++ b/services/server/src/routes/app_auth.rs
@@ -0,0 +1,1809 @@
+//! Hosted OAuth-style app auth for third-party web apps.
+//!
+//! `/connect/app` is rendered by the Vite app. These API endpoints own all
+//! security-sensitive short-lived Redis state: registered client validation,
+//! redirect construction, server-held delegate references, and one-time code
+//! exchange.
+
+use aes_gcm::aead::{Aead, KeyInit};
+use aes_gcm::{Aes256Gcm, Nonce};
+use axum::{
+ extract::{Path, State},
+ http::{header, HeaderMap},
+ Json,
+};
+use base64::{engine::general_purpose, Engine as _};
+use blake2::{
+ digest::{Update, VariableOutput},
+ Blake2bVar,
+};
+use chrono::{DateTime, Duration, Utc};
+use ed25519_dalek::SigningKey;
+use rand::{rngs::OsRng, RngCore};
+use redis::AsyncCommands;
+use reqwest::Url;
+use serde::de::DeserializeOwned;
+use serde::{Deserialize, Serialize};
+use sha2::{Digest, Sha256};
+use std::sync::Arc;
+
+use crate::storage::sui::verify_delegate_key_onchain;
+use crate::types::{
+ AppAuthClientConfig, AppError, AppState, APP_AUTH_CLIENT_STATUS_ACTIVE,
+ APP_AUTH_CLIENT_STATUS_BLOCKED,
+};
+
+const APP_AUTH_SESSION_TTL_SECS: u64 = 15 * 60;
+const APP_AUTH_CODE_TTL_SECS: u64 = 5 * 60;
+const APP_AUTH_ADMIN_SESSION_TTL_SECS: u64 = 2 * 60 * 60;
+// ENG-1783 review B1 (2026-05-26): both delegate writes previously used
+// ttl_secs:None, leaking ~800B / completed flow into Redis indefinitely
+// (~560MB at 1M flows). 24h is long enough to cover the start→complete→
+// token-exchange window and any near-term re-read by a future "third-party
+// app calls MemWal via delegate_ref" feature, short enough that abandoned
+// or stale entries reclaim themselves automatically.
+const APP_AUTH_DELEGATE_TTL_SECS: u64 = 24 * 60 * 60;
+const APP_AUTH_INTENT: &str = "sdk_delegate";
+const DEFAULT_LABEL: &str = "Walrus Memory App";
+const APP_AUTH_DISPLAY_NAME_MAX_CHARS: usize = 80;
+const APP_AUTH_MAX_REGISTERED_URLS: usize = 10;
+// ENG-1783 review N4 (2026-05-26): the `label` field is third-party-supplied
+// per-session text shown next to the trusted display_name on the consent
+// screen. 64 chars made it easy to spoof phrases like "MemWal Official Wallet"
+// even though display_name is the verified app identity. 32 chars is enough
+// for legitimate per-session context ("Login from Chrome — Mac") while
+// reducing the phishing surface.
+const APP_AUTH_LABEL_MAX_CHARS: usize = 32;
+
+#[derive(Debug, Deserialize)]
+pub struct AppAuthStartRequest {
+ pub client_id: String,
+ pub redirect_uri: String,
+ pub state: String,
+ pub label: Option,
+ pub intent: String,
+ pub fallback_uri: Option,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct AppAuthRegisterRequest {
+ pub display_name: String,
+ pub redirect_uris: Vec,
+ pub fallback_uri: Option,
+ #[serde(default)]
+ pub fallback_uris: Vec,
+}
+
+#[derive(Debug, Serialize)]
+pub struct AppAuthRegisterResponse {
+ pub client_id: String,
+ pub client_secret: String,
+ pub display_name: String,
+ pub allowed_redirect_uris: Vec,
+ pub fallback_uri: Option,
+ pub allowed_fallback_uris: Vec,
+ pub status: String,
+}
+
+#[derive(Debug, Serialize)]
+pub struct AppAuthAdminLoginResponse {
+ pub token: String,
+ pub expires_at: DateTime,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct AppAuthAdminLoginRequest {
+ pub admin_token: String,
+}
+
+#[derive(Debug, Serialize)]
+pub struct AppAuthAdminClientListResponse {
+ pub clients: Vec,
+}
+
+#[derive(Debug, Serialize)]
+pub struct AppAuthAdminClientDetail {
+ pub client_id: String,
+ pub display_name: String,
+ pub allowed_redirect_uris: Vec,
+ pub fallback_uri: Option,
+ pub allowed_fallback_uris: Vec,
+ pub status: String,
+ pub created_at: DateTime,
+ pub updated_at: DateTime,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct AppAuthAdminUpdateClientRequest {
+ pub display_name: String,
+ pub redirect_uris: Vec,
+ pub fallback_uri: Option,
+ #[serde(default)]
+ pub fallback_uris: Vec,
+ pub status: String,
+}
+
+#[derive(Debug, Serialize)]
+pub struct AppAuthAdminSecretResponse {
+ pub client_id: String,
+ pub client_secret: String,
+}
+
+#[derive(Debug, Serialize)]
+pub struct AppAuthAdminClientResponse {
+ pub client_id: String,
+ pub status: String,
+}
+
+#[derive(Debug, Serialize)]
+pub struct AppAuthStartResponse {
+ pub session_id: String,
+ pub client: AppAuthClientPublic,
+ pub redirect_host: String,
+ pub label: String,
+ pub expires_at: DateTime,
+ pub delegate: AppAuthStartDelegate,
+}
+
+#[derive(Debug, Serialize)]
+pub struct AppAuthClientPublic {
+ pub client_id: String,
+ pub display_name: String,
+}
+
+#[derive(Debug, Serialize)]
+pub struct AppAuthStartDelegate {
+ pub public_key: String,
+ pub sui_address: String,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct AppAuthCompleteRequest {
+ pub session_id: String,
+ pub account_id: String,
+ pub owner_address: String,
+ pub provider: String,
+ pub tx_digest: String,
+}
+
+#[derive(Debug, Serialize)]
+pub struct AppAuthRedirectResponse {
+ pub redirect_url: String,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct AppAuthCancelRequest {
+ pub session_id: String,
+ pub error: Option,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct AppAuthTokenRequest {
+ pub grant_type: String,
+ pub code: String,
+ pub redirect_uri: String,
+ pub state: String,
+}
+
+#[derive(Debug, Serialize)]
+pub struct AppAuthTokenResponse {
+ pub account_id: String,
+ pub owner_address: String,
+ pub provider: String,
+ pub delegate: AppAuthTokenDelegate,
+}
+
+#[derive(Debug, Serialize)]
+pub struct AppAuthTokenDelegate {
+ pub status: String,
+ #[serde(rename = "ref")]
+ pub delegate_ref: String,
+ pub public_key: String,
+ pub label: String,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+struct AppAuthSessionStore {
+ id: String,
+ client_id: String,
+ redirect_uri: String,
+ fallback_uri: Option,
+ state: String,
+ label: String,
+ delegate_ref: String,
+ delegate_public_key: String,
+ status: String,
+ expires_at: DateTime,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+struct AppAuthCodeStore {
+ client_id: String,
+ redirect_uri: String,
+ state: String,
+ account_id: String,
+ owner_address: String,
+ provider: String,
+ delegate_ref: String,
+ delegate_public_key: String,
+ delegate_label: String,
+ expires_at: DateTime,
+}
+
+#[allow(dead_code)]
+#[derive(Clone, Serialize, Deserialize)]
+struct AppAuthDelegateStore {
+ id: String,
+ client_id: String,
+ account_id: Option,
+ owner_address: Option,
+ provider: Option,
+ delegate_public_key: String,
+ delegate_address: String,
+ encrypted_delegate_private_key: String,
+ label: String,
+ status: String,
+ tx_digest: Option,
+ created_at: DateTime,
+ updated_at: DateTime,
+}
+
+/// Dynamic client registration for third-party backend apps.
+///
+/// This replaces the "ask Walrus Memory operators to edit
+/// APP_AUTH_CLIENTS_JSON" path for real dapps, without making production
+/// registration anonymously mutable. Staging/demo can opt into public
+/// registration; production leaves it off and requires APP_AUTH_ADMIN_TOKEN on
+/// this endpoint. The returned `client_secret` is shown once and must be stored
+/// by the third-party backend, never in browser JavaScript.
+pub async fn app_auth_create_client(
+ headers: HeaderMap,
+ State(state): State>,
+ Json(req): Json,
+) -> Result, AppError> {
+ create_app_auth_client(headers, state, req).await
+}
+
+/// Backward-compatible alias for early demo deployments. New dapps should use
+/// `POST /api/app-auth/clients`.
+pub async fn app_auth_register(
+ headers: HeaderMap,
+ State(state): State>,
+ Json(req): Json,
+) -> Result, AppError> {
+ create_app_auth_client(headers, state, req).await
+}
+
+async fn create_app_auth_client(
+ headers: HeaderMap,
+ state: Arc,
+ req: AppAuthRegisterRequest,
+) -> Result, AppError> {
+ if !state.config.app_auth_public_client_registration_enabled {
+ require_app_auth_admin(&headers, state.as_ref()).await?;
+ }
+
+ create_app_auth_client_record(state, req).await
+}
+
+async fn create_app_auth_client_record(
+ state: Arc,
+ req: AppAuthRegisterRequest,
+) -> Result, AppError> {
+ let display_name = sanitize_client_display_name(&req.display_name)?;
+ let allowed_redirect_uris = validate_registration_urls("redirect_uris", &req.redirect_uris)?;
+
+ let mut requested_fallback_uris = req.fallback_uris;
+ if let Some(fallback_uri) = req.fallback_uri {
+ requested_fallback_uris.push(fallback_uri);
+ }
+ let allowed_fallback_uris = if requested_fallback_uris.is_empty() {
+ Vec::new()
+ } else {
+ validate_registration_urls("fallback_uris", &requested_fallback_uris)?
+ };
+ let fallback_uri = allowed_fallback_uris.first().cloned();
+
+ let client_id = random_token("app_");
+ let client_secret = random_token("mwas_");
+ let client = AppAuthClientConfig {
+ client_id: client_id.clone(),
+ client_secret_sha256: hash_secret(&client_secret),
+ display_name: display_name.clone(),
+ allowed_redirect_uris: allowed_redirect_uris.clone(),
+ fallback_uri: fallback_uri.clone(),
+ allowed_fallback_uris: allowed_fallback_uris.clone(),
+ status: APP_AUTH_CLIENT_STATUS_ACTIVE.to_string(),
+ };
+
+ state.db.insert_app_auth_client(&client).await?;
+
+ Ok(Json(AppAuthRegisterResponse {
+ client_id,
+ client_secret,
+ display_name,
+ allowed_redirect_uris,
+ fallback_uri,
+ allowed_fallback_uris,
+ status: APP_AUTH_CLIENT_STATUS_ACTIVE.to_string(),
+ }))
+}
+
+pub async fn app_auth_admin_login(
+ State(state): State>,
+ Json(req): Json,
+) -> Result, AppError> {
+ let presented = req.admin_token.trim();
+ if presented.is_empty() {
+ return Err(AppError::Unauthorized("missing admin token".into()));
+ }
+ ensure_configured_app_auth_admin_token(presented, state.as_ref())?;
+
+ let token = random_token("mwadm_");
+ let expires_at = Utc::now() + Duration::seconds(APP_AUTH_ADMIN_SESSION_TTL_SECS as i64);
+ set_redis_json(
+ state.as_ref(),
+ &app_auth_admin_session_key(&hash_secret(&token)),
+ &"active",
+ Some(APP_AUTH_ADMIN_SESSION_TTL_SECS),
+ )
+ .await?;
+
+ Ok(Json(AppAuthAdminLoginResponse { token, expires_at }))
+}
+
+pub async fn app_auth_admin_list_clients(
+ headers: HeaderMap,
+ State(state): State>,
+) -> Result, AppError> {
+ require_app_auth_admin(&headers, state.as_ref()).await?;
+ let rows = state.db.list_app_auth_clients().await?;
+ Ok(Json(AppAuthAdminClientListResponse {
+ clients: rows
+ .into_iter()
+ .map(|row| AppAuthAdminClientDetail {
+ client_id: row.client.client_id,
+ display_name: row.client.display_name,
+ allowed_redirect_uris: row.client.allowed_redirect_uris,
+ fallback_uri: row.client.fallback_uri,
+ allowed_fallback_uris: row.client.allowed_fallback_uris,
+ status: row.client.status,
+ created_at: row.created_at,
+ updated_at: row.updated_at,
+ })
+ .collect(),
+ }))
+}
+
+pub async fn app_auth_admin_create_client(
+ headers: HeaderMap,
+ State(state): State>,
+ Json(req): Json,
+) -> Result, AppError> {
+ require_app_auth_admin(&headers, state.as_ref()).await?;
+ create_app_auth_client_record(state, req).await
+}
+
+pub async fn app_auth_admin_update_client(
+ headers: HeaderMap,
+ State(state): State>,
+ Path(client_id): Path,
+ Json(req): Json,
+) -> Result, AppError> {
+ require_app_auth_admin(&headers, state.as_ref()).await?;
+ let client_id = sanitize_client_id(&client_id)?;
+ let display_name = sanitize_client_display_name(&req.display_name)?;
+ let allowed_redirect_uris = validate_registration_urls("redirect_uris", &req.redirect_uris)?;
+
+ let mut requested_fallback_uris = req.fallback_uris;
+ if let Some(fallback_uri) = req.fallback_uri {
+ requested_fallback_uris.push(fallback_uri);
+ }
+ let allowed_fallback_uris = if requested_fallback_uris.is_empty() {
+ Vec::new()
+ } else {
+ validate_registration_urls("fallback_uris", &requested_fallback_uris)?
+ };
+ let fallback_uri = allowed_fallback_uris.first().cloned();
+ let status = sanitize_client_status(&req.status)?;
+
+ let updated = state
+ .db
+ .update_app_auth_client(
+ &client_id,
+ &display_name,
+ &allowed_redirect_uris,
+ fallback_uri.as_ref(),
+ &allowed_fallback_uris,
+ &status,
+ )
+ .await?;
+ if !updated {
+ return Err(AppError::BadRequest("unknown app client".into()));
+ }
+
+ Ok(Json(AppAuthAdminClientDetail {
+ client_id,
+ display_name,
+ allowed_redirect_uris,
+ fallback_uri,
+ allowed_fallback_uris,
+ status,
+ created_at: Utc::now(),
+ updated_at: Utc::now(),
+ }))
+}
+
+pub async fn app_auth_rotate_client_secret(
+ headers: HeaderMap,
+ State(state): State>,
+ Path(client_id): Path,
+) -> Result, AppError> {
+ require_app_auth_admin(&headers, state.as_ref()).await?;
+ let client_id = sanitize_client_id(&client_id)?;
+ let client_secret = random_token("mwas_");
+ let updated = state
+ .db
+ .update_app_auth_client_secret(&client_id, &hash_secret(&client_secret))
+ .await?;
+ if !updated {
+ return Err(AppError::BadRequest("unknown app client".into()));
+ }
+
+ Ok(Json(AppAuthAdminSecretResponse {
+ client_id,
+ client_secret,
+ }))
+}
+
+pub async fn app_auth_block_client(
+ headers: HeaderMap,
+ State(state): State>,
+ Path(client_id): Path,
+) -> Result, AppError> {
+ require_app_auth_admin(&headers, state.as_ref()).await?;
+ let client_id = sanitize_client_id(&client_id)?;
+
+ let updated = state
+ .db
+ .update_app_auth_client_status(&client_id, APP_AUTH_CLIENT_STATUS_BLOCKED)
+ .await?;
+ if !updated {
+ return Err(AppError::BadRequest("unknown app client".into()));
+ }
+
+ Ok(Json(AppAuthAdminClientResponse {
+ client_id,
+ status: APP_AUTH_CLIENT_STATUS_BLOCKED.to_string(),
+ }))
+}
+
+pub async fn app_auth_unblock_client(
+ headers: HeaderMap,
+ State(state): State>,
+ Path(client_id): Path,
+) -> Result, AppError> {
+ require_app_auth_admin(&headers, state.as_ref()).await?;
+ let client_id = sanitize_client_id(&client_id)?;
+
+ let updated = state
+ .db
+ .update_app_auth_client_status(&client_id, APP_AUTH_CLIENT_STATUS_ACTIVE)
+ .await?;
+ if !updated {
+ return Err(AppError::BadRequest("unknown app client".into()));
+ }
+
+ Ok(Json(AppAuthAdminClientResponse {
+ client_id,
+ status: APP_AUTH_CLIENT_STATUS_ACTIVE.to_string(),
+ }))
+}
+
+pub async fn app_auth_start(
+ State(state): State>,
+ Json(req): Json,
+) -> Result, AppError> {
+ if req.intent != APP_AUTH_INTENT {
+ return Err(AppError::BadRequest("unsupported intent".into()));
+ }
+ if req.state.trim().is_empty() {
+ return Err(AppError::BadRequest("state is required".into()));
+ }
+
+ let client = load_client(state.as_ref(), &req.client_id)
+ .await?
+ .ok_or_else(|| AppError::BadRequest("unknown app client".into()))?;
+ let redirect_uri = validated_allowed_url(
+ &req.redirect_uri,
+ &client.allowed_redirect_uris,
+ state.config.app_auth_enable_dev_localhost_wildcards,
+ )
+ .ok_or_else(|| AppError::BadRequest("redirect_uri is not registered for this client".into()))?;
+ let fallback_uri = select_fallback_uri(
+ &client,
+ req.fallback_uri.as_deref(),
+ state.config.app_auth_enable_dev_localhost_wildcards,
+ );
+ if req.fallback_uri.is_some() && fallback_uri.is_none() {
+ return Err(AppError::BadRequest(
+ "fallback_uri is not registered for this client".into(),
+ ));
+ }
+
+ let label = sanitize_label(
+ req.label
+ .as_deref()
+ .filter(|value| !value.trim().is_empty())
+ .unwrap_or(&client.display_name),
+ );
+ let (private_key_hex, public_key, delegate_address) = generate_delegate_key();
+ let encrypted_delegate_private_key = encrypt_delegate_private_key(&state, &private_key_hex)?;
+ let delegate_ref = random_token("appdel_");
+ let session_id = random_token("appses_");
+ let now = Utc::now();
+ let expires_at = now + Duration::seconds(APP_AUTH_SESSION_TTL_SECS as i64);
+
+ let delegate = AppAuthDelegateStore {
+ id: delegate_ref.clone(),
+ client_id: client.client_id.clone(),
+ account_id: None,
+ owner_address: None,
+ provider: None,
+ delegate_public_key: public_key.clone(),
+ delegate_address: delegate_address.clone(),
+ encrypted_delegate_private_key,
+ label: label.clone(),
+ status: "pending".to_string(),
+ tx_digest: None,
+ created_at: now,
+ updated_at: now,
+ };
+ let session = AppAuthSessionStore {
+ id: session_id.clone(),
+ client_id: client.client_id.clone(),
+ redirect_uri: redirect_uri.clone(),
+ fallback_uri: fallback_uri.clone(),
+ state: req.state.clone(),
+ label: label.clone(),
+ delegate_ref: delegate_ref.clone(),
+ delegate_public_key: public_key.clone(),
+ status: "pending".to_string(),
+ expires_at,
+ };
+
+ set_redis_json(
+ state.as_ref(),
+ &app_auth_delegate_key(&delegate_ref),
+ &delegate,
+ Some(APP_AUTH_DELEGATE_TTL_SECS),
+ )
+ .await?;
+ set_redis_json(
+ state.as_ref(),
+ &app_auth_session_key(&session_id),
+ &session,
+ Some(APP_AUTH_SESSION_TTL_SECS),
+ )
+ .await?;
+
+ Ok(Json(AppAuthStartResponse {
+ session_id,
+ client: AppAuthClientPublic {
+ client_id: client.client_id.clone(),
+ display_name: client.display_name.clone(),
+ },
+ redirect_host: url_origin_label(&redirect_uri),
+ label,
+ expires_at,
+ delegate: AppAuthStartDelegate {
+ public_key,
+ sui_address: delegate_address,
+ },
+ }))
+}
+
+pub async fn app_auth_complete(
+ State(state): State>,
+ Json(req): Json,
+) -> Result, AppError> {
+ let provider = normalize_provider(&req.provider)?;
+ let tx_digest = sanitize_short_token(&req.tx_digest, "tx_digest")?;
+ let account_id = sanitize_sui_object_id(&req.account_id, "account_id")?;
+ let owner_address = sanitize_sui_object_id(&req.owner_address, "owner_address")?;
+ let session = load_session(&state, &req.session_id).await?;
+ ensure_session_pending(&session)?;
+
+ let public_key_bytes = hex::decode(&session.delegate_public_key)
+ .map_err(|_| AppError::Internal("stored delegate public key is invalid".into()))?;
+ let verified_owner = verify_delegate_key_onchain(
+ &state.http_client,
+ &state.config.sui_rpc_url,
+ &account_id,
+ &public_key_bytes,
+ )
+ .await
+ .map_err(|e| AppError::BadRequest(format!("delegate key is not registered on-chain: {}", e)))?;
+
+ if !verified_owner.eq_ignore_ascii_case(&owner_address) {
+ return Err(AppError::BadRequest(
+ "verified owner does not match connected account".into(),
+ ));
+ }
+
+ let code = random_token("mwa_");
+ let code_hash = hash_secret(&code);
+ let session = take_session(state.as_ref(), &req.session_id).await?;
+ ensure_session_pending(&session)?;
+
+ let code_expires_at = Utc::now() + Duration::seconds(APP_AUTH_CODE_TTL_SECS as i64);
+ let code_payload = AppAuthCodeStore {
+ client_id: session.client_id.clone(),
+ redirect_uri: session.redirect_uri.clone(),
+ state: session.state.clone(),
+ account_id: account_id.clone(),
+ owner_address: verified_owner.clone(),
+ provider: provider.clone(),
+ delegate_ref: session.delegate_ref.clone(),
+ delegate_public_key: session.delegate_public_key.clone(),
+ delegate_label: session.label.clone(),
+ expires_at: code_expires_at,
+ };
+
+ let mut delegate = load_delegate(state.as_ref(), &session.delegate_ref).await?;
+ delegate.account_id = Some(account_id);
+ delegate.owner_address = Some(verified_owner);
+ delegate.provider = Some(provider);
+ delegate.status = "active".to_string();
+ delegate.tx_digest = Some(tx_digest);
+ delegate.updated_at = Utc::now();
+
+ set_redis_json(
+ state.as_ref(),
+ &app_auth_delegate_key(&session.delegate_ref),
+ &delegate,
+ Some(APP_AUTH_DELEGATE_TTL_SECS),
+ )
+ .await?;
+ set_redis_json(
+ state.as_ref(),
+ &app_auth_code_key(&session.client_id, &code_hash),
+ &code_payload,
+ Some(APP_AUTH_CODE_TTL_SECS),
+ )
+ .await?;
+
+ Ok(Json(AppAuthRedirectResponse {
+ redirect_url: build_success_redirect(&session.redirect_uri, &code, &session.state),
+ }))
+}
+
+pub async fn app_auth_cancel(
+ State(state): State>,
+ Json(req): Json,
+) -> Result, AppError> {
+ let session = take_session(state.as_ref(), &req.session_id).await?;
+ ensure_session_pending(&session)?;
+ let error = req
+ .error
+ .as_deref()
+ .map(|value| sanitize_error_code(value))
+ .unwrap_or_else(|| "access_denied".to_string());
+ let target = session
+ .fallback_uri
+ .as_deref()
+ .unwrap_or(session.redirect_uri.as_str());
+ Ok(Json(AppAuthRedirectResponse {
+ redirect_url: build_error_redirect(target, &error, &session.state),
+ }))
+}
+
+/// OAuth-style token endpoint: exchanges a short-lived `code` for the
+/// account / delegate the user approved on the consent screen.
+///
+/// ENG-1783 review N2 (2026-05-26): two canonical OAuth attacks (code reuse
+/// and cross-client code exchange) are structurally prevented by three layers
+/// of defense, which must be preserved together by any future refactor:
+/// 1. **Namespaced key.** `take_code` reads `app_auth:code:{client_id}:{hash}`.
+/// An attacker authenticating as client B looking up client A's code
+/// hits a different Redis key and gets `NotFound`. See
+/// `app_auth_code_key_namespaces_by_client_id` for the regression test.
+/// 2. **GETDEL atomicity.** `take_code` uses Redis `GETDEL`, which atomically
+/// reads-and-deletes. The first successful exchange consumes the code;
+/// every subsequent exchange (replay) finds an empty key.
+/// 3. **Equality re-check.** Even if a future refactor breaks the key
+/// namespacing, the `row.client_id != client_id` check below is
+/// defense-in-depth.
+pub async fn app_auth_token(
+ headers: HeaderMap,
+ State(state): State>,
+ Json(req): Json,
+) -> Result, AppError> {
+ if req.grant_type != "authorization_code" {
+ return Err(AppError::BadRequest("unsupported grant_type".into()));
+ }
+
+ let (client_id, client_secret) = parse_basic_client_auth(&headers)?;
+ let client = load_client(state.as_ref(), &client_id)
+ .await?
+ .ok_or_else(|| AppError::Unauthorized("invalid client credentials".into()))?;
+ let secret_hash = hash_secret(&client_secret);
+ if !constant_time_eq(
+ secret_hash.as_bytes(),
+ client.client_secret_sha256.as_bytes(),
+ ) {
+ return Err(AppError::Unauthorized("invalid client credentials".into()));
+ }
+
+ let code_hash = hash_secret(&req.code);
+ let row = take_code(state.as_ref(), &client_id, &code_hash).await?;
+ if row.client_id != client_id {
+ // Defense-in-depth (layer 3 in the doc comment above). The namespaced
+ // key already guarantees miss for cross-client exchange; this guards
+ // against the case where someone refactors `take_code` to ignore the
+ // client_id parameter.
+ return Err(AppError::Unauthorized(
+ "client_id does not match authorization code".into(),
+ ));
+ }
+ if let Some(reason) = code_binding_error(&row, &req.redirect_uri, &req.state, Utc::now()) {
+ return Err(AppError::Unauthorized(reason.into()));
+ }
+
+ Ok(Json(AppAuthTokenResponse {
+ account_id: row.account_id,
+ owner_address: row.owner_address,
+ provider: row.provider,
+ delegate: AppAuthTokenDelegate {
+ status: "active".to_string(),
+ delegate_ref: row.delegate_ref,
+ public_key: row.delegate_public_key,
+ label: row.delegate_label,
+ },
+ }))
+}
+
+async fn load_client(
+ state: &AppState,
+ client_id: &str,
+) -> Result, AppError> {
+ if let Some(client) = state.config.app_auth_clients.iter().find(|client| {
+ client.client_id == client_id && client.status == APP_AUTH_CLIENT_STATUS_ACTIVE
+ }) {
+ return Ok(Some(client.clone()));
+ }
+ state.db.fetch_app_auth_client(client_id).await
+}
+
+fn app_auth_admin_token_from_headers(headers: &HeaderMap) -> Option<&str> {
+ headers
+ .get("x-admin-token")
+ .and_then(|value| value.to_str().ok())
+ .or_else(|| {
+ headers
+ .get(header::AUTHORIZATION)
+ .and_then(|value| value.to_str().ok())
+ .and_then(|value| value.strip_prefix("Bearer "))
+ })
+}
+
+fn ensure_configured_app_auth_admin_token(
+ presented: &str,
+ state: &AppState,
+) -> Result<(), AppError> {
+ let configured =
+ state.config.app_auth_admin_token.as_ref().ok_or_else(|| {
+ AppError::Unauthorized("app auth admin token is not configured".into())
+ })?;
+ if presented.trim().is_empty() {
+ return Err(AppError::Unauthorized(
+ "missing app auth admin token".into(),
+ ));
+ }
+
+ let configured_hash = Sha256::digest(configured.as_slice());
+ let presented_hash = Sha256::digest(presented.as_bytes());
+ if !constant_time_eq(configured_hash.as_slice(), presented_hash.as_slice()) {
+ return Err(AppError::Unauthorized(
+ "invalid app auth admin token".into(),
+ ));
+ }
+
+ Ok(())
+}
+
+async fn require_app_auth_admin(headers: &HeaderMap, state: &AppState) -> Result<(), AppError> {
+ let presented = app_auth_admin_token_from_headers(headers)
+ .ok_or_else(|| AppError::Unauthorized("missing app auth admin token".into()))?;
+ if ensure_configured_app_auth_admin_token(presented, state).is_ok() {
+ return Ok(());
+ }
+
+ if get_redis_json::(state, &app_auth_admin_session_key(&hash_secret(presented)))
+ .await?
+ .is_some()
+ {
+ return Ok(());
+ }
+
+ Err(AppError::Unauthorized(
+ "invalid app auth admin token".into(),
+ ))
+}
+
+fn sanitize_client_id(client_id: &str) -> Result {
+ let client_id = client_id.trim();
+ if client_id.is_empty() || client_id.len() > 160 {
+ return Err(AppError::BadRequest("client_id is invalid".into()));
+ }
+ Ok(client_id.to_string())
+}
+
+fn sanitize_client_status(status: &str) -> Result {
+ match status.trim() {
+ APP_AUTH_CLIENT_STATUS_ACTIVE => Ok(APP_AUTH_CLIENT_STATUS_ACTIVE.to_string()),
+ APP_AUTH_CLIENT_STATUS_BLOCKED => Ok(APP_AUTH_CLIENT_STATUS_BLOCKED.to_string()),
+ _ => Err(AppError::BadRequest("client status is invalid".into())),
+ }
+}
+
+fn select_fallback_uri(
+ client: &AppAuthClientConfig,
+ requested_fallback_uri: Option<&str>,
+ allow_dev_localhost_wildcards: bool,
+) -> Option {
+ if let Some(requested) = requested_fallback_uri {
+ return validated_allowed_url(
+ requested,
+ &client.allowed_fallback_uris,
+ allow_dev_localhost_wildcards,
+ );
+ }
+ client.fallback_uri.as_deref().and_then(|fallback| {
+ validated_allowed_url(
+ fallback,
+ &client.allowed_fallback_uris,
+ allow_dev_localhost_wildcards,
+ )
+ })
+}
+
+fn validated_allowed_url(
+ candidate: &str,
+ allowlist: &[String],
+ allow_dev_localhost_wildcards: bool,
+) -> Option {
+ let candidate = validated_redirect_url(candidate)?;
+ let candidate_normalized = candidate.as_str();
+ let allowed = allowlist.iter().any(|allowed| {
+ validated_redirect_url(allowed)
+ .map(|allowed_url| allowed_url.as_str() == candidate_normalized)
+ .unwrap_or_else(|| {
+ localhost_wildcard_matches(&candidate, allowed, allow_dev_localhost_wildcards)
+ })
+ });
+ allowed.then(|| candidate_normalized.to_string())
+}
+
+fn validated_redirect_url(raw: &str) -> Option {
+ let url = Url::parse(raw).ok()?;
+ if url.fragment().is_some() || !url.username().is_empty() || url.password().is_some() {
+ return None;
+ }
+ let scheme = url.scheme();
+ let host = url.host_str()?;
+ let is_localhost = is_loopback_host(host);
+ if scheme != "https" && !(scheme == "http" && is_localhost) {
+ return None;
+ }
+ Some(url)
+}
+
+fn validated_registration_url(raw: &str) -> Option {
+ let url = validated_redirect_url(raw)?;
+ let host = url.host_str()?;
+ if url.scheme() != "https" || is_loopback_host(host) || is_reserved_memwal_host(host) {
+ return None;
+ }
+ Some(url.as_str().to_string())
+}
+
+fn localhost_wildcard_matches(
+ candidate: &Url,
+ allowed_pattern: &str,
+ allow_dev_localhost_wildcards: bool,
+) -> bool {
+ if !allow_dev_localhost_wildcards {
+ return false;
+ }
+ let Some(rest) = allowed_pattern.strip_prefix("http://") else {
+ return false;
+ };
+ let Some((host_pattern, path_pattern)) = rest.split_once('/') else {
+ return false;
+ };
+ let Some(host) = host_pattern.strip_suffix(":*") else {
+ return false;
+ };
+ if !matches!(host, "localhost" | "127.0.0.1") {
+ return false;
+ }
+ if candidate.scheme() != "http" || candidate.host_str() != Some(host) {
+ return false;
+ }
+ if candidate.port().is_none() || candidate.query().is_some() {
+ return false;
+ }
+ candidate.path() == format!("/{path_pattern}")
+}
+
+fn is_loopback_host(host: &str) -> bool {
+ matches!(host, "localhost" | "127.0.0.1" | "::1")
+}
+
+fn is_reserved_memwal_host(host: &str) -> bool {
+ let host = host.trim_end_matches('.').to_ascii_lowercase();
+ host == "memwal.ai" || host.ends_with(".memwal.ai")
+}
+
+fn build_success_redirect(redirect_uri: &str, code: &str, state: &str) -> String {
+ let mut url = Url::parse(redirect_uri).expect("stored redirect_uri must be valid");
+ url.query_pairs_mut()
+ .append_pair("code", code)
+ .append_pair("state", state);
+ url.to_string()
+}
+
+fn build_error_redirect(target_uri: &str, error: &str, state: &str) -> String {
+ let mut url = Url::parse(target_uri).expect("stored fallback/redirect_uri must be valid");
+ url.query_pairs_mut()
+ .append_pair("error", error)
+ .append_pair("state", state);
+ url.to_string()
+}
+
+fn url_origin_label(raw: &str) -> String {
+ Url::parse(raw)
+ .ok()
+ .and_then(|url| {
+ let host = url.host_str()?;
+ let mut origin = format!("{}://{}", url.scheme(), host);
+ if let Some(port) = url.port() {
+ origin.push_str(&format!(":{port}"));
+ }
+ Some(origin)
+ })
+ .unwrap_or_else(|| "registered app".to_string())
+}
+
+/// Sanitize the third-party-supplied per-session `label`. UNTRUSTED INPUT.
+/// The consent screen renders this **next to** the verified `display_name`,
+/// so spoofing pressure is real — see `APP_AUTH_LABEL_MAX_CHARS` for the
+/// rationale behind the char cap. The display layer must never substitute
+/// `label` for `display_name`; it's a per-session context string only.
+fn sanitize_label(raw: &str) -> String {
+ let cleaned = raw
+ .chars()
+ .filter(|ch| !matches!(ch, '<' | '>' | '&' | '"' | '\'' | '/' | '\\'))
+ .filter(|ch| !ch.is_control())
+ .collect::()
+ .trim()
+ .chars()
+ .take(APP_AUTH_LABEL_MAX_CHARS)
+ .collect::();
+ if cleaned.is_empty() {
+ DEFAULT_LABEL.to_string()
+ } else {
+ cleaned
+ }
+}
+
+fn sanitize_client_display_name(raw: &str) -> Result {
+ let cleaned = raw
+ .chars()
+ .filter(|ch| !matches!(ch, '<' | '>' | '&' | '"' | '\'' | '/' | '\\'))
+ .filter(|ch| !ch.is_control())
+ .collect::()
+ .trim()
+ .chars()
+ .take(APP_AUTH_DISPLAY_NAME_MAX_CHARS)
+ .collect::();
+ if cleaned.is_empty() {
+ Err(AppError::BadRequest("display_name is required".into()))
+ } else if display_name_uses_reserved_brand(&cleaned) {
+ Err(AppError::BadRequest(
+ "display_name cannot use Walrus Memory branding".into(),
+ ))
+ } else {
+ Ok(cleaned)
+ }
+}
+
+fn display_name_uses_reserved_brand(raw: &str) -> bool {
+ let normalized = raw
+ .chars()
+ .filter(|ch| ch.is_ascii_alphanumeric())
+ .flat_map(|ch| ch.to_lowercase())
+ .collect::();
+ normalized.contains("memwal") || normalized.contains("walrusmemory")
+}
+
+fn validate_registration_urls(field: &str, values: &[String]) -> Result, AppError> {
+ if values.is_empty() || values.len() > APP_AUTH_MAX_REGISTERED_URLS {
+ return Err(AppError::BadRequest(format!(
+ "{} must contain 1-{} URLs",
+ field, APP_AUTH_MAX_REGISTERED_URLS
+ )));
+ }
+
+ let mut urls = Vec::with_capacity(values.len());
+ for value in values {
+ let url = validated_registration_url(value)
+ .ok_or_else(|| AppError::BadRequest(format!("{} contains an invalid URL", field)))?;
+ if !urls.iter().any(|existing| existing == &url) {
+ urls.push(url);
+ }
+ }
+ if urls.is_empty() {
+ Err(AppError::BadRequest(format!(
+ "{} must contain at least one unique URL",
+ field
+ )))
+ } else {
+ Ok(urls)
+ }
+}
+
+fn sanitize_error_code(raw: &str) -> String {
+ let cleaned = raw
+ .chars()
+ .filter(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || *ch == '_')
+ .take(48)
+ .collect::();
+ if cleaned.is_empty() {
+ "server_error".to_string()
+ } else {
+ cleaned
+ }
+}
+
+fn sanitize_short_token(raw: &str, field: &str) -> Result {
+ let trimmed = raw.trim();
+ if trimmed.is_empty() || trimmed.len() > 160 {
+ return Err(AppError::BadRequest(format!("{} is invalid", field)));
+ }
+ Ok(trimmed.to_string())
+}
+
+fn sanitize_sui_object_id(raw: &str, field: &str) -> Result {
+ let value = raw.trim();
+ if value.starts_with("0x")
+ && value.len() == 66
+ && value[2..].chars().all(|ch| ch.is_ascii_hexdigit())
+ {
+ Ok(value.to_ascii_lowercase())
+ } else {
+ Err(AppError::BadRequest(format!("{} is invalid", field)))
+ }
+}
+
+fn normalize_provider(raw: &str) -> Result {
+ match raw {
+ "wallet" | "google" => Ok(raw.to_string()),
+ _ => Err(AppError::BadRequest("unsupported auth provider".into())),
+ }
+}
+
+fn random_token(prefix: &str) -> String {
+ let mut bytes = [0u8; 32];
+ OsRng.fill_bytes(&mut bytes);
+ format!(
+ "{}{}",
+ prefix,
+ general_purpose::URL_SAFE_NO_PAD.encode(bytes)
+ )
+}
+
+fn hash_secret(secret: &str) -> String {
+ hex::encode(Sha256::digest(secret.as_bytes()))
+}
+
+fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
+ if left.len() != right.len() {
+ return false;
+ }
+ let mut diff = 0u8;
+ for (a, b) in left.iter().zip(right.iter()) {
+ diff |= a ^ b;
+ }
+ diff == 0
+}
+
+fn generate_delegate_key() -> (String, String, String) {
+ let mut secret = [0u8; 32];
+ OsRng.fill_bytes(&mut secret);
+ let signing_key = SigningKey::from_bytes(&secret);
+ let public_key = signing_key.verifying_key().to_bytes();
+ let private_key_hex = hex::encode(secret);
+ let public_key_hex = hex::encode(public_key);
+ let delegate_address = delegate_public_key_to_sui_address(&public_key);
+ (private_key_hex, public_key_hex, delegate_address)
+}
+
+fn delegate_public_key_to_sui_address(public_key: &[u8; 32]) -> String {
+ let mut hasher = Blake2bVar::new(32).expect("BLAKE2b-256 length is valid");
+ hasher.update(&[0x00]);
+ hasher.update(public_key);
+ let mut digest = [0u8; 32];
+ hasher
+ .finalize_variable(&mut digest)
+ .expect("BLAKE2b output length matches digest buffer");
+ format!("0x{}", hex::encode(digest))
+}
+
+fn encrypt_delegate_private_key(
+ state: &AppState,
+ private_key_hex: &str,
+) -> Result {
+ let secret = state
+ .config
+ .app_auth_delegate_secret
+ .as_ref()
+ .ok_or_else(|| {
+ AppError::Internal(
+ "APP_AUTH_DELEGATE_ENCRYPTION_KEY or SIDECAR_AUTH_TOKEN is required for app auth"
+ .into(),
+ )
+ })?;
+ let cipher = Aes256Gcm::new_from_slice(secret.as_slice())
+ .map_err(|_| AppError::Internal("app auth delegate encryption key is invalid".into()))?;
+ let mut nonce_bytes = [0u8; 12];
+ OsRng.fill_bytes(&mut nonce_bytes);
+ let ciphertext = cipher
+ .encrypt(Nonce::from_slice(&nonce_bytes), private_key_hex.as_bytes())
+ .map_err(|_| AppError::Internal("failed to encrypt app delegate private key".into()))?;
+ Ok(format!(
+ "v1.{}.{}",
+ general_purpose::URL_SAFE_NO_PAD.encode(nonce_bytes),
+ general_purpose::URL_SAFE_NO_PAD.encode(ciphertext)
+ ))
+}
+
+fn app_auth_session_key(session_id: &str) -> String {
+ format!("app_auth:session:{session_id}")
+}
+
+fn app_auth_code_key(client_id: &str, code_hash: &str) -> String {
+ format!("app_auth:code:{client_id}:{code_hash}")
+}
+
+fn app_auth_delegate_key(delegate_ref: &str) -> String {
+ format!("app_auth:delegate:{delegate_ref}")
+}
+
+fn app_auth_admin_session_key(token_hash: &str) -> String {
+ format!("app_auth:admin_session:{token_hash}")
+}
+
+async fn set_redis_json(
+ state: &AppState,
+ key: &str,
+ value: &T,
+ ttl_secs: Option,
+) -> Result<(), AppError> {
+ let payload = serde_json::to_string(value)
+ .map_err(|e| AppError::Internal(format!("Failed to encode app auth state: {}", e)))?;
+ let mut redis = state.redis.clone();
+ if let Some(ttl_secs) = ttl_secs {
+ redis
+ .set_ex::<_, _, ()>(key, payload, ttl_secs)
+ .await
+ .map_err(|e| AppError::Internal(format!("Failed to store app auth state: {}", e)))?;
+ } else {
+ redis
+ .set::<_, _, ()>(key, payload)
+ .await
+ .map_err(|e| AppError::Internal(format!("Failed to store app auth state: {}", e)))?;
+ }
+ Ok(())
+}
+
+async fn get_redis_json(
+ state: &AppState,
+ key: &str,
+) -> Result, AppError> {
+ let mut redis = state.redis.clone();
+ let payload: Option = redis
+ .get(key)
+ .await
+ .map_err(|e| AppError::Internal(format!("Failed to load app auth state: {}", e)))?;
+ payload
+ .map(|payload| {
+ serde_json::from_str(&payload)
+ .map_err(|e| AppError::Internal(format!("Failed to decode app auth state: {}", e)))
+ })
+ .transpose()
+}
+
+async fn take_redis_json(
+ state: &AppState,
+ key: &str,
+) -> Result, AppError> {
+ let mut redis = state.redis.clone();
+ let payload: Option = redis::cmd("GETDEL")
+ .arg(key)
+ .query_async(&mut redis)
+ .await
+ .map_err(|e| AppError::Internal(format!("Failed to consume app auth state: {}", e)))?;
+ payload
+ .map(|payload| {
+ serde_json::from_str(&payload)
+ .map_err(|e| AppError::Internal(format!("Failed to decode app auth state: {}", e)))
+ })
+ .transpose()
+}
+
+async fn load_session(state: &AppState, session_id: &str) -> Result {
+ get_redis_json(state, &app_auth_session_key(session_id))
+ .await?
+ .ok_or_else(|| AppError::BadRequest("unknown app auth session".into()))
+}
+
+async fn take_session(state: &AppState, session_id: &str) -> Result {
+ take_redis_json(state, &app_auth_session_key(session_id))
+ .await?
+ .ok_or_else(|| AppError::BadRequest("unknown or already used app auth session".into()))
+}
+
+async fn load_delegate(
+ state: &AppState,
+ delegate_ref: &str,
+) -> Result {
+ get_redis_json(state, &app_auth_delegate_key(delegate_ref))
+ .await?
+ .ok_or_else(|| AppError::Internal("app auth delegate ref is missing".into()))
+}
+
+async fn take_code(
+ state: &AppState,
+ client_id: &str,
+ code_hash: &str,
+) -> Result {
+ take_redis_json(state, &app_auth_code_key(client_id, code_hash))
+ .await?
+ .ok_or_else(|| {
+ AppError::Unauthorized("authorization code expired, already used, or invalid".into())
+ })
+}
+
+fn ensure_session_pending(session: &AppAuthSessionStore) -> Result<(), AppError> {
+ if session.status != "pending" {
+ return Err(AppError::BadRequest(
+ "app auth session is not pending".into(),
+ ));
+ }
+ if session.expires_at <= Utc::now() {
+ return Err(AppError::BadRequest("app auth session expired".into()));
+ }
+ Ok(())
+}
+
+fn code_binding_error(
+ row: &AppAuthCodeStore,
+ redirect_uri: &str,
+ state: &str,
+ now: DateTime,
+) -> Option<&'static str> {
+ if row.expires_at <= now {
+ return Some("authorization code expired");
+ }
+ if row.redirect_uri != redirect_uri {
+ return Some("redirect_uri does not match authorization code");
+ }
+ if row.state != state {
+ return Some("state does not match authorization code");
+ }
+ None
+}
+
+fn parse_basic_client_auth(headers: &HeaderMap) -> Result<(String, String), AppError> {
+ let auth = headers
+ .get(header::AUTHORIZATION)
+ .and_then(|value| value.to_str().ok())
+ .ok_or_else(|| AppError::Unauthorized("missing client authentication".into()))?;
+ let encoded = auth
+ .strip_prefix("Basic ")
+ .ok_or_else(|| AppError::Unauthorized("client authentication must use Basic".into()))?;
+ let decoded = general_purpose::STANDARD
+ .decode(encoded)
+ .map_err(|_| AppError::Unauthorized("client authentication is invalid".into()))?;
+ let decoded = String::from_utf8(decoded)
+ .map_err(|_| AppError::Unauthorized("client authentication is invalid".into()))?;
+ let (client_id, secret) = decoded
+ .split_once(':')
+ .ok_or_else(|| AppError::Unauthorized("client authentication is invalid".into()))?;
+ if client_id.is_empty() || secret.is_empty() {
+ return Err(AppError::Unauthorized(
+ "client authentication is invalid".into(),
+ ));
+ }
+ Ok((client_id.to_string(), secret.to_string()))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn demo_client() -> AppAuthClientConfig {
+ AppAuthClientConfig {
+ client_id: "demo_dapp".into(),
+ client_secret_sha256: hash_secret("demo_dapp_secret"),
+ display_name: "Demo Dapp".into(),
+ allowed_redirect_uris: vec![
+ "https://demo-app.com/api/memwal/callback".into(),
+ "http://localhost:5173/api/memwal/callback".into(),
+ ],
+ fallback_uri: Some("https://demo-app.com/memwal/error".into()),
+ allowed_fallback_uris: vec!["https://demo-app.com/memwal/error".into()],
+ status: APP_AUTH_CLIENT_STATUS_ACTIVE.to_string(),
+ }
+ }
+
+ #[test]
+ fn validates_exact_allowlisted_redirect() {
+ let client = demo_client();
+ assert_eq!(
+ validated_allowed_url(
+ "https://demo-app.com/api/memwal/callback",
+ &client.allowed_redirect_uris,
+ false
+ )
+ .as_deref(),
+ Some("https://demo-app.com/api/memwal/callback")
+ );
+ assert_eq!(
+ validated_allowed_url(
+ "https://demo-app.com/api/memwal/callback/extra",
+ &client.allowed_redirect_uris,
+ false
+ ),
+ None
+ );
+ }
+
+ #[test]
+ fn validates_deployed_app_redirect_and_fallback_exactly() {
+ let client = AppAuthClientConfig {
+ client_id: "deployed_demo".into(),
+ client_secret_sha256: hash_secret("deployed_demo_secret"),
+ display_name: "Deployed Demo".into(),
+ allowed_redirect_uris: vec![
+ "https://deployed-demo.example.com/api/memwal/callback".into()
+ ],
+ fallback_uri: Some("https://deployed-demo.example.com/memwal/error".into()),
+ allowed_fallback_uris: vec!["https://deployed-demo.example.com/memwal/error".into()],
+ status: APP_AUTH_CLIENT_STATUS_ACTIVE.to_string(),
+ };
+
+ assert_eq!(
+ validated_allowed_url(
+ "https://deployed-demo.example.com/api/memwal/callback",
+ &client.allowed_redirect_uris,
+ false
+ )
+ .as_deref(),
+ Some("https://deployed-demo.example.com/api/memwal/callback")
+ );
+ assert_eq!(
+ validated_allowed_url(
+ "https://deployed-demo.example.com/api/memwal/callback/",
+ &client.allowed_redirect_uris,
+ false
+ ),
+ None
+ );
+ assert_eq!(
+ select_fallback_uri(
+ &client,
+ Some("https://deployed-demo.example.com/memwal/error"),
+ false
+ )
+ .as_deref(),
+ Some("https://deployed-demo.example.com/memwal/error")
+ );
+ assert_eq!(
+ select_fallback_uri(
+ &client,
+ Some("https://deployed-demo.example.com/other"),
+ false
+ ),
+ None
+ );
+ }
+
+ #[test]
+ fn rejects_http_for_deployed_apps_even_when_allowlisted() {
+ let allowlist = vec!["http://deployed-demo.example.com/api/memwal/callback".to_string()];
+
+ assert_eq!(
+ validated_allowed_url(
+ "http://deployed-demo.example.com/api/memwal/callback",
+ &allowlist,
+ false
+ ),
+ None
+ );
+ assert_eq!(
+ validated_allowed_url(
+ "http://deployed-demo.example.com/api/memwal/callback",
+ &allowlist,
+ true
+ ),
+ None
+ );
+ }
+
+ #[test]
+ fn rejects_open_redirect_inputs() {
+ let client = demo_client();
+ for candidate in [
+ "javascript:alert(1)",
+ "https://demo-app.com.evil.test/api/memwal/callback",
+ "https://demo-app.com/api/memwal/callback#token",
+ "https://attacker@demo-app.com/api/memwal/callback",
+ "http://demo-app.com/api/memwal/callback",
+ ] {
+ assert_eq!(
+ validated_allowed_url(candidate, &client.allowed_redirect_uris, false),
+ None,
+ "{candidate} must be rejected"
+ );
+ }
+ }
+
+ #[test]
+ fn allows_localhost_http_for_dev_only_when_registered() {
+ let client = demo_client();
+ assert_eq!(
+ validated_allowed_url(
+ "http://localhost:5173/api/memwal/callback",
+ &client.allowed_redirect_uris,
+ false
+ )
+ .as_deref(),
+ Some("http://localhost:5173/api/memwal/callback")
+ );
+ }
+
+ #[test]
+ fn localhost_wildcard_accepts_any_port_when_enabled() {
+ let allowlist = vec![
+ "http://localhost:*/api/memwal/callback".to_string(),
+ "http://127.0.0.1:*/api/memwal/callback".to_string(),
+ ];
+ assert_eq!(
+ validated_allowed_url(
+ "http://localhost:3000/api/memwal/callback",
+ &allowlist,
+ true
+ )
+ .as_deref(),
+ Some("http://localhost:3000/api/memwal/callback")
+ );
+ assert_eq!(
+ validated_allowed_url(
+ "http://localhost:5174/api/memwal/callback",
+ &allowlist,
+ true
+ )
+ .as_deref(),
+ Some("http://localhost:5174/api/memwal/callback")
+ );
+ assert_eq!(
+ validated_allowed_url(
+ "http://127.0.0.1:8080/api/memwal/callback",
+ &allowlist,
+ true
+ )
+ .as_deref(),
+ Some("http://127.0.0.1:8080/api/memwal/callback")
+ );
+ }
+
+ #[test]
+ fn localhost_wildcard_rejects_unsafe_values() {
+ let allowlist = vec!["http://localhost:*/api/memwal/callback".to_string()];
+ for candidate in [
+ "http://localhost:3000/wrong",
+ "http://localhost.evil.test:3000/api/memwal/callback",
+ "https://localhost:3000/api/memwal/callback",
+ "http://attacker@localhost:3000/api/memwal/callback",
+ "http://localhost:3000/api/memwal/callback#frag",
+ "http://localhost/api/memwal/callback",
+ "http://localhost:3000/api/memwal/callback?x=1",
+ ] {
+ assert_eq!(
+ validated_allowed_url(candidate, &allowlist, true),
+ None,
+ "{candidate} must be rejected"
+ );
+ }
+ }
+
+ #[test]
+ fn localhost_wildcard_is_ignored_when_disabled() {
+ let allowlist = vec!["http://localhost:*/api/memwal/callback".to_string()];
+ assert_eq!(
+ validated_allowed_url(
+ "http://localhost:3000/api/memwal/callback",
+ &allowlist,
+ false
+ ),
+ None
+ );
+ }
+
+ #[test]
+ fn localhost_wildcard_pattern_must_be_loopback_http_port() {
+ let candidate = "http://localhost:3000/api/memwal/callback";
+ for pattern in [
+ "https://localhost:*/api/memwal/callback",
+ "http://evil.test:*/api/memwal/callback",
+ "http://localhost/api/memwal/callback",
+ "http://localhost:*/different",
+ ] {
+ assert_eq!(
+ validated_allowed_url(candidate, &[pattern.to_string()], true),
+ None,
+ "{pattern} must not allow localhost wildcard redirect"
+ );
+ }
+ }
+
+ #[test]
+ fn fallback_uri_must_be_allowlisted() {
+ let client = demo_client();
+ assert_eq!(
+ select_fallback_uri(&client, Some("https://demo-app.com/memwal/error"), false)
+ .as_deref(),
+ Some("https://demo-app.com/memwal/error")
+ );
+ assert_eq!(
+ select_fallback_uri(&client, Some("https://evil.test/callback"), false),
+ None
+ );
+ }
+
+ #[test]
+ fn fallback_uri_accepts_localhost_wildcard_when_enabled() {
+ let client = AppAuthClientConfig {
+ client_id: "dev_localhost".into(),
+ client_secret_sha256: hash_secret("dev_localhost_secret"),
+ display_name: "Local Dev App".into(),
+ allowed_redirect_uris: vec![],
+ fallback_uri: None,
+ allowed_fallback_uris: vec!["http://localhost:*/memwal/error".into()],
+ status: APP_AUTH_CLIENT_STATUS_ACTIVE.to_string(),
+ };
+ assert_eq!(
+ select_fallback_uri(&client, Some("http://localhost:5174/memwal/error"), true)
+ .as_deref(),
+ Some("http://localhost:5174/memwal/error")
+ );
+ assert_eq!(
+ select_fallback_uri(&client, Some("http://localhost:5174/other"), true),
+ None
+ );
+ }
+
+ #[test]
+ fn success_redirect_contains_only_code_and_state() {
+ let redirect = build_success_redirect(
+ "https://demo-app.com/api/memwal/callback",
+ "mwa_test",
+ "random_state",
+ );
+ let url = Url::parse(&redirect).unwrap();
+ let params: std::collections::HashMap<_, _> = url.query_pairs().collect();
+ assert_eq!(params.get("code").map(|v| v.as_ref()), Some("mwa_test"));
+ assert_eq!(
+ params.get("state").map(|v| v.as_ref()),
+ Some("random_state")
+ );
+ assert!(!params.contains_key("account_id"));
+ assert!(!params.contains_key("delegate_key"));
+ assert!(!params.contains_key("token"));
+ assert!(!params.contains_key("bearer"));
+ }
+
+ #[test]
+ fn error_redirect_preserves_state_without_credentials() {
+ let redirect = build_error_redirect(
+ "https://demo-app.com/memwal/error",
+ "access_denied",
+ "random_state",
+ );
+ let url = Url::parse(&redirect).unwrap();
+ let params: std::collections::HashMap<_, _> = url.query_pairs().collect();
+ assert_eq!(
+ params.get("error").map(|v| v.as_ref()),
+ Some("access_denied")
+ );
+ assert_eq!(
+ params.get("state").map(|v| v.as_ref()),
+ Some("random_state")
+ );
+ assert!(!params.contains_key("delegate_key"));
+ assert!(!params.contains_key("token"));
+ }
+
+ #[test]
+ fn origin_label_includes_scheme_and_port() {
+ assert_eq!(
+ url_origin_label("https://demo-app.com/api/memwal/callback"),
+ "https://demo-app.com"
+ );
+ assert_eq!(
+ url_origin_label("http://localhost:3000/api/memwal/callback"),
+ "http://localhost:3000"
+ );
+ }
+
+ // ENG-1783 review N2 (2026-05-26): regression test for layer 1 of
+ // `app_auth_token`'s cross-client/replay defense (see doc comment on
+ // `app_auth_token`). The Redis key for an authorization code MUST include
+ // `client_id` so that client B's token request looks up a different key
+ // than client A's code was stored under. If a refactor ever changes
+ // `app_auth_code_key` to drop the client_id segment, this test fails and
+ // forces a review of the equality re-check (layer 3) before it's the only
+ // remaining barrier.
+ #[test]
+ fn app_auth_code_key_namespaces_by_client_id() {
+ let code_hash = "deadbeef";
+ let k_a = app_auth_code_key("client_a", code_hash);
+ let k_b = app_auth_code_key("client_b", code_hash);
+ assert_ne!(k_a, k_b);
+ assert!(k_a.contains("client_a"));
+ assert!(k_b.contains("client_b"));
+ assert!(k_a.contains(code_hash));
+ }
+
+ // ENG-1783 review N4 (2026-05-26): label sanitization tightened from 64
+ // to 32 chars. Lock the cap so a future bump back to 64 forces a fresh
+ // phishing-surface review.
+ #[test]
+ fn sanitize_label_caps_at_thirty_two_chars() {
+ let long = "X".repeat(100);
+ let cleaned = sanitize_label(&long);
+ assert_eq!(cleaned.chars().count(), APP_AUTH_LABEL_MAX_CHARS);
+ assert_eq!(APP_AUTH_LABEL_MAX_CHARS, 32);
+ }
+
+ #[test]
+ fn sanitize_label_falls_back_to_default_on_empty_or_only_stripped_chars() {
+ assert_eq!(sanitize_label(""), DEFAULT_LABEL);
+ assert_eq!(sanitize_label(" "), DEFAULT_LABEL);
+ assert_eq!(sanitize_label("<<<>>>"), DEFAULT_LABEL);
+ }
+
+ #[test]
+ fn public_registration_url_validation_requires_deployed_https() {
+ assert_eq!(
+ validated_registration_url("https://example.com/api/memwal/callback").as_deref(),
+ Some("https://example.com/api/memwal/callback")
+ );
+ assert_eq!(
+ validated_registration_url("http://example.com/api/memwal/callback"),
+ None
+ );
+ assert_eq!(
+ validated_registration_url("http://localhost:3000/api/memwal/callback"),
+ None
+ );
+ assert_eq!(
+ validated_registration_url("https://dev.memwal.ai/api/memwal/callback"),
+ None
+ );
+ assert_eq!(
+ validated_registration_url("https://memwal.ai/api/memwal/callback"),
+ None
+ );
+ }
+
+ #[test]
+ fn registration_display_name_is_sanitized_and_required() {
+ assert_eq!(
+ sanitize_client_display_name(" ").unwrap(),
+ "My DemoApp"
+ );
+ assert!(sanitize_client_display_name("<<>>").is_err());
+ assert!(sanitize_client_display_name("MemWal Official App").is_err());
+ assert!(sanitize_client_display_name("Walrus-Memory Login").is_err());
+ assert_eq!(
+ sanitize_client_display_name(&"x".repeat(100))
+ .unwrap()
+ .chars()
+ .count(),
+ APP_AUTH_DISPLAY_NAME_MAX_CHARS
+ );
+ }
+
+ #[test]
+ fn registration_urls_are_deduped_and_capped() {
+ let urls = validate_registration_urls(
+ "redirect_uris",
+ &[
+ "https://example.com/api/memwal/callback".into(),
+ "https://example.com/api/memwal/callback".into(),
+ ],
+ )
+ .unwrap();
+ assert_eq!(urls, vec!["https://example.com/api/memwal/callback"]);
+
+ let too_many = (0..=APP_AUTH_MAX_REGISTERED_URLS)
+ .map(|idx| format!("https://example.com/callback/{idx}"))
+ .collect::>();
+ assert!(validate_registration_urls("redirect_uris", &too_many).is_err());
+ }
+
+ #[test]
+ fn code_binding_rejects_expired_and_mismatched_inputs() {
+ let now = Utc::now();
+ let mut row = AppAuthCodeStore {
+ client_id: "demo_dapp".into(),
+ redirect_uri: "https://demo-app.com/api/memwal/callback".into(),
+ state: "random_state".into(),
+ account_id: "0x".to_string() + &"1".repeat(64),
+ owner_address: "0x".to_string() + &"2".repeat(64),
+ provider: "wallet".into(),
+ delegate_ref: "appdel_1".into(),
+ delegate_public_key: "a".repeat(64),
+ delegate_label: "Demo".into(),
+ expires_at: now + Duration::minutes(5),
+ };
+ assert_eq!(
+ code_binding_error(
+ &row,
+ "https://demo-app.com/api/memwal/callback",
+ "random_state",
+ now
+ ),
+ None
+ );
+ assert_eq!(
+ code_binding_error(&row, "https://demo-app.com/other", "random_state", now),
+ Some("redirect_uri does not match authorization code")
+ );
+ assert_eq!(
+ code_binding_error(
+ &row,
+ "https://demo-app.com/api/memwal/callback",
+ "wrong",
+ now
+ ),
+ Some("state does not match authorization code")
+ );
+ row.expires_at = now - Duration::seconds(1);
+ assert_eq!(
+ code_binding_error(
+ &row,
+ "https://demo-app.com/api/memwal/callback",
+ "random_state",
+ now
+ ),
+ Some("authorization code expired")
+ );
+ }
+
+ #[test]
+ fn client_secret_hash_matches_demo_secret() {
+ assert!(constant_time_eq(
+ hash_secret("demo_dapp_secret").as_bytes(),
+ demo_client().client_secret_sha256.as_bytes()
+ ));
+ assert!(!constant_time_eq(
+ hash_secret("wrong").as_bytes(),
+ demo_client().client_secret_sha256.as_bytes()
+ ));
+ }
+}
diff --git a/services/server/src/routes/mod.rs b/services/server/src/routes/mod.rs
index 625d4e50..1456e9af 100644
--- a/services/server/src/routes/mod.rs
+++ b/services/server/src/routes/mod.rs
@@ -16,6 +16,7 @@
mod admin;
mod analyze;
+mod app_auth;
mod recall;
mod remember;
mod sponsor;
@@ -24,6 +25,12 @@ mod sponsor;
// without having to know which submodule each handler lives in.
pub use admin::{ask, forget, get_config, health, restore, stats, version};
pub use analyze::analyze;
+pub use app_auth::{
+ app_auth_admin_create_client, app_auth_admin_list_clients, app_auth_admin_login,
+ app_auth_admin_update_client, app_auth_block_client, app_auth_cancel, app_auth_complete,
+ app_auth_create_client, app_auth_register, app_auth_rotate_client_secret, app_auth_start,
+ app_auth_token, app_auth_unblock_client,
+};
pub use recall::{recall, recall_manual};
pub use remember::{
remember, remember_bulk, remember_bulk_status, remember_manual, remember_status,
diff --git a/services/server/src/routes/remember.rs b/services/server/src/routes/remember.rs
index f8a93bba..2ad8a0d8 100644
--- a/services/server/src/routes/remember.rs
+++ b/services/server/src/routes/remember.rs
@@ -1073,6 +1073,11 @@ mod tests {
sponsor_rate_limit: crate::types::SponsorRateLimitConfig::default(),
allowed_origins: String::new(),
benchmark_mode: false,
+ app_auth_clients: vec![],
+ app_auth_public_client_registration_enabled: false,
+ app_auth_admin_token: None,
+ app_auth_enable_dev_localhost_wildcards: false,
+ app_auth_delegate_secret: None,
}
}
diff --git a/services/server/src/storage/db.rs b/services/server/src/storage/db.rs
index 18a0575f..03cb3ae3 100644
--- a/services/server/src/storage/db.rs
+++ b/services/server/src/storage/db.rs
@@ -2,7 +2,7 @@ use pgvector::Vector;
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
-use crate::types::{AppError, SearchHit};
+use crate::types::{AppAuthClientConfig, AppAuthClientRecord, AppError, SearchHit};
pub struct VectorDb {
pool: PgPool,
@@ -90,6 +90,15 @@ impl VectorDb {
.await
.map_err(|e| AppError::Internal(format!("Failed to run migration 009: {}", e)))?;
+ // ENG-1783: hosted app-auth dynamic client registration. Allows
+ // third-party dapps to register exact callback/fallback URLs without
+ // requiring operators to edit APP_AUTH_CLIENTS_JSON.
+ let migration_010 = include_str!("../../migrations/010_app_auth_clients.sql");
+ sqlx::raw_sql(migration_010)
+ .execute(&pool)
+ .await
+ .map_err(|e| AppError::Internal(format!("Failed to run migration 010: {}", e)))?;
+
tracing::info!("database connected and migrations applied");
Ok(Self { pool })
@@ -101,6 +110,264 @@ impl VectorDb {
&self.pool
}
+ pub async fn insert_app_auth_client(
+ &self,
+ client: &AppAuthClientConfig,
+ ) -> Result<(), AppError> {
+ let started = std::time::Instant::now();
+ let result = sqlx::query(
+ "INSERT INTO app_auth_clients (
+ client_id,
+ client_secret_sha256,
+ display_name,
+ allowed_redirect_uris,
+ fallback_uri,
+ allowed_fallback_uris,
+ status
+ )
+ VALUES ($1, $2, $3, $4, $5, $6, $7)",
+ )
+ .bind(&client.client_id)
+ .bind(&client.client_secret_sha256)
+ .bind(&client.display_name)
+ .bind(&client.allowed_redirect_uris)
+ .bind(&client.fallback_uri)
+ .bind(&client.allowed_fallback_uris)
+ .bind(&client.status)
+ .execute(&self.pool)
+ .await
+ .map_err(|e| AppError::Internal(format!("Failed to insert app auth client: {}", e)));
+ crate::observability::observe_db(
+ "app_auth_clients.insert",
+ db_status(&result),
+ started.elapsed(),
+ );
+ result?;
+ Ok(())
+ }
+
+ pub async fn fetch_app_auth_client(
+ &self,
+ client_id: &str,
+ ) -> Result, AppError> {
+ let started = std::time::Instant::now();
+ let result: Result<
+ Option<(
+ String,
+ String,
+ String,
+ Vec,
+ Option,
+ Vec,
+ String,
+ )>,
+ AppError,
+ > = sqlx::query_as(
+ "SELECT
+ client_id,
+ client_secret_sha256,
+ display_name,
+ allowed_redirect_uris,
+ fallback_uri,
+ allowed_fallback_uris,
+ status
+ FROM app_auth_clients
+ WHERE client_id = $1
+ AND status = 'active'
+ LIMIT 1",
+ )
+ .bind(client_id)
+ .fetch_optional(&self.pool)
+ .await
+ .map_err(|e| AppError::Internal(format!("Failed to fetch app auth client: {}", e)));
+ crate::observability::observe_db(
+ "app_auth_clients.fetch",
+ db_status(&result),
+ started.elapsed(),
+ );
+
+ result.map(|row| {
+ row.map(
+ |(
+ client_id,
+ client_secret_sha256,
+ display_name,
+ allowed_redirect_uris,
+ fallback_uri,
+ allowed_fallback_uris,
+ status,
+ )| AppAuthClientConfig {
+ client_id,
+ client_secret_sha256,
+ display_name,
+ allowed_redirect_uris,
+ fallback_uri,
+ allowed_fallback_uris,
+ status,
+ },
+ )
+ })
+ }
+
+ pub async fn update_app_auth_client_status(
+ &self,
+ client_id: &str,
+ status: &str,
+ ) -> Result {
+ let started = std::time::Instant::now();
+ let result = sqlx::query(
+ "UPDATE app_auth_clients
+ SET status = $2,
+ updated_at = NOW()
+ WHERE client_id = $1",
+ )
+ .bind(client_id)
+ .bind(status)
+ .execute(&self.pool)
+ .await
+ .map_err(|e| AppError::Internal(format!("Failed to update app auth client status: {}", e)));
+ crate::observability::observe_db(
+ "app_auth_clients.update_status",
+ db_status(&result),
+ started.elapsed(),
+ );
+
+ result.map(|done| done.rows_affected() > 0)
+ }
+
+ pub async fn list_app_auth_clients(&self) -> Result, AppError> {
+ let started = std::time::Instant::now();
+ let result: Result<
+ Vec<(
+ String,
+ String,
+ String,
+ Vec,
+ Option,
+ Vec,
+ String,
+ chrono::DateTime,
+ chrono::DateTime,
+ )>,
+ AppError,
+ > = sqlx::query_as(
+ "SELECT
+ client_id,
+ client_secret_sha256,
+ display_name,
+ allowed_redirect_uris,
+ fallback_uri,
+ allowed_fallback_uris,
+ status,
+ created_at,
+ updated_at
+ FROM app_auth_clients
+ ORDER BY created_at DESC",
+ )
+ .fetch_all(&self.pool)
+ .await
+ .map_err(|e| AppError::Internal(format!("Failed to list app auth clients: {}", e)));
+ crate::observability::observe_db(
+ "app_auth_clients.list",
+ db_status(&result),
+ started.elapsed(),
+ );
+
+ result.map(|rows| {
+ rows.into_iter()
+ .map(
+ |(
+ client_id,
+ client_secret_sha256,
+ display_name,
+ allowed_redirect_uris,
+ fallback_uri,
+ allowed_fallback_uris,
+ status,
+ created_at,
+ updated_at,
+ )| AppAuthClientRecord {
+ client: AppAuthClientConfig {
+ client_id,
+ client_secret_sha256,
+ display_name,
+ allowed_redirect_uris,
+ fallback_uri,
+ allowed_fallback_uris,
+ status,
+ },
+ created_at,
+ updated_at,
+ },
+ )
+ .collect()
+ })
+ }
+
+ pub async fn update_app_auth_client(
+ &self,
+ client_id: &str,
+ display_name: &str,
+ allowed_redirect_uris: &[String],
+ fallback_uri: Option<&String>,
+ allowed_fallback_uris: &[String],
+ status: &str,
+ ) -> Result {
+ let started = std::time::Instant::now();
+ let result = sqlx::query(
+ "UPDATE app_auth_clients
+ SET display_name = $2,
+ allowed_redirect_uris = $3,
+ fallback_uri = $4,
+ allowed_fallback_uris = $5,
+ status = $6,
+ updated_at = NOW()
+ WHERE client_id = $1",
+ )
+ .bind(client_id)
+ .bind(display_name)
+ .bind(allowed_redirect_uris)
+ .bind(fallback_uri)
+ .bind(allowed_fallback_uris)
+ .bind(status)
+ .execute(&self.pool)
+ .await
+ .map_err(|e| AppError::Internal(format!("Failed to update app auth client: {}", e)));
+ crate::observability::observe_db(
+ "app_auth_clients.update",
+ db_status(&result),
+ started.elapsed(),
+ );
+
+ result.map(|done| done.rows_affected() > 0)
+ }
+
+ pub async fn update_app_auth_client_secret(
+ &self,
+ client_id: &str,
+ client_secret_sha256: &str,
+ ) -> Result {
+ let started = std::time::Instant::now();
+ let result = sqlx::query(
+ "UPDATE app_auth_clients
+ SET client_secret_sha256 = $2,
+ updated_at = NOW()
+ WHERE client_id = $1",
+ )
+ .bind(client_id)
+ .bind(client_secret_sha256)
+ .execute(&self.pool)
+ .await
+ .map_err(|e| AppError::Internal(format!("Failed to rotate app auth client secret: {}", e)));
+ crate::observability::observe_db(
+ "app_auth_clients.rotate_secret",
+ db_status(&result),
+ started.elapsed(),
+ );
+
+ result.map(|done| done.rows_affected() > 0)
+ }
+
/// Insert a vector entry (with blob size tracking for storage quota).
///
/// MEM-54: `importance` is the per-fact score set at extraction time
diff --git a/services/server/src/types.rs b/services/server/src/types.rs
index 3b9714e6..47a2f7d9 100644
--- a/services/server/src/types.rs
+++ b/services/server/src/types.rs
@@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize};
+use sha2::{Digest, Sha256};
use std::sync::Arc;
use crate::engine::MemoryEngine;
@@ -165,6 +166,52 @@ impl KeyPool {
// Config
// ============================================================
+#[derive(Clone)]
+pub struct SecretBytes(Vec);
+
+impl SecretBytes {
+ pub fn from_string(value: String) -> Self {
+ Self(value.into_bytes())
+ }
+
+ pub fn as_slice(&self) -> &[u8] {
+ &self.0
+ }
+}
+
+impl std::fmt::Debug for SecretBytes {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.write_str("")
+ }
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+pub struct AppAuthClientConfig {
+ pub client_id: String,
+ pub client_secret_sha256: String,
+ pub display_name: String,
+ pub allowed_redirect_uris: Vec,
+ pub fallback_uri: Option,
+ #[serde(default)]
+ pub allowed_fallback_uris: Vec,
+ #[serde(default = "default_app_auth_client_status")]
+ pub status: String,
+}
+
+#[derive(Debug, Clone)]
+pub struct AppAuthClientRecord {
+ pub client: AppAuthClientConfig,
+ pub created_at: chrono::DateTime,
+ pub updated_at: chrono::DateTime,
+}
+
+pub const APP_AUTH_CLIENT_STATUS_ACTIVE: &str = "active";
+pub const APP_AUTH_CLIENT_STATUS_BLOCKED: &str = "blocked";
+
+fn default_app_auth_client_status() -> String {
+ APP_AUTH_CLIENT_STATUS_ACTIVE.to_string()
+}
+
#[derive(Debug, Clone)]
pub struct Config {
pub port: u16,
@@ -213,6 +260,21 @@ pub struct Config {
/// bypassing SEAL + Walrus. **Not for production.** Off by default;
/// set `BENCHMARK_MODE=true` to enable. Surfaced via `GET /health`.
pub benchmark_mode: bool,
+ /// Legacy/static confidential clients for hosted web app auth. Real dapps
+ /// should use `/api/app-auth/clients`, which stores clients in Postgres.
+ pub app_auth_clients: Vec,
+ /// Staging/demo only: allow unauthenticated third-party app client
+ /// registration. Production leaves this off and requires APP_AUTH_ADMIN_TOKEN
+ /// on `POST /api/app-auth/clients`.
+ pub app_auth_public_client_registration_enabled: bool,
+ /// Optional operator token for hosted app-auth admin actions, such as
+ /// blocking a dynamically registered client.
+ pub app_auth_admin_token: Option,
+ /// Dev/test only: allow configured localhost callback paths on any port.
+ pub app_auth_enable_dev_localhost_wildcards: bool,
+ /// AES key material derived from APP_AUTH_DELEGATE_ENCRYPTION_KEY or
+ /// SIDECAR_AUTH_TOKEN. Redacted in Debug.
+ pub app_auth_delegate_secret: Option,
}
impl Config {
@@ -232,6 +294,9 @@ impl Config {
std::env::var("WALRUS_AGGREGATOR_URLS").ok().as_deref(),
);
+ let app_auth_enable_dev_localhost_wildcards =
+ app_auth_dev_localhost_wildcards_enabled(&network);
+
Self {
port: std::env::var("PORT")
.unwrap_or_else(|_| "8000".to_string())
@@ -283,10 +348,110 @@ impl Config {
benchmark_mode: std::env::var("BENCHMARK_MODE")
.map(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes"))
.unwrap_or(false),
+ app_auth_clients: parse_app_auth_clients(
+ &network,
+ app_auth_enable_dev_localhost_wildcards,
+ ),
+ app_auth_public_client_registration_enabled: env_bool(
+ "APP_AUTH_PUBLIC_CLIENT_REGISTRATION_ENABLED",
+ ),
+ app_auth_admin_token: std::env::var("APP_AUTH_ADMIN_TOKEN")
+ .ok()
+ .map(|value| value.trim().to_string())
+ .filter(|value| !value.is_empty())
+ .map(SecretBytes::from_string),
+ app_auth_enable_dev_localhost_wildcards,
+ app_auth_delegate_secret: derive_app_auth_delegate_secret(),
}
}
}
+fn parse_app_auth_clients(
+ network: &str,
+ include_dev_localhost_client: bool,
+) -> Vec {
+ let mut clients = match std::env::var("APP_AUTH_CLIENTS_JSON") {
+ Ok(raw) if !raw.trim().is_empty() => match serde_json::from_str(&raw) {
+ Ok(clients) => clients,
+ Err(err) => {
+ tracing::error!(
+ "APP_AUTH_CLIENTS_JSON is invalid: {}; falling back to built-in non-mainnet app-auth clients",
+ err
+ );
+ default_app_auth_clients_for_network(network)
+ }
+ },
+ _ => default_app_auth_clients_for_network(network),
+ };
+
+ if include_dev_localhost_client
+ && !clients
+ .iter()
+ .any(|client| client.client_id == "dev_localhost")
+ {
+ clients.push(AppAuthClientConfig {
+ client_id: "dev_localhost".to_string(),
+ // sha256("dev_localhost_secret") — local/dev only.
+ client_secret_sha256:
+ "a8af739963bcf3b6b2267229bbaba4106dc13812e7ace14c03b4cdb70acc0667".to_string(),
+ display_name: "Local Dev App".to_string(),
+ allowed_redirect_uris: vec![
+ "http://localhost:*/api/memwal/callback".to_string(),
+ "http://127.0.0.1:*/api/memwal/callback".to_string(),
+ ],
+ fallback_uri: None,
+ allowed_fallback_uris: vec![
+ "http://localhost:*/memwal/error".to_string(),
+ "http://127.0.0.1:*/memwal/error".to_string(),
+ ],
+ status: APP_AUTH_CLIENT_STATUS_ACTIVE.to_string(),
+ });
+ }
+
+ clients
+}
+
+fn default_app_auth_clients_for_network(network: &str) -> Vec {
+ if network.eq_ignore_ascii_case("mainnet") {
+ Vec::new()
+ } else {
+ vec![AppAuthClientConfig {
+ client_id: "demo_dapp".to_string(),
+ // sha256("demo_dapp_secret") — test/dev only. Override in production.
+ client_secret_sha256:
+ "5619a8cdf18ecc129cf7301e0272f0bb4d04144ec1e72e2882de620436a5c577".to_string(),
+ display_name: "Demo Dapp".to_string(),
+ allowed_redirect_uris: vec!["https://example.invalid/api/memwal/callback".to_string()],
+ fallback_uri: Some("https://example.invalid/memwal/error".to_string()),
+ allowed_fallback_uris: vec!["https://example.invalid/memwal/error".to_string()],
+ status: APP_AUTH_CLIENT_STATUS_ACTIVE.to_string(),
+ }]
+ }
+}
+
+fn app_auth_dev_localhost_wildcards_enabled(network: &str) -> bool {
+ app_auth_dev_localhost_wildcards_enabled_from_value(
+ network,
+ env_bool("APP_AUTH_ENABLE_DEV_LOCALHOST_WILDCARDS"),
+ )
+}
+
+fn app_auth_dev_localhost_wildcards_enabled_from_value(network: &str, enabled: bool) -> bool {
+ !network.eq_ignore_ascii_case("mainnet") && enabled
+}
+
+fn derive_app_auth_delegate_secret() -> Option {
+ let source = std::env::var("APP_AUTH_DELEGATE_ENCRYPTION_KEY")
+ .ok()
+ .filter(|value| !value.trim().is_empty())
+ .or_else(|| {
+ std::env::var("SIDECAR_AUTH_TOKEN")
+ .ok()
+ .filter(|value| !value.trim().is_empty())
+ })?;
+ Some(SecretBytes(Sha256::digest(source.as_bytes()).to_vec()))
+}
+
fn env_bool(name: &str) -> bool {
std::env::var(name)
.map(|v| {
@@ -1260,6 +1425,30 @@ mod tests {
assert_eq!(config.per_hour, 30);
}
+ #[test]
+ fn app_auth_dev_localhost_wildcards_are_never_enabled_on_mainnet() {
+ assert!(!app_auth_dev_localhost_wildcards_enabled_from_value(
+ "mainnet", true
+ ));
+ assert!(!app_auth_dev_localhost_wildcards_enabled_from_value(
+ "Mainnet", true
+ ));
+ assert!(app_auth_dev_localhost_wildcards_enabled_from_value(
+ "testnet", true
+ ));
+ assert!(!app_auth_dev_localhost_wildcards_enabled_from_value(
+ "testnet", false
+ ));
+ }
+
+ #[test]
+ fn app_auth_demo_default_clients_are_not_injected_on_mainnet() {
+ assert!(default_app_auth_clients_for_network("mainnet").is_empty());
+ assert!(default_app_auth_clients_for_network("testnet")
+ .iter()
+ .any(|client| client.client_id == "demo_dapp"));
+ }
+
#[test]
fn walrus_storage_epochs_default_by_network() {
assert_eq!(default_walrus_storage_epochs_for_network("mainnet"), 3);