Skip to content

feat(oauth): move authorize UI into web-studio (#2160 follow-up)#2170

Merged
ZaynJarvis merged 1 commit into
volcengine:mainfrom
t0saki:feat/oauth-studio-consent
May 21, 2026
Merged

feat(oauth): move authorize UI into web-studio (#2160 follow-up)#2170
ZaynJarvis merged 1 commit into
volcengine:mainfrom
t0saki:feat/oauth-studio-consent

Conversation

@t0saki
Copy link
Copy Markdown
Collaborator

@t0saki t0saki commented May 21, 2026

Description

#2160 dropped the legacy /console standalone service but deliberately left the OAuth authorize page's /console link and Quick-authorize panel in place, with the commit message calling out a focused follow-up to re-point them at web-studio. This PR is that follow-up: the OAuth consent UI and the OTP push-flow entry both move into OpenViking Studio (same-origin SPA at /studio), and the legacy server-rendered authorize page becomes a pure cross-device fallback.

The previous "Quick authorize" same-origin panel relied on reading the user's API key from localStorage across tabs — which never actually worked from web-studio (it only writes sessionStorage) and would require expanding the XSS attack surface if we made it work. Routing OAuth through the user's Studio tab sidesteps the cross-tab problem entirely: the consent SPA reads the session-stored key from the same tab it runs in.

Screenshots

PixPin_2026-05-21_17-44-24 PixPin_2026-05-21_17-45-20

Related Issue

Follow-up to #2160 ("feat(docker)!: drop legacy console (keep BFF + Caddy)…").

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • Refactoring (no functional changes)
  • Performance improvement
  • Test update

Changes Made

Backend (`openviking/server/oauth/`)

  • `provider.authorize()` now defaults to redirecting to `/studio/oauth/consent` (same-origin SPA) instead of the server-rendered `/oauth/authorize/page`. Exposes `FALLBACK_AUTHORIZE_PAGE` for callers that need the legacy path.
  • New public endpoint `GET /api/v1/auth/oauth/pending/{pending_id}` returns the minimum info the consent UI needs (`client_name`, `redirect_host`, `scopes`); deliberately does NOT expose `display_code` or the full `redirect_uri` so cross-device brute-force protection and phishing-resistance are preserved.
  • `POST /api/v1/auth/oauth-verify` now accepts either `pending_id` (Studio consent path) or `code` (cross-device fallback).
  • HTML `/oauth/authorize/page` template stripped of every `/console` reference and the Quick-authorize same-origin panel; it now serves only as a cross-device fallback pointing users at `/studio/oauth/verify` on another already-signed-in device.
  • `ctx.from_oauth` gate (router.py) untouched: OAuth bearer still cannot mint new OAuth state or OTPs.

Web Studio (`web-studio/src/`)

  • New shared `` component: "current identity" or "use a different API key" (the latter never persisted).
  • New routes `/studio/oauth/consent` (same-device consent card with ``) and `/studio/oauth/verify` (cross-device 6-character code entry).
  • `ConnectionDialog` gains an "OAuth client OTP" section reusing ``, driving `POST /api/v1/auth/otp`.
  • API key storage unchanged — still only `sessionStorage`. No new `localStorage` writes, no cross-tab channels: the consent UI runs inside Studio's own tab and reads the key directly.
  • `en` + `zh-CN` i18n bundles extended with the new `oauth` namespace and `connection.oauthOtp` subtree.

Docs

  • `docs/{zh,en}/guides/11-oauth.md`: refreshed quickstart, How-it-works, Claude.ai walkthrough, `curl` example, and troubleshooting around the Studio consent / cross-device verify split.
  • `docs/{zh,en}/guides/12-public-access.md`: rewritten to lead with public HTTPS (Caddy auto-LE / bring-your-own-proxy / Cloudflare); the `:1934` Caddy block is now a one-paragraph compatibility note for deployments that already bookmarked it.
  • `docs/design/mcp-oauth2-1.md`: top-level "Studio migration" note explaining the new default path; Phase 1 history retained.
  • `Caddyfile` / `docker-compose.yml` comments reworded from "aggregated proxy" to "legacy fallback" to match the new docs (no behavioral change).

Testing

  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • I have tested this on the following platforms:
    • Linux (via the t0saki/OpenViking Fork GHCR build — multi-arch amd64 + arm64)
    • macOS (local dev)
    • Windows

Automated:

  • `pytest tests/server/oauth/` — 55 passed (51 existing + 4 new):
    • `test_oauth_pending_info_endpoint` — GET pending returns minimum fields, never `display_code` or full `redirect_uri`.
    • `test_oauth_pending_info_404_when_missing` — expired/unknown returns 404.
    • `test_oauth_verify_by_pending_id` — Studio consent path verifies via `pending_id` and marks the pending row verified.
    • `test_oauth_verify_requires_pending_or_code` — verify rejects requests with neither field.
  • The existing full end-to-end test (`test_full_device_flow`) was preserved by pinning the test fixture to `FALLBACK_AUTHORIZE_PAGE` (since the test app doesn't mount `/studio`).
  • `uvx ruff format --check` + `uvx ruff check` on `openviking/server/oauth/` and `tests/server/oauth/` — clean.
  • `web-studio`: `tsc --noEmit`, ESLint, Prettier, `vite build` — all clean (route tree regenerated to include the two new oauth routes).

Manual (end-to-end against `feat/oauth-studio-consent` GHCR image):

  • Same-device OAuth happy path: MCP client → 302 to `/studio/oauth/consent?pending=…` → consent card shows correct `client_name` + `redirect_host` → click Authorize → network shows `POST /api/v1/auth/oauth-verify` with `Authorization: Bearer …` and `{pending_id, decision}`, no `/console/…` requests → browser auto-redirects to client `redirect_uri` with auth code → token exchange succeeds.
  • "Use a different API key" path: IdentityPicker → paste another admin key → Authorize → token is bound to the pasted key's identity (verified via `/api/v1/auth/whoami`).
  • ConnectionDialog "OAuth client OTP": Generate OTP → 6-char code + TTL countdown displayed → MCP client redeems on authorize page successfully.
  • Cross-device fallback: consent page "Use another device →" link opens `/oauth/authorize/page` (server-rendered, shows `display_code` + Studio verify URL) → another already-signed-in device opens `/studio/oauth/verify`, enters code → original device's authorize page polls and auto-redirects back to the MCP client.

Checklist

Additional Notes

Backwards compatibility:

  • `POST /api/v1/auth/oauth-verify` still accepts the original `code` field, so any third-party automation that posts the 6-character `display_code` keeps working.
  • `/oauth/authorize/page` is preserved as the cross-device fallback — only its template was simplified.
  • The `FALLBACK_AUTHORIZE_PAGE` constant makes it trivial to opt back into the legacy redirect target if a downstream deployment can't ship the `/studio` SPA.

Security:

  • API key continues to live only in `sessionStorage` (per-tab, cleared on tab close). No `localStorage` writes were added — the Studio consent UI sidesteps the cross-tab problem by running inside the user's existing Studio tab.
  • Consent still requires an explicit user click; `client_name` and `redirect_host` are shown for phishing identification.
  • Knowing a `pending_id` only yields client metadata via the new GET endpoint; the verify step still requires a legitimate Bearer.
  • The `display_code` is never returned by GET pending — the cross-device path's brute-force protection is preserved.

Out of scope for this PR (potential follow-ups):

  • Per-client scope picker in the consent UI (currently shows scopes, doesn't let users prune them).
  • A dedicated `/studio/login` page; today the consent page opens the existing `ConnectionDialog` modal when the user isn't signed in.

…w-up)

volcengine#2160 dropped the legacy `/console` standalone service but deliberately
left the OAuth authorize page's `/console` link and Quick-authorize panel
in place, calling out a follow-up to re-point them at web-studio. This
PR is that follow-up.

Backend
- `provider.authorize()` now defaults to redirecting to
  `/studio/oauth/consent` (same-origin SPA) instead of the server-rendered
  `/oauth/authorize/page`. New `FALLBACK_AUTHORIZE_PAGE` constant exposed
  for callers that need to opt into the legacy path.
- New public endpoint `GET /api/v1/auth/oauth/pending/{pending_id}` returns
  the minimum info the consent UI needs (client_name, redirect_host,
  scopes); deliberately does NOT expose display_code or full redirect_uri.
- `POST /api/v1/auth/oauth-verify` now accepts either `pending_id`
  (Studio consent path) or `code` (cross-device fallback).
- HTML `/oauth/authorize/page` template stripped of `/console` link, the
  `/console/api/v1/...` JS, and the Quick-authorize same-origin panel.
  It now serves as a pure cross-device fallback that points users at
  `/studio/oauth/verify` on another already-signed-in device.

Web Studio
- New `<IdentityPicker>` shared component: "current identity" or
  "use a different API key" — the temporary key is never persisted.
- New routes `/studio/oauth/consent` (same-device consent card) and
  `/studio/oauth/verify` (cross-device code entry).
- ConnectionDialog gains an "OAuth client OTP" section (same
  IdentityPicker), driving `POST /api/v1/auth/otp`.
- API key storage is unchanged: only sessionStorage. No new localStorage
  writes, no cross-tab channels — the consent UI runs inside Studio's own
  tab, so it reads the session-stored key directly.

Docs
- 11-oauth.md (zh/en): refreshed quickstart, How-it-works, Claude.ai
  walkthrough, curl example, and troubleshooting around the Studio
  consent / cross-device verify split.
- 12-public-access.md (zh/en): rewritten to lead with public HTTPS;
  the `:1934` Caddy block is now a one-paragraph compatibility note for
  deployments that already bookmarked it.
- mcp-oauth2-1.md: top-level "Studio migration" note explains the new
  default path; Phase 1 history retained.
- Caddyfile / docker-compose.yml comments reworded from "aggregated
  proxy" to "legacy fallback" to match the new docs.

Tests
- `tests/server/oauth/test_router.py` fixture pins to
  FALLBACK_AUTHORIZE_PAGE so existing end-to-end assertions keep working.
- 4 new tests cover the pending-info endpoint and pending_id verify path.
- 55 passed locally; ruff format+check, web-studio tsc/eslint/prettier
  all clean.

Security notes
- Consent UI requires explicit user click; client_name + redirect_host
  shown for phishing identification.
- Knowing a pending_id does not bypass Bearer auth.
- display_code is not returned by GET pending — the cross-device
  brute-force protection is preserved.
- `ctx.from_oauth` gate (router.py) untouched: OAuth bearer still
  cannot mint new OAuth state or OTPs.
@github-actions
Copy link
Copy Markdown

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

**🎫 Ticket compliance analysis **

⏱️ Estimated effort to review: 4 🔵🔵🔵🔵⚪
🏅 Score: 85
🧪 PR contains tests
🔒 No security concerns identified
✅ No TODO sections
🔀 No multiple PR themes
⚡ Recommended focus areas for review

Code duplication

Duplicate extractMessage function exists in consent.tsx, verify.tsx, and connection-dialog.tsx. Extract to a shared utility file for maintainability.

function extractMessage(raw: string): string {
  try {
    const parsed = JSON.parse(raw) as { error?: { message?: string } }
    return parsed.error?.message || raw.slice(0, 200)
  } catch {
    return raw.slice(0, 200)
  }
Code duplication

Duplicate extractMessage function exists in consent.tsx, verify.tsx, and connection-dialog.tsx. Extract to a shared utility file for maintainability.

function extractMessage(raw: string): string {
  try {
    const parsed = JSON.parse(raw) as { error?: { message?: string } }
    return parsed.error?.message || raw.slice(0, 200)
  } catch {
    return raw.slice(0, 200)
  }
Code duplication

Duplicate extractMessage function exists in consent.tsx, verify.tsx, and connection-dialog.tsx. Extract to a shared utility file for maintainability.

function extractMessage(raw: string): string {
  try {
    const parsed = JSON.parse(raw) as { error?: { message?: string } }
    return parsed.error?.message || raw.slice(0, 200)
  } catch {
    return raw.slice(0, 200)
  }

@github-actions
Copy link
Copy Markdown

PR Code Suggestions ✨

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
General
Add cancellation for polling to prevent leaks and post-unmount actions

Add cancellation support to pollStatusAndRedirect using an AbortController and a
cleanup flag to prevent state updates after the component unmounts and avoid memory
leaks.

web-studio/src/routes/oauth/consent.tsx [110-134]

-async function pollStatusAndRedirect(): Promise<void> {
-  for (;;) {
-    try {
-      const resp = await fetch(
-        `/oauth/authorize/page/status?pending=${encodeURIComponent(pending)}`,
-        { cache: "no-store" },
-      )
-      if (resp.status === 410) {
-        setPhase({ kind: "expired" })
-        return
+function usePollStatusAndRedirect(pending: string, setPhase: React.Dispatch<React.SetStateAction<Phase>>) {
+  const pollRef = React.useRef<{ cancelled: boolean }>({ cancelled: false });
+  React.useEffect(() => {
+    pollRef.current.cancelled = false;
+    return () => {
+      pollRef.current.cancelled = true;
+    };
+  }, [pending]);
+
+  return React.useCallback(async () => {
+    const { cancelled } = pollRef.current;
+    while (!cancelled) {
+      try {
+        const resp = await fetch(
+          `/oauth/authorize/page/status?pending=${encodeURIComponent(pending)}`,
+          { cache: "no-store" },
+        );
+        if (cancelled) return;
+        if (resp.status === 410) {
+          setPhase({ kind: "expired" });
+          return;
+        }
+        const body = (await resp.json()) as {
+          status: string;
+          redirect_url?: string;
+        };
+        if (body.status === "approved" && body.redirect_url) {
+          window.location.replace(body.redirect_url);
+          return;
+        }
+      } catch {
+        // Transient — retry.
       }
-      const body = (await resp.json()) as {
-        status: string
-        redirect_url?: string
-      }
-      if (body.status === "approved" && body.redirect_url) {
-        window.location.replace(body.redirect_url)
-        return
-      }
-    } catch {
-      // Transient — retry.
+      await new Promise((r) => setTimeout(r, 1000));
+      if (pollRef.current.cancelled) return;
     }
-    await new Promise((r) => setTimeout(r, 1000))
-  }
+  }, [pending, setPhase]);
 }
 
+// Usage in component:
+const pollStatusAndRedirect = usePollStatusAndRedirect(pending, setPhase);
+
Suggestion importance[1-10]: 5

__

Why: Adds proper cleanup logic to the polling loop using a ref flag to avoid memory leaks and state updates after unmount, improving code stability and correctness without changing core functionality.

Low

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR completes the post-#2160 OAuth UI migration by moving the primary authorization/consent UX into OpenViking Studio (/studio SPA) and reducing the legacy server-rendered authorize page to a cross-device fallback. It also adds a public “pending authorization metadata” endpoint to support the Studio consent card, extends docs to reflect the new flow, and updates tests accordingly.

Changes:

  • Default OAuth authorize redirect now targets Studio consent (/studio/oauth/consent), with the legacy HTML page retained as a fallback.
  • Adds Studio UI routes/components for same-device consent and cross-device verification, plus an “OAuth client OTP” block in the Connection dialog.
  • Updates OAuth docs and design notes; adds/updates server tests for the new pending-info + pending-id verify path.

Reviewed changes

Copilot reviewed 17 out of 17 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
web-studio/src/routeTree.gen.ts Registers the new OAuth consent/verify routes in the generated router tree.
web-studio/src/routes/oauth/verify.tsx Adds the Studio cross-device verification page that posts code to /api/v1/auth/oauth-verify.
web-studio/src/routes/oauth/consent.tsx Adds the Studio consent card flow (fetch pending metadata, approve/deny, poll status + redirect).
web-studio/src/i18n/locales/zh-CN.ts Adds zh-CN strings for OAuth consent/verify and the connection dialog OTP section.
web-studio/src/i18n/locales/en.ts Adds English strings for OAuth consent/verify and the connection dialog OTP section.
web-studio/src/components/identity-picker.tsx Introduces a shared identity picker for “current identity” vs “temporary API key”.
web-studio/src/components/connection-dialog.tsx Adds an “OAuth client OTP” section that issues /api/v1/auth/otp using a selected identity.
tests/server/oauth/test_router.py Updates fixture to pin legacy authorize page; adds tests for pending-info and pending-id verification.
openviking/server/oauth/router.py Adds GET /api/v1/auth/oauth/pending/{pending_id} and extends verify to accept pending_id or code; simplifies fallback HTML.
openviking/server/oauth/provider.py Defaults provider authorize-page target to /studio/oauth/consent; exposes fallback constant.
docs/zh/guides/12-public-access.md Rewrites public-access guidance to center port 1933 + HTTPS, relegating 1934 to legacy compatibility.
docs/zh/guides/11-oauth.md Updates OAuth onboarding to Studio consent + cross-device verify split, including curl examples.
docs/en/guides/12-public-access.md English rewrite aligning with the new 1933-first + HTTPS guidance.
docs/en/guides/11-oauth.md English OAuth guide updated for Studio consent + fallback verify.
docs/design/mcp-oauth2-1.md Adds a “Studio migration” note describing the new default consent path.
docker-compose.yml Rewords comments to describe the 1934 Caddy proxy as a legacy fallback.
Caddyfile Rewords comments to reflect the proxy’s legacy/fallback role (no behavioral change).
Comments suppressed due to low confidence (2)

web-studio/src/routes/oauth/consent.tsx:306

  • postVerify('deny') can be triggered without any API key, but POST /api/v1/auth/oauth-verify requires authentication (it depends on get_request_context). As a result, the Deny action will always 401 for signed-out users. Either disable Deny when there is no effective API key (matching Authorize), or adjust the backend to allow unauthenticated deny by pending_id.
              <Button
                variant="outline"
                onClick={() => void postVerify('deny')}
                disabled={
                  phase.kind === 'verifying' || phase.kind === 'denying'
                }
              >
                {phase.kind === 'denying'
                  ? t('consent.denying')
                  : t('consent.deny')}
              </Button>
              <Button
                onClick={() => void postVerify('approve')}
                disabled={
                  phase.kind === 'verifying' ||
                  phase.kind === 'denying' ||
                  !hasAnyKey
                }

web-studio/src/routes/oauth/verify.tsx:155

  • The verify flow expects a 6-character code, but the input allows up to 12 characters. Limiting maxLength to 6 (and optionally stripping non-alphanumeric characters) would prevent avoidable "invalid code" submissions.
                      id="ov-oauth-verify-code"
                      autoFocus
                      autoComplete="off"
                      inputMode="text"
                      maxLength={12}
                      placeholder={t('verify.codePlaceholder')}
                      value={code}

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.


client = await provider.get_client(record["client_id"])
redirect_uri = record.get("redirect_uri") or ""
redirect_host = urlparse(redirect_uri).netloc or None
Comment on lines +454 to +467
# Two callers, two lookup paths: Studio's consent SPA has the pending_id
# straight off the authorize URL; the cross-device fallback page asks
# the user to type the 6-char display_code on a different device. Both
# converge to the same pending row.
if body.pending_id:
record = await store.load_pending_authorization(body.pending_id)
if record is None or record.get("verified"):
raise InvalidArgumentError("Invalid or expired pending authorization")
elif body.code:
record = await store.find_pending_by_display_code(body.code)
if record is None:
raise InvalidArgumentError("Invalid or expired verification code")
else:
raise InvalidArgumentError("Must provide either 'pending_id' or 'code'")
Comment on lines +78 to +80
void fetch(`/api/v1/auth/oauth/pending/${encodeURIComponent(pending)}`, {
cache: 'no-store',
})
Comment on lines +110 to +134
async function pollStatusAndRedirect(): Promise<void> {
for (;;) {
try {
const resp = await fetch(
`/oauth/authorize/page/status?pending=${encodeURIComponent(pending)}`,
{ cache: 'no-store' },
)
if (resp.status === 410) {
setPhase({ kind: 'expired' })
return
}
const body = (await resp.json()) as {
status: string
redirect_url?: string
}
if (body.status === 'approved' && body.redirect_url) {
window.location.replace(body.redirect_url)
return
}
} catch {
// Transient — retry.
}
await new Promise((r) => setTimeout(r, 1000))
}
}
Comment on lines +70 to +78
const resp = await fetch('/api/v1/auth/oauth-verify', {
method: 'POST',
cache: 'no-store',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${effectiveKey}`,
},
body: JSON.stringify({ code: normalized, decision: 'approve' }),
})
Comment on lines +284 to +292
const resp = await fetch('/api/v1/auth/otp', {
method: 'POST',
cache: 'no-store',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${effectiveKey}`,
},
body: '{}',
})
Comment on lines +14 to +18
> - 服务端 HTML 页面 `/oauth/authorize/page` 退化为**跨设备 fallback**:
> 显示 6 字符 `display_code` + 引导文案,让用户在另一台已登录 Studio 的设备
> 打开 `/studio/oauth/verify` 输入码完成授权。同设备的 quick-authorize
> 面板(依赖 `sessionStorage.ov_console_api_key` 跨 tab 探测)已移除,
> 不再需要把 API Key 写到 `localStorage`。
Comment on lines +623 to +625
description: '请输入发起 MCP 客户端登录的那台设备上显示的 6 位验证码。',
codeLabel: '验证码',
codePlaceholder: '6 位验证码',
Copy link
Copy Markdown
Collaborator

@ZaynJarvis ZaynJarvis left a comment

Choose a reason for hiding this comment

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

lgtm

@ZaynJarvis ZaynJarvis merged commit da59289 into volcengine:main May 21, 2026
11 checks passed
@github-project-automation github-project-automation Bot moved this from Backlog to Done in OpenViking project May 21, 2026
qin-ctx pushed a commit that referenced this pull request May 21, 2026
…/studio) (#2175)

Follow-up to #2160 (console removal) and #2170 (OAuth UI moved to web-studio).
Six guides still showed the old `python -m openviking.console.bootstrap`
launch + `-p 8020:8020` docker run snippets, which now fail because the
console package is gone and the image no longer exposes 8020.

- docs/{en,zh}/getting-started/04-setup-for-agent.md: drop `-p 8020:8020`
  from docker run and the "ports: 1933:1933, 8020:8020" summary; note that
  Web Studio is served by the OV server at `/studio` so no extra port is
  needed.
- docs/{en,zh}/guides/03-deployment.md: drop 4× `-p 8020:8020` snippets,
  replace "OpenViking Console on port 8020" with the inline `/studio`
  mention, and update the "access this after startup" list to point at
  `http://localhost:1933/studio` (with 1934 documented as a legacy Caddy
  fallback consistent with the updated 12-public-access guide).
- docs/{en,zh}/guides/05-observability.md: rewrite the "Web Studio for
  web-based investigation" section. The standalone-bootstrap launch is
  gone; instead direct readers to `/studio` and its observability-relevant
  pages — Home (token/retrieval/context-commit trends, /api/v1/console/*
  BFF), Request Logs (audit), Resources, Retrieval, Sessions. Update the
  "choose an entry point" table accordingly.

docs/design/mcp-oauth2-1.md is intentionally untouched — it's a historical
design document. The README inside openviking/observability/usage_audit/
and 12-public-access.md were already updated in earlier PRs.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

3 participants