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.
This commit is contained in:
GareArc 2026-04-27 00:32:31 -07:00
parent a07b32274a
commit eb5ef3dba5
No known key found for this signature in database
5 changed files with 55 additions and 24 deletions

View File

@ -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<Props> = ({

View File

@ -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

View File

@ -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<Props> = ({ 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 (

View File

@ -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)

View File

@ -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<string, string> {
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<DeviceLookupReply
return res.json()
}
export const deviceApproveAccount = (user_code: string) =>
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