import type { KyInstance } from 'ky' import { BaseError } from '../errors/base.js' import { ErrorCode } from '../errors/codes.js' export const DEFAULT_CLIENT_ID = 'difyctl' export type CodeRequest = { client_id?: string device_label: string } export type CodeResponse = { device_code: string user_code: string verification_uri: string expires_in: number interval: number } export type PollRequest = { client_id?: string device_code: string } export type PollAccount = { id: string email: string name: string } export type PollWorkspace = { id: string name: string role: string } export type PollSuccess = { token: string expires_at?: string subject_type?: string subject_email?: string subject_issuer?: string account?: PollAccount | null workspaces?: readonly PollWorkspace[] default_workspace_id?: string token_id?: string } export type PollResult = | { status: 'pending' } | { status: 'slow_down' } | { status: 'expired' } | { status: 'denied' } | { status: 'retry_5xx' } | { status: 'approved', success: PollSuccess } const POLL_ERROR_TO_STATUS: Record = { authorization_pending: 'pending', slow_down: 'slow_down', expired_token: 'expired', access_denied: 'denied', } export class DeviceFlowApi { private readonly http: KyInstance constructor(http: KyInstance) { this.http = http } async requestCode(req: CodeRequest): Promise { if (req.device_label === '') { throw new BaseError({ code: ErrorCode.UsageMissingArg, message: 'device_label is required', }) } const body = { client_id: req.client_id ?? DEFAULT_CLIENT_ID, device_label: req.device_label } const res = await this.http.post('oauth/device/code', { json: body, throwHttpErrors: false, context: { skipClassify: true } }) if (res.status === 404) throw versionSkew() if (!res.ok) { throw new BaseError({ code: ErrorCode.Server4xxOther, message: `device/code: HTTP ${res.status}`, httpStatus: res.status, }) } return await res.json() as CodeResponse } async pollOnce(req: PollRequest): Promise { if (req.device_code === '') { throw new BaseError({ code: ErrorCode.UsageMissingArg, message: 'device_code is required', }) } const body = { client_id: req.client_id ?? DEFAULT_CLIENT_ID, device_code: req.device_code } const res = await this.http.post('oauth/device/token', { json: body, throwHttpErrors: false, context: { skipClassify: true } }) if (res.status === 404) throw versionSkew() if (res.status >= 500) return { status: 'retry_5xx' } let payload: { error?: string } & Partial = {} try { const text = await res.text() payload = text === '' ? {} : JSON.parse(text) as typeof payload } catch (err) { throw new BaseError({ code: ErrorCode.Unknown, message: `decode poll response: ${(err as Error).message}`, }) } if (typeof payload.error === 'string' && payload.error !== '') { const status = POLL_ERROR_TO_STATUS[payload.error] if (status === undefined) { throw new BaseError({ code: ErrorCode.Unknown, message: `unknown poll error "${payload.error}"`, }) } return { status } as PollResult } if (typeof payload.token !== 'string' || payload.token === '') { throw new BaseError({ code: ErrorCode.Unknown, message: `poll: ${res.status} with no OAuth envelope`, }) } return { status: 'approved', success: payload as PollSuccess } } } function versionSkew(): BaseError { return new BaseError({ code: ErrorCode.UnsupportedEndpoint, message: 'this Dify host does not implement the OAuth device flow', httpStatus: 404, }) }