From eb5ef3dba5d3548d4ba3ee357ce9e717a493e79b Mon Sep 17 00:00:00 2001 From: GareArc Date: Mon, 27 Apr 2026 00:32:31 -0700 Subject: [PATCH] feat(web): switch /device page to /openapi/v1 paths (Phase G.21) Approve/deny + lookup + SSO endpoints now live under /openapi/v1/oauth/device/*. Approve/deny use direct fetch with console session cookie + CSRF instead of the /console/api-prefixed post() helper. --- .../device/components/authorize-account.tsx | 2 +- web/app/device/components/authorize-sso.tsx | 7 ++- web/app/device/components/chooser.tsx | 6 +- web/app/device/utils/user-code.ts | 2 +- web/service/device-flow.ts | 62 ++++++++++++++----- 5 files changed, 55 insertions(+), 24 deletions(-) diff --git a/web/app/device/components/authorize-account.tsx b/web/app/device/components/authorize-account.tsx index 0b3cf79866..8bdc6ce03c 100644 --- a/web/app/device/components/authorize-account.tsx +++ b/web/app/device/components/authorize-account.tsx @@ -17,7 +17,7 @@ type Props = { /** * AuthorizeAccount is the account-branch authorize screen. Called with a * live console session already established (user bounced through /signin). - * Posts to /console/api/oauth/device/{approve,deny}; these endpoints mint + * Posts to /openapi/v1/oauth/device/{approve,deny}; these endpoints mint * the dfoa_ token server-side. */ const AuthorizeAccount: FC = ({ diff --git a/web/app/device/components/authorize-sso.tsx b/web/app/device/components/authorize-sso.tsx index 60dc277641..722052498d 100644 --- a/web/app/device/components/authorize-sso.tsx +++ b/web/app/device/components/authorize-sso.tsx @@ -13,9 +13,10 @@ type Props = { /** * AuthorizeSSO is the external-SSO branch authorize screen. On mount it - * fetches /v1/oauth/device/approval-context to learn subject_email, issuer, - * user_code, and csrf_token from the device_approval_grant cookie. On - * Approve click, posts /v1/oauth/device/approve-external with the CSRF header. + * fetches /openapi/v1/oauth/device/approval-context to learn subject_email, + * issuer, user_code, and csrf_token from the device_approval_grant cookie. + * On Approve click, posts /openapi/v1/oauth/device/approve-external with + * the CSRF header. * * The user_code in state is bound to the cookie by server; we do not accept * one from the URL because the SSO branch deliberately detaches from the diff --git a/web/app/device/components/chooser.tsx b/web/app/device/components/chooser.tsx index 751d19b897..026de2921c 100644 --- a/web/app/device/components/chooser.tsx +++ b/web/app/device/components/chooser.tsx @@ -13,7 +13,7 @@ type Props = { * Chooser renders the two-button device-auth login selector. Account button * seeds postLoginRedirect + navigates to /signin so every existing account * login method (password / email-code / social OAuth / account-SSO) flows - * through its usual plumbing. SSO button hits /v1/oauth/device/sso-initiate + * through its usual plumbing. SSO button hits /openapi/v1/oauth/device/sso-initiate * directly — the SSO branch skips /signin entirely. * * v1.0 scope: only account-SSO honours postLoginRedirect (via sso-auth's @@ -31,10 +31,10 @@ const Chooser: FC = ({ userCode, ssoAvailable }) => { } const onSSO = () => { - // Full-page navigation, not router.push — /v1/oauth/device/sso-initiate + // Full-page navigation, not router.push — /openapi/v1/oauth/device/sso-initiate // issues a 302 to the IdP. Next's client router can't follow cross- // origin redirects; a plain window.location assignment handles it. - window.location.href = `/v1/oauth/device/sso-initiate?user_code=${encodeURIComponent(userCode)}` + window.location.href = `/openapi/v1/oauth/device/sso-initiate?user_code=${encodeURIComponent(userCode)}` } return ( diff --git a/web/app/device/utils/user-code.ts b/web/app/device/utils/user-code.ts index 1753da16f3..15f3efc94f 100644 --- a/web/app/device/utils/user-code.ts +++ b/web/app/device/utils/user-code.ts @@ -29,7 +29,7 @@ export function normaliseUserCodeInput(raw: string): string { /** * isValidUserCode tests whether the normalised form is a complete XXXX-XXXX - * token suitable for submission to /console/api/oauth/device/lookup. + * token suitable for submission to /openapi/v1/oauth/device/lookup. */ export function isValidUserCode(normalised: string): boolean { return /^[A-Z0-9]{4}-[A-Z0-9]{4}$/.test(normalised) diff --git a/web/service/device-flow.ts b/web/service/device-flow.ts index 51e0c3cecc..d936a48fb2 100644 --- a/web/service/device-flow.ts +++ b/web/service/device-flow.ts @@ -1,18 +1,22 @@ -// Web-side calls into the Dify device-flow endpoints: +// Web-side calls into the Dify device-flow endpoints. All routes now sit +// under /openapi/v1/oauth/device/* (Phase G of the openapi migration). The +// approve/deny endpoints still require the console session cookie + CSRF +// token; lookup is unauthenticated; the SSO branch uses cookie + per-flow +// CSRF baked into the approval-context response. // -// /v1/oauth/device/lookup (public — GET, no auth, IP-rate-limited) -// /v1/oauth/device/approval-context (cookie-authed — GET) -// /v1/oauth/device/approve-external (cookie-authed + CSRF — POST) -// /console/api/oauth/device/approve (session-authed — POST) -// /console/api/oauth/device/deny (session-authed — POST) +// /openapi/v1/oauth/device/lookup (public — GET) +// /openapi/v1/oauth/device/approve (cookie + CSRF — POST) +// /openapi/v1/oauth/device/deny (cookie + CSRF — POST) +// /openapi/v1/oauth/device/approval-context (cookie — GET) +// /openapi/v1/oauth/device/approve-external (cookie + per-flow CSRF — POST) // -// Approve/deny use the standard service/base helpers so they get console- -// session cookies automatically. Lookup + SSO-branch endpoints sit under -// /v1 so they ride the existing service-API gateway route. +// /openapi/v1/* is its own URL prefix, so we bypass service/base's +// API_PREFIX (which targets /console/api) and call fetch directly. -import { post } from './base' +import Cookies from 'js-cookie' +import { CSRF_COOKIE_NAME, CSRF_HEADER_NAME } from '@/config' -const DEVICE_BASE = '/v1/oauth/device' +const DEVICE_BASE = '/openapi/v1/oauth/device' // Typed error thrown by every wrapper here. The page/component layer // switches on `code` to choose user-facing copy / view; never render @@ -49,6 +53,10 @@ function statusFallbackCode(status: number): string { return 'unknown' } +function consoleCsrfHeader(): Record { + return { [CSRF_HEADER_NAME]: Cookies.get(CSRF_COOKIE_NAME()) || '' } +} + // ----- Account branch -------------------------------------------------------- export type DeviceLookupReply = { @@ -65,13 +73,35 @@ export async function deviceLookup(user_code: string): Promise - post<{ status: 'approved' }>('/oauth/device/approve', { body: { user_code } }) +export async function deviceApproveAccount(user_code: string): Promise<{ status: 'approved' }> { + const res = await fetch(`${DEVICE_BASE}/approve`, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + ...consoleCsrfHeader(), + }, + body: JSON.stringify({ user_code }), + }) + if (!res.ok) await failFromResponse(res) + return res.json() +} -export const deviceDenyAccount = (user_code: string) => - post<{ status: 'denied' }>('/oauth/device/deny', { body: { user_code } }) +export async function deviceDenyAccount(user_code: string): Promise<{ status: 'denied' }> { + const res = await fetch(`${DEVICE_BASE}/deny`, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + ...consoleCsrfHeader(), + }, + body: JSON.stringify({ user_code }), + }) + if (!res.ok) await failFromResponse(res) + return res.json() +} -// ----- SSO branch (cookie-authed via /v1/oauth/device/*) -------------------- +// ----- SSO branch (cookie-authed via /openapi/v1/oauth/device/*) ----------- export type ApprovalContext = { subject_email: string