feat(oauth): move authorize UI into web-studio (#2160 follow-up)#2170
Conversation
…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.
PR Reviewer Guide 🔍Here are some key observations to aid the review process:
|
PR Code Suggestions ✨Explore these optional code suggestions:
|
There was a problem hiding this comment.
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, butPOST /api/v1/auth/oauth-verifyrequires authentication (it depends onget_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
maxLengthto 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 |
| # 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'") |
| void fetch(`/api/v1/auth/oauth/pending/${encodeURIComponent(pending)}`, { | ||
| cache: 'no-store', | ||
| }) |
| 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)) | ||
| } | ||
| } |
| 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' }), | ||
| }) |
| const resp = await fetch('/api/v1/auth/otp', { | ||
| method: 'POST', | ||
| cache: 'no-store', | ||
| headers: { | ||
| 'Content-Type': 'application/json', | ||
| Authorization: `Bearer ${effectiveKey}`, | ||
| }, | ||
| body: '{}', | ||
| }) |
| > - 服务端 HTML 页面 `/oauth/authorize/page` 退化为**跨设备 fallback**: | ||
| > 显示 6 字符 `display_code` + 引导文案,让用户在另一台已登录 Studio 的设备 | ||
| > 打开 `/studio/oauth/verify` 输入码完成授权。同设备的 quick-authorize | ||
| > 面板(依赖 `sessionStorage.ov_console_api_key` 跨 tab 探测)已移除, | ||
| > 不再需要把 API Key 写到 `localStorage`。 |
| description: '请输入发起 MCP 客户端登录的那台设备上显示的 6 位验证码。', | ||
| codeLabel: '验证码', | ||
| codePlaceholder: '6 位验证码', |
…/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.
Description
#2160 dropped the legacy
/consolestandalone service but deliberately left the OAuth authorize page's/consolelink 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
localStorageacross tabs — which never actually worked fromweb-studio(it only writessessionStorage) 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
Related Issue
Follow-up to #2160 ("feat(docker)!: drop legacy console (keep BFF + Caddy)…").
Type of Change
Changes Made
Backend (`openviking/server/oauth/`)
Web Studio (`web-studio/src/`)
Docs
Testing
Automated:
Manual (end-to-end against `feat/oauth-studio-consent` GHCR image):
Checklist
Additional Notes
Backwards compatibility:
Security:
Out of scope for this PR (potential follow-ups):