From 3b99f394dcef6854b0bd0c536101654a7b84342f Mon Sep 17 00:00:00 2001 From: Parker Date: Tue, 25 Aug 2026 14:20:29 +0800 Subject: [PATCH] Guard Y309 device-code login without a callback --- .../y309-device-code-login-completes.case.ts | 21 ++ .../oauth/y309-device-code-login-completes.md | 60 +++++ framework/runner-registry.ts | 2 + .../runners/oauth-device-grant.runner.ts | 237 ++++++++++++++++++ framework/types.ts | 9 + registry.ts | 2 + 6 files changed, 331 insertions(+) create mode 100644 cases/oauth/y309-device-code-login-completes.case.ts create mode 100644 cases/oauth/y309-device-code-login-completes.md create mode 100644 framework/runners/oauth-device-grant.runner.ts diff --git a/cases/oauth/y309-device-code-login-completes.case.ts b/cases/oauth/y309-device-code-login-completes.case.ts new file mode 100644 index 0000000..4c57a06 --- /dev/null +++ b/cases/oauth/y309-device-code-login-completes.case.ts @@ -0,0 +1,21 @@ +import { defineBugCase } from "../../framework/types"; + +// Y309 / T6745: a CLI running where no loopback callback can arrive uses the +// device grant instead. The browser's approval is an authenticated API call in +// this case, so the whole login can be exercised without opening a browser. +export default defineBugCase({ + id: "oauth/y309-device-code-login-completes", + title: "Device-code login completes without a local callback", + runner: "oauth-device-grant", + timeoutMs: 120_000, + bug: { + issue: "T6745", + status: "fixed", + sourceCommits: ["f0624c29b"], + }, + config: { + clientId: "clttckxmg4deadomjhs", + expectedAppName: "Teable CLI", + verificationPath: "/oauth/device", + }, +}); diff --git a/cases/oauth/y309-device-code-login-completes.md b/cases/oauth/y309-device-code-login-completes.md new file mode 100644 index 0000000..45bbe94 --- /dev/null +++ b/cases/oauth/y309-device-code-login-completes.md @@ -0,0 +1,60 @@ +# oauth/y309-device-code-login-completes + +**Y309 / T6745** - fixed. + +## The requirement + +A CLI running in a remote shell, container or other environment where a +browser cannot return to a local callback port must still be able to sign in. +The device authorization grant provides that path: the CLI receives a short +code, a signed-in person approves it on another device, and the CLI polls for +tokens using outbound HTTP only. + +## Why this is an API case + +The browser approval page is a client of three public endpoints. This case +drives those endpoints directly, so it covers the same server-side login flow +without requiring a browser or a reachable callback port: + +1. An anonymous client asks the built-in CLI application for a device code. +2. The signed-in seed user reads the application and scopes using a lower-case, + separator-free form of the displayed code. +3. That user approves the request. +4. The anonymous client exchanges the device code for a token pair. +5. A fresh client uses only the bearer token to read the approving user's + profile. + +No test-case actual-result text is used. Every prerequisite and assertion is +implemented in code. + +## Preconditions verified before the checkpoint + +- The harness session reads the expected seed user's profile. This is the + person who will approve the login. +- A fresh HTTP client with no cookie or bearer token receives `401` from that + same profile endpoint. This proves the final authenticated read cannot pass + through an inherited session. + +## What the checkpoint asserts + +- Device-code issuance answers `200` with an eight-letter grouped user code, + positive expiry and polling interval, and the expected verification path. +- The approval lookup accepts the human-friendly code without case or separator + fidelity and identifies the Teable CLI application with a non-empty scope + list. +- Approval and token exchange succeed. +- The token response contains a bearer access token, refresh token, positive + lifetimes and granted scopes. +- A client carrying only that bearer token reads the exact user who approved + the request. + +The runner never includes the user code, device code or issued tokens in an +artifact or assertion message. After the checkpoint it revokes the token +through the public API. + +## Scope + +This is one atomic happy-path case. It does not also test denial, expiration, +poll backoff, transient-network retries, terminal log scanning, or the existing +loopback login. Those are separate behaviors and combining them here would make +a failure ambiguous. diff --git a/framework/runner-registry.ts b/framework/runner-registry.ts index c8e25e2..745eee6 100644 --- a/framework/runner-registry.ts +++ b/framework/runner-registry.ts @@ -11,6 +11,7 @@ import { runLookupFilterViewCase } from "./runners/lookup-filter-view.runner"; import { runLookupUserSnapshotSortCase } from "./runners/lookup-user-snapshot-sort.runner"; import { runUserGroupIdentityCase } from "./runners/user-group-identity.runner"; import { runHttpCheckCase } from "./runners/http-check.runner"; +import { runOAuthDeviceGrantCase } from "./runners/oauth-device-grant.runner"; import { runNullMultiplicityLookupCase } from "./runners/null-multiplicity-lookup.runner"; import { runPasteByIdAlignmentCase } from "./runners/paste-by-id-alignment.runner"; import { runPasteNonCollaboratorUserCase } from "./runners/paste-non-collaborator-user.runner"; @@ -106,6 +107,7 @@ type RunnerFn = ( // and the implementation here — miss one and `pnpm check:types` fails. const runners: { [K in BugRunnerKind]: RunnerFn } = { "http-check": runHttpCheckCase, + "oauth-device-grant": runOAuthDeviceGrantCase, "record-flow": runRecordFlowCase, "group-collapse": runGroupCollapseCase, "share-save": runShareSaveCase, diff --git a/framework/runners/oauth-device-grant.runner.ts b/framework/runners/oauth-device-grant.runner.ts new file mode 100644 index 0000000..ace8d7a --- /dev/null +++ b/framework/runners/oauth-device-grant.runner.ts @@ -0,0 +1,237 @@ +import { axios, createAxios } from "@teable/openapi"; +import { bugCheckpoint } from "../checkpoint"; +import type { BugCaseFor, BugProbeResult, BugRunContext } from "../types"; +import type { OAuthDeviceGrantCaseConfig } from "../types"; + +const AUTH_USER_PATH = "/auth/user"; +const DEVICE_CODE_PATH = "/oauth/device/code"; +const DEVICE_APP_PATH = "/oauth/device"; +const DEVICE_DECISION_PATH = "/oauth/device/decision"; +const TOKEN_PATH = "/oauth/access_token"; +const DEVICE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"; +const FORM_HEADERS = { + "Content-Type": "application/x-www-form-urlencoded", +}; + +type UserProfile = { + id?: string; + email?: string; +}; + +type DeviceCodeResponse = { + device_code?: unknown; + user_code?: unknown; + verification_uri?: unknown; + expires_in?: unknown; + interval?: unknown; +}; + +type DeviceAppResponse = { + name?: unknown; + scopes?: unknown; +}; + +type TokenResponse = { + access_token?: unknown; + refresh_token?: unknown; + token_type?: unknown; + expires_in?: unknown; + refresh_expires_in?: unknown; + scopes?: unknown; +}; + +const isPositiveNumber = (value: unknown): value is number => + typeof value === "number" && Number.isFinite(value) && value > 0; + +const isNonEmptyString = (value: unknown): value is string => + typeof value === "string" && value.length > 0; + +export const runOAuthDeviceGrantCase = async ( + bugCase: BugCaseFor<"oauth-device-grant">, + context: BugRunContext, +): Promise => { + const config: OAuthDeviceGrantCaseConfig = bugCase.config; + const apiBaseUrl = `${context.appUrl}/api`; + const anonymous = createAxios(); + anonymous.defaults.baseURL = apiBaseUrl; + + // Preconditions, outside the checkpoint: there is a signed-in approver, and + // a separate client without that session genuinely starts anonymous. + const approver = (await axios.get(AUTH_USER_PATH)).data; + if ( + approver.id !== globalThis.testConfig.userId || + approver.email !== globalThis.testConfig.email + ) { + throw new Error( + `the approval session is not the seed user (id=${String(approver.id)}, email=${String(approver.email)})`, + ); + } + const anonymousProfile = await anonymous.get(AUTH_USER_PATH, { + validateStatus: () => true, + }); + if (anonymousProfile.status !== 401) { + throw new Error( + `the anonymous control answered ${anonymousProfile.status}, expected 401; the final bearer-token check could inherit a session`, + ); + } + + let tokenIssued = false; + try { + const probe = await bugCheckpoint( + "device-code-login-issues-a-working-bearer-token", + async () => { + const deviceResponse = await anonymous.post( + DEVICE_CODE_PATH, + new URLSearchParams({ client_id: config.clientId }).toString(), + { + headers: FORM_HEADERS, + validateStatus: () => true, + }, + ); + if (deviceResponse.status !== 200) { + throw new Error( + `device-code issuance answered ${deviceResponse.status}, expected 200`, + ); + } + + const device = deviceResponse.data; + if (!isNonEmptyString(device.device_code)) { + throw new Error("device-code issuance returned no device code"); + } + if ( + typeof device.user_code !== "string" || + !/^[A-Z]{4}-[A-Z]{4}$/.test(device.user_code) + ) { + throw new Error( + "device-code issuance did not return an eight-letter grouped user code", + ); + } + if (!isPositiveNumber(device.expires_in)) { + throw new Error("device-code issuance returned no positive expiry"); + } + if (!isPositiveNumber(device.interval)) { + throw new Error( + "device-code issuance returned no positive polling interval", + ); + } + if (!isNonEmptyString(device.verification_uri)) { + throw new Error("device-code issuance returned no verification URL"); + } + const verificationUrl = new URL(device.verification_uri); + if (verificationUrl.pathname !== config.verificationPath) { + throw new Error( + `the verification URL uses path ${verificationUrl.pathname}, expected ${config.verificationPath}`, + ); + } + + const typedUserCode = device.user_code + .toLowerCase() + .replace(/[^a-z]/g, ""); + const appResponse = await axios.get( + `${DEVICE_APP_PATH}/${typedUserCode}`, + ); + const app = appResponse.data; + if (app.name !== config.expectedAppName) { + throw new Error( + `the approval lookup named ${String(app.name)}, expected ${config.expectedAppName}`, + ); + } + if (!Array.isArray(app.scopes) || app.scopes.length === 0) { + throw new Error("the approval lookup returned no requested scopes"); + } + + const decision = await axios.post( + DEVICE_DECISION_PATH, + { userCode: device.user_code, approve: true }, + { validateStatus: () => true }, + ); + if (decision.status < 200 || decision.status >= 300) { + throw new Error( + `device-code approval answered ${decision.status}, expected a successful status`, + ); + } + + const tokenResponse = await anonymous.post( + TOKEN_PATH, + new URLSearchParams({ + grant_type: DEVICE_GRANT_TYPE, + device_code: device.device_code, + client_id: config.clientId, + }).toString(), + { + headers: FORM_HEADERS, + validateStatus: () => true, + }, + ); + if (tokenResponse.status !== 200) { + throw new Error( + `the approved device-code exchange answered ${tokenResponse.status}, expected 200`, + ); + } + + const tokens = tokenResponse.data; + if ( + tokens.token_type !== "Bearer" || + !isNonEmptyString(tokens.access_token) || + !isNonEmptyString(tokens.refresh_token) || + !isPositiveNumber(tokens.expires_in) || + !isPositiveNumber(tokens.refresh_expires_in) || + !Array.isArray(tokens.scopes) || + tokens.scopes.length === 0 + ) { + throw new Error( + "the approved exchange did not return a complete bearer token pair with lifetimes and scopes", + ); + } + tokenIssued = true; + + const bearerProfile = await anonymous.get(AUTH_USER_PATH, { + headers: { Authorization: `Bearer ${tokens.access_token}` }, + validateStatus: () => true, + }); + if (bearerProfile.status !== 200) { + throw new Error( + `the issued bearer token answered ${bearerProfile.status} on the authenticated profile endpoint, expected 200`, + ); + } + if ( + bearerProfile.data.id !== approver.id || + bearerProfile.data.email !== approver.email + ) { + throw new Error( + `the bearer token resolved to id=${String(bearerProfile.data.id)}, email=${String(bearerProfile.data.email)} instead of the approving user`, + ); + } + + return { + appName: app.name, + scopeCount: app.scopes.length, + userCodeFormat: "AAAA-AAAA", + expirySeconds: device.expires_in, + pollingIntervalSeconds: device.interval, + authenticatedUserId: bearerProfile.data.id, + }; + }, + ); + + return { + details: { + clientId: config.clientId, + verificationPath: config.verificationPath, + ...probe, + }, + }; + } finally { + if (tokenIssued) { + try { + await axios.post(`/oauth/client/${config.clientId}/revoke-token`); + } catch (error) { + console.warn( + `[e2e-lab] cleanup failed for ${bugCase.id}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + } +}; diff --git a/framework/types.ts b/framework/types.ts index d5620b4..8f836d4 100644 --- a/framework/types.ts +++ b/framework/types.ts @@ -8,6 +8,7 @@ import type { DayBucket } from "./runners/group-buckets"; // config shape fails `pnpm check:types` at the case file itself. export interface BugCaseConfigByRunner { "http-check": HttpCheckCaseConfig; + "oauth-device-grant": OAuthDeviceGrantCaseConfig; "record-flow": RecordFlowCaseConfig; "group-collapse": GroupCollapseCaseConfig; "share-save": ShareSaveCaseConfig; @@ -225,6 +226,14 @@ export interface HttpCheckCaseConfig { }; } +// Prove the approver session and anonymous control -> request a device code -> +// approve it -> checkpoint: exchange it and use the bearer token as that user. +export interface OAuthDeviceGrantCaseConfig { + clientId: string; + expectedAppName: string; + verificationPath: string; +} + export interface RecordFlowFieldSpec { name: string; type: Extract< diff --git a/registry.ts b/registry.ts index e21a38b..7f93c92 100644 --- a/registry.ts +++ b/registry.ts @@ -1,4 +1,5 @@ import smokeAuthUserCase from "./cases/smoke/auth-user.case"; +import oauthY309DeviceCodeLoginCompletesCase from "./cases/oauth/y309-device-code-login-completes.case"; import recordBulkUpdate100MixedLandsCase from "./cases/record/bulk-update-100-mixed-lands.case"; import lookupOfRollupCreateCase from "./cases/record/a-row-when-a-looked-up-total-lost-its-rule.case"; import aiConfigOnlyChangePlanCase from "./cases/field/change-only-the-instruction-behind-a-column.case"; @@ -111,6 +112,7 @@ import type { BugCase } from "./framework/types"; // checks can enumerate cases without resolving @teable/* packages. const cases = [ smokeAuthUserCase, + oauthY309DeviceCodeLoginCompletesCase, recordBulkUpdate100MixedLandsCase, lookupOfRollupCreateCase, aiConfigOnlyChangePlanCase,