Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions cases/oauth/y309-device-code-login-completes.case.ts
Original file line number Diff line number Diff line change
@@ -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",
},
});
60 changes: 60 additions & 0 deletions cases/oauth/y309-device-code-login-completes.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions framework/runner-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -106,6 +107,7 @@ type RunnerFn<K extends BugRunnerKind> = (
// and the implementation here — miss one and `pnpm check:types` fails.
const runners: { [K in BugRunnerKind]: RunnerFn<K> } = {
"http-check": runHttpCheckCase,
"oauth-device-grant": runOAuthDeviceGrantCase,
"record-flow": runRecordFlowCase,
"group-collapse": runGroupCollapseCase,
"share-save": runShareSaveCase,
Expand Down
237 changes: 237 additions & 0 deletions framework/runners/oauth-device-grant.runner.ts
Original file line number Diff line number Diff line change
@@ -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<BugProbeResult> => {
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<UserProfile>(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<DeviceCodeResponse>(
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<DeviceAppResponse>(
`${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<TokenResponse>(
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<UserProfile>(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)
}`,
);
}
}
}
};
9 changes: 9 additions & 0 deletions framework/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<
Expand Down
2 changes: 2 additions & 0 deletions registry.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Expand Down