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
121 changes: 121 additions & 0 deletions apps/drive-cli/src/account.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/**
* The CLI signs in to the Otter Account itself (device authorization), like Code's server CLI:
* each machine keeps its own renewable grant. Drive requests use a short-lived Otter Cloud
* capability for `otterware:drive`, so revoking this grant at Accounts ends access at once.
*/
import { DRIVE_CLOUD_AUDIENCE } from "@otterware/drive-contracts/constants";
import type { AccountGrant } from "./config";

const ACCOUNTS_URL = (process.env.OTTER_ACCOUNTS_URL ?? "https://accounts.otterware.app/v1/auth")
.trim()
.replace(/\/$/, "");
/** Accounts' existing CLI client; its tokens are scoped to the Code relay resource. */
const CLIENT_ID = "otter-code-cli";
const RESOURCE = "https://relay.code.otterware.app";

interface TokenResponse {
access_token: string;
refresh_token?: string;
expires_in: number;
}

const form = async (path: string, params: Record<string, string>) => {
const response = await fetch(`${ACCOUNTS_URL}${path}`, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(params),
signal: AbortSignal.timeout(15_000),
});
const body = (await response.json().catch(() => ({}))) as Record<string, unknown>;
if (!response.ok)
throw new Error(String(body.error ?? `Otter Accounts answered ${response.status}.`));
return body;
};

const grantOf = (token: TokenResponse, previous?: AccountGrant): AccountGrant => {
const refreshToken = token.refresh_token ?? previous?.refreshToken;
if (!refreshToken) throw new Error("Otter Accounts did not grant offline access.");
return {
accessToken: token.access_token,
refreshToken,
expiresAt: Date.now() + token.expires_in * 1_000,
};
};

export async function startDeviceLogin() {
const code = await form("/device/code", {
client_id: CLIENT_ID,
resource: RESOURCE,
scope: "openid profile email offline_access",
});
return {
deviceCode: String(code.device_code),
userCode: String(code.user_code),
verificationUrl: String(code.verification_uri_complete ?? code.verification_uri),
expiresIn: Number(code.expires_in),
interval: Number(code.interval ?? 5),
};
}

/** One poll of the device grant; null while the user has not approved yet. */
export async function pollDeviceLogin(
deviceCode: string,
): Promise<AccountGrant | "slow_down" | null> {
try {
return grantOf(
(await form("/oauth2/token", {
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: deviceCode,
client_id: CLIENT_ID,
resource: RESOURCE,
})) as unknown as TokenResponse,
);
} catch (error) {
const reason = (error as Error).message;
if (reason === "authorization_pending") return null;
if (reason === "slow_down") return "slow_down";
throw error;
}
}

/** A current grant, renewed (and rotated) a minute before its access token expires. */
export async function freshGrant(grant: AccountGrant): Promise<AccountGrant> {
if (grant.expiresAt - 60_000 > Date.now()) return grant;
return grantOf(
(await form("/oauth2/token", {
grant_type: "refresh_token",
refresh_token: grant.refreshToken,
client_id: CLIENT_ID,
resource: RESOURCE,
})) as unknown as TokenResponse,
grant,
);
}

/** Exchanges the grant for Drive's capability: Accounts' suite session, then Otter Cloud. */
export async function driveCapability(grant: AccountGrant, cloudOrigin: string): Promise<string> {
const session = await fetch(`${ACCOUNTS_URL}/suite/session`, {
method: "POST",
headers: { authorization: `Bearer ${grant.accessToken}` },
signal: AbortSignal.timeout(15_000),
});
if (!session.ok) throw new Error("Your Otter Account sign-in ended. Run otterdrive auth login.");
const { token } = (await session.json()) as { token: string };
const capability = await fetch(`${cloudOrigin}/v1/cloud/token`, {
method: "POST",
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
body: JSON.stringify({ audience: DRIVE_CLOUD_AUDIENCE }),
signal: AbortSignal.timeout(15_000),
});
if (!capability.ok) throw new Error(`Otter Cloud answered ${capability.status}.`);
return ((await capability.json()) as { accessToken: string }).accessToken;
}

/** Revokes this machine's grant at Accounts (RFC 7009); other devices stay signed in. */
export async function revokeGrant(grant: AccountGrant): Promise<void> {
await form("/oauth2/revoke", {
token: grant.refreshToken,
token_type_hint: "refresh_token",
client_id: CLIENT_ID,
}).catch(() => {});
}
4 changes: 2 additions & 2 deletions apps/drive-cli/src/artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import type { Command } from "commander";
import open from "open";
import pc from "picocolors";
import { ApiClient } from "./client";
import { getProfile } from "./config";
import { getProfile, isUserLogin } from "./config";
import { resolveFolderReference, type Folder } from "./folders";
import { discoverFiles, resolveEntryPath, type LocalArtifactFile } from "./files";
import type { GlobalOptions } from "./output";
Expand Down Expand Up @@ -322,7 +322,7 @@ export function registerArtifactCommands(program: Command): void {
.description("Move an artifact to another folder")
.action(async (id: string, folderRef: string, command: Command) => {
const { profile } = await getProfile(globals(command).profile);
if (!profile.accessToken || profile.apiKey) {
if (!isUserLogin(profile)) {
throw new Error("Moving artifacts requires a user login, not an API key.");
}
const client = new ApiClient(profile);
Expand Down
72 changes: 28 additions & 44 deletions apps/drive-cli/src/auth.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
import * as NodeTimersPromises from "node:timers/promises";
const { setTimeout: delay } = NodeTimersPromises;

import {
deviceCodeResponseSchema,
deviceTokenResponseSchema,
} from "@otterware/drive-contracts/api";
import { DRIVE_CLOUD_ORIGIN } from "@otterware/drive-contracts/constants";
import type { Command } from "commander";
import open from "open";
import pc from "picocolors";
import { pollDeviceLogin, revokeGrant, startDeviceLogin } from "./account";
import { ApiClient } from "./client";
import { clearCredentials, configPath, getProfile, updateProfile } from "./config";
import type { GlobalOptions } from "./output";
Expand Down Expand Up @@ -53,47 +51,31 @@ export function registerAuthCommands(program: Command): void {
return;
}

const client = new ApiClient(profile);
const code = deviceCodeResponseSchema.parse(
await client.post("/api/auth/device/code", {
client_id: "otterdrive-cli",
scope: "openid profile email offline_access",
}),
);
const verificationUrl = new URL(
code.verification_uri_complete ?? code.verification_uri,
profile.apiUrl,
).toString();
// This machine's own Otter Account grant; Drive requests go to Otter Cloud with it.
const code = await startDeviceLogin();
process.stdout.write(`\nEnter ${pc.bold(pc.cyan(code.userCode))} at:\n`);
process.stdout.write(`${code.verificationUrl}\n\n`);
if (options.noOpen !== true) await open(code.verificationUrl);

process.stdout.write(`\nEnter ${pc.bold(pc.cyan(code.user_code))} at:\n`);
process.stdout.write(`${verificationUrl}\n\n`);
if (options.noOpen !== true) await open(verificationUrl);

const expiresAt = Date.now() + code.expires_in * 1_000;
let interval = (code.interval ?? 5) * 1_000;
const expiresAt = Date.now() + code.expiresIn * 1_000;
let interval = code.interval * 1_000;
while (Date.now() < expiresAt) {
await delay(interval);
try {
const token = deviceTokenResponseSchema.parse(
await client.post("/api/auth/device/token", {
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: code.device_code,
client_id: "otterdrive-cli",
}),
);
await updateProfile(name, {
apiUrl: profile.apiUrl,
accessToken: token.access_token,
apiKey: undefined,
});
if (globals.json) printJson(token);
else success(`Authenticated profile ${pc.bold(name)}.`);
return;
} catch (error) {
const message = (error as Error).message;
if (message.includes("slow_down")) interval += 5_000;
else if (!message.includes("authorization_pending")) throw error;
const grant = await pollDeviceLogin(code.deviceCode);
if (grant === "slow_down") {
interval += 5_000;
continue;
}
if (!grant) continue;
await updateProfile(name, {
apiUrl: options.url ?? DRIVE_CLOUD_ORIGIN,
accountGrant: grant,
accessToken: undefined,
apiKey: undefined,
});
if (globals.json) printJson({ success: true, profile: name });
else success(`Authenticated profile ${pc.bold(name)}.`);
return;
}
throw new Error("The login code expired before it was approved.");
});
Expand All @@ -104,7 +86,7 @@ export function registerAuthCommands(program: Command): void {
.action(async (_options: unknown, command: Command) => {
const globals = globalOptions(command);
const { name, profile } = await getProfile(globals.profile);
if (!profile.accessToken && !profile.apiKey) {
if (!profile.accessToken && !profile.apiKey && !profile.accountGrant) {
const status = {
authenticated: false,
profile: name,
Expand All @@ -124,10 +106,12 @@ export function registerAuthCommands(program: Command): void {

auth
.command("logout")
.description("Remove locally stored credentials")
.description("Sign this machine out and remove its credentials")
.action(async (_options: unknown, command: Command) => {
const globals = globalOptions(command);
const { name } = await getProfile(globals.profile);
const { name, profile } = await getProfile(globals.profile);
// Ends this machine's sign-in at Otter Accounts too; other devices stay signed in.
if (profile.accountGrant) await revokeGrant(profile.accountGrant);
await clearCredentials(name);
if (globals.json) printJson({ success: true, profile: name });
else success(`Removed credentials for profile ${pc.bold(name)}.`);
Expand Down
35 changes: 32 additions & 3 deletions apps/drive-cli/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { apiErrorSchema } from "@otterware/drive-contracts/api";
import type { Profile } from "./config";
import { DRIVE_CLOUD_AUDIENCE } from "@otterware/drive-contracts/constants";
import { driveCapability, freshGrant } from "./account";
import { profileNameOf, updateProfile, type Profile } from "./config";

/** One capability per process and profile; Cloud's capabilities last 15 minutes. */
const capabilities = new WeakMap<Profile, { token: string; at: number }>();

export class ApiClient {
readonly baseUrl: string;
Expand All @@ -11,11 +16,18 @@ export class ApiClient {
}

async request<T>(path: string, init: RequestInit & { raw?: boolean } = {}): Promise<T> {
const url = path.startsWith("http") ? path : `${this.baseUrl}${path}`;
// Credentials go only to the configured API, never to a server-supplied upload host.
if (new URL(url).origin !== new URL(this.baseUrl).origin)
throw new Error(`Refusing to send Drive credentials to ${new URL(url).origin}.`);
const headers = new Headers(init.headers);
headers.set("accept", init.raw ? "*/*" : "application/json");
if (this.profile.apiKey) headers.set("x-api-key", this.profile.apiKey);
if (this.profile.accessToken) {
headers.set("authorization", `Bearer ${this.profile.accessToken}`);
} else if (this.profile.accountGrant && !this.profile.apiKey) {
headers.set("authorization", `Bearer ${await this.capability()}`);
headers.set("x-otter-audience", DRIVE_CLOUD_AUDIENCE);
}
if (this.profile.folderId) {
headers.set("x-otterdrive-folder", this.profile.folderId);
Expand All @@ -24,7 +36,7 @@ export class ApiClient {
headers.set("content-type", "application/json");
}

const response = await fetch(path.startsWith("http") ? path : `${this.baseUrl}${path}`, {
const response = await fetch(url, {
...init,
headers,
});
Expand All @@ -34,7 +46,9 @@ export class ApiClient {
const generic =
typeof body === "object" && body !== null ? (body as Record<string, unknown>) : null;
const message = parsed.success
? parsed.data.error.message
? parsed.data.error.code === "identity_conflict"
? "Your Drive account was created before Otter Accounts and needs a one-time migration. Contact Otterware support, or use an API key meanwhile."
: parsed.data.error.message
: typeof generic?.error === "string"
? `${generic.error}: ${String(generic.error_description ?? generic.message ?? "")}`
: `${response.status} ${response.statusText}`;
Expand All @@ -45,6 +59,21 @@ export class ApiClient {
return (await response.json()) as T;
}

private async capability(): Promise<string> {
const cached = capabilities.get(this.profile);
if (cached && Date.now() - cached.at < 14 * 60_000) return cached.token;
const previous = this.profile.accountGrant!;
const grant = await freshGrant(previous);
if (grant !== previous) {
this.profile.accountGrant = grant;
const name = profileNameOf(this.profile);
if (name) await updateProfile(name, { accountGrant: grant }, false);
}
const token = await driveCapability(grant, this.baseUrl);
capabilities.set(this.profile, { token, at: Date.now() });
return token;
}

get<T>(path: string): Promise<T> {
return this.request<T>(path);
}
Expand Down
26 changes: 26 additions & 0 deletions apps/drive-cli/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,17 @@ const { dirname, join } = NodePath;

import { DEFAULT_API_URL } from "@otterware/drive-contracts/api";

/** This machine's own Otter Account grant (device login); never shared between machines. */
export interface AccountGrant {
accessToken: string;
refreshToken: string;
expiresAt: number;
}

export interface Profile {
apiUrl: string;
accountGrant?: AccountGrant | undefined;
/** A legacy Drive session or OTTERDRIVE_TOKEN bearer. */
accessToken?: string | undefined;
apiKey?: string | undefined;
folderId?: string | undefined;
Expand All @@ -33,6 +42,12 @@ export function mergeProfile(existing: Profile | undefined, update: Partial<Prof
if (Object.prototype.hasOwnProperty.call(update, "apiKey") && update.apiKey === undefined) {
delete profile.apiKey;
}
if (
Object.prototype.hasOwnProperty.call(update, "accountGrant") &&
update.accountGrant === undefined
) {
delete profile.accountGrant;
}
return profile;
}

Expand Down Expand Up @@ -88,6 +103,14 @@ async function writeConfig(config: ConfigFile): Promise<void> {
await chmod(path, 0o600);
}

const profileNames = new WeakMap<Profile, string>();
/** The stored profile a loaded profile came from, so a renewed grant is saved back to it. */
export const profileNameOf = (profile: Profile) => profileNames.get(profile);

/** A person's own sign-in (not an agent API key): required for sharing and moving. */
export const isUserLogin = (profile: Profile) =>
Boolean((profile.accessToken || profile.accountGrant) && !profile.apiKey);

export async function getProfile(name?: string): Promise<{
name: string;
profile: Profile;
Expand All @@ -105,6 +128,7 @@ export async function getProfile(name?: string): Promise<{
if (token) {
delete profile.apiKey;
delete profile.accessToken;
delete profile.accountGrant;
if (token.startsWith("otw_")) profile.apiKey = token;
else profile.accessToken = token;
}
Expand All @@ -116,6 +140,7 @@ export async function getProfile(name?: string): Promise<{
if (folder) {
profile.folderId = folder;
}
profileNames.set(profile, profileName);
return { name: profileName, profile };
}

Expand All @@ -138,6 +163,7 @@ export async function clearCredentials(name: string): Promise<void> {
if (profile) {
delete profile.accessToken;
delete profile.apiKey;
delete profile.accountGrant;
await writeConfig(config);
}
}
Loading
Loading