diff --git a/.changeset/browser-login.md b/.changeset/browser-login.md new file mode 100644 index 0000000..0e19bbd --- /dev/null +++ b/.changeset/browser-login.md @@ -0,0 +1,5 @@ +--- +'@thatopen/services': minor +--- + +Sign in through the browser: `thatopen login` without a token, and `EngineServicesClient.signInWithBrowser`. diff --git a/docs/cli/login.md b/docs/cli/login.md index 80ee2ad..b459036 100644 --- a/docs/cli/login.md +++ b/docs/cli/login.md @@ -6,7 +6,9 @@ description: "Authenticate with the ThatOpen platform by validating an access to Validates an access token against the platform API and saves the credentials. Run this once globally before using any other command, or per-project with `--local`. -To get a token: +Without a token, it signs you in through your browser, as the Revit plugin does: it shows a code, opens the platform, and you confirm the code there. The session renews itself, and appears under **Connected apps** in your account, where you can disconnect it. + +To use a token from the dashboard instead: 1. Go to [https://platform.thatopen.com](https://platform.thatopen.com) 2. In the header, click **Data** 3. Find the **Tokens** card and create a new token @@ -15,17 +17,18 @@ To get a token: **Usage:** ```bash -thatopen login --token [flags] +thatopen login [flags] ``` **Flags:** -- `--token ` — Access token from the dashboard. Required. +- `--token ` — Access token from the dashboard. Without one, you sign in through your browser. - `--api-url ` — Platform API URL. Default: `https://platform.thatopen.com` (production). Pass `https://dev.platform.thatopen.com` to target the dev environment. - `--local` — Save credentials to `.thatopen` in the current directory instead of the global config. Use this inside a project to override the global token for that project only. **Examples:** ```bash +thatopen login thatopen login --token my-token thatopen login --token my-token --local npm run login -- --token my-token # from inside a scaffolded project diff --git a/src/cli/commands/login.ts b/src/cli/commands/login.ts index 7f61fea..dca85b4 100644 --- a/src/cli/commands/login.ts +++ b/src/cli/commands/login.ts @@ -1,11 +1,50 @@ import { Command } from 'commander'; +import { readFileSync } from 'node:fs'; +import { hostname } from 'node:os'; +import { join } from 'node:path'; +import { signInWithBrowser } from '../../core/browser-sign-in'; +import { openInBrowser } from '../lib/browser-session'; import { writeConfig, updateLocalConfig, readLocalConfig } from '../lib/config'; import { cliClient } from '../lib/cli-client'; import { setupNpmrc } from '../lib/npmrc'; +const PACKAGE_VERSION: string = JSON.parse( + readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'), +).version; + +const signIn = async (apiUrl: string) => { + try { + return await signInWithBrowser({ + apiUrl, + application: 'That Open CLI', + machine: hostname(), + version: PACKAGE_VERSION, + onVerification: ({ url, userCode, manualUrl }) => { + console.log(''); + console.log(` Your code: ${userCode}`); + console.log(''); + console.log(' Confirm it in the browser that has just opened.'); + console.log(` If none did, go to ${manualUrl} and enter the code.`); + console.log(''); + openInBrowser(url); + }, + }); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`Could not sign in: ${msg}`); + console.error( + 'You can also log in with a token from the dashboard: thatopen login --token ', + ); + process.exit(1); + } +}; + export const loginCommand = new Command('login') .description('Authenticate with the ThatOpen platform') - .option('--token ', 'Access token from the dashboard') + .option( + '--token ', + 'Access token from the dashboard. Without one, you sign in through your browser', + ) .option( '--api-url ', 'API URL (defaults to production; pass https://dev.platform.thatopen.com for the dev environment)', @@ -16,29 +55,14 @@ export const loginCommand = new Command('login') 'Save credentials to local .thatopen file instead of global config', ) .action(async (opts: { token?: string; apiUrl: string; local?: boolean }) => { - if (!opts.token) { - console.log(''); - console.log(' To log in, you need an access token from the ThatOpen platform.'); - console.log(''); - console.log(' 1. Go to your ThatOpen dashboard'); - console.log(' 2. Navigate to Data > API Tokens'); - console.log(' 3. Create a new token and copy it'); - console.log(''); - console.log(' Then run:'); - if (opts.local) { - console.log(' npm run login -- --token '); - } else { - console.log(' thatopen login --token '); - } - console.log(''); - process.exit(1); - } - const apiUrl = opts.apiUrl.replace(/\/$/, ''); + const session = opts.token ? undefined : await signIn(apiUrl); + const accessToken = opts.token ?? session!.accessToken; + console.log('Validating token...'); - const client = cliClient(opts.token, apiUrl); + const client = cliClient(accessToken, apiUrl); try { await client.listApps(); @@ -50,10 +74,10 @@ export const loginCommand = new Command('login') } if (opts.local) { - updateLocalConfig({ accessToken: opts.token, apiUrl }); + updateLocalConfig({ accessToken, apiUrl, session }); console.log('Logged in successfully. Config saved to .thatopen'); } else { - writeConfig({ accessToken: opts.token, apiUrl }); + writeConfig({ accessToken, apiUrl, session }); console.log( 'Logged in successfully. Config saved to ~/.thatopen/config.json', ); diff --git a/src/cli/index.ts b/src/cli/index.ts index e40fc3b..ede95a7 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -11,6 +11,7 @@ import { swapCommand } from './commands/swap'; import { revitCommand } from './commands/revit'; import { rhinoCommand } from './commands/rhino'; import { mcpCommand } from './commands/mcp'; +import { renewStoredSession } from './lib/browser-session'; const pkg = JSON.parse( readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'), @@ -48,6 +49,11 @@ program .description('CLI for ThatOpen Engine Services') .version(pkg.version); +// The login that just asked for a new session has nothing to renew. +program.hook('preAction', async (_program, command) => { + if (command.name() !== 'login') await renewStoredSession(); +}); + program.addCommand(loginCommand); program.addCommand(createCommand); program.addCommand(publishCommand); @@ -59,4 +65,4 @@ program.addCommand(revitCommand); program.addCommand(rhinoCommand); program.addCommand(mcpCommand); -program.parse(process.argv); +program.parseAsync(process.argv); diff --git a/src/cli/lib/browser-session.ts b/src/cli/lib/browser-session.ts new file mode 100644 index 0000000..d52ea10 --- /dev/null +++ b/src/cli/lib/browser-session.ts @@ -0,0 +1,73 @@ +import { execFile } from 'node:child_process'; +import { + refreshBrowserSession, + type BrowserSession, +} from '../../core/browser-sign-in'; +import { + readConfig, + readLocalConfig, + updateLocalConfig, + writeConfig, +} from './config'; + +/** Renewed this long before it expires, so a command never starts on its last seconds. */ +const RENEW_BEFORE_MS = 60_000; + +/** Best effort: on a machine with no browser, the address is on the screen anyway. */ +export function openInBrowser(url: string): void { + const [command, args] = + process.platform === 'win32' + ? (['rundll32', ['url.dll,FileProtocolHandler', url]] as const) + : process.platform === 'darwin' + ? (['open', [url]] as const) + : (['xdg-open', [url]] as const); + execFile(command, [...args], () => {}); +} + +/** + * Before a command runs, a browser sign-in whose access token is about to expire + * gets a new one, in the same config it came from. A login with `--token` has no + * session and is left alone. + * + * If the renewal fails the command goes on, and fails on its own call with the + * platform's answer, unless the session was cut: then it says to log in again. + */ +export async function renewStoredSession(cwd?: string): Promise { + const local = readLocalConfig(cwd); + const stored = local?.session ?? readConfig()?.session; + if (!stored || stored.expiresAt - Date.now() > RENEW_BEFORE_MS) return; + + let next: BrowserSession; + try { + next = await refreshBrowserSession(stored); + } catch (error) { + console.error( + `Could not renew your sign-in: ${error instanceof Error ? error.message : String(error)}`, + ); + console.error('If this keeps happening, run `thatopen login` again.'); + return; + } + + storeSession(next, cwd); +} + +/** The stored session this token belongs to, if it came from a browser sign-in. */ +export function storedSessionFor( + accessToken: string, + cwd?: string, +): BrowserSession | undefined { + return [readLocalConfig(cwd)?.session, readConfig()?.session].find( + (session) => session?.accessToken === accessToken, + ); +} + +/** Puts a renewed session back where it came from: its refresh token has rotated. */ +export function storeSession(session: BrowserSession, cwd?: string): void { + const fields = { + accessToken: session.accessToken, + apiUrl: session.apiUrl, + session, + }; + if (readLocalConfig(cwd)?.session) updateLocalConfig(fields, cwd); + else writeConfig(fields); +} diff --git a/src/cli/lib/cli-client.ts b/src/cli/lib/cli-client.ts index 092d493..1ba1d19 100644 --- a/src/cli/lib/cli-client.ts +++ b/src/cli/lib/cli-client.ts @@ -1,4 +1,5 @@ import { EngineServicesClient } from '../../core/client'; +import { storedSessionFor, storeSession } from './browser-session'; /** * The one way the CLI builds a client. @@ -11,5 +12,13 @@ import { EngineServicesClient } from '../../core/client'; * case upgrading the platform comes first, not flipping this off. */ export function cliClient(accessToken: string, apiUrl: string) { + /* A browser sign-in's token lasts minutes, and `serve` and `mcp` run for hours. + So it renews itself here, and what was renewed is stored for the next command. */ + const session = storedSessionFor(accessToken); + if (session) { + return EngineServicesClient.fromBrowserSession(session, { + onSession: storeSession, + }); + } return new EngineServicesClient(accessToken, apiUrl, { useBearer: true }); } diff --git a/src/cli/lib/config.ts b/src/cli/lib/config.ts index 3e0b89d..50ddc20 100644 --- a/src/cli/lib/config.ts +++ b/src/cli/lib/config.ts @@ -1,10 +1,13 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { homedir } from 'node:os'; +import type { BrowserSession } from '../../core/browser-sign-in'; export interface ThatOpenConfig { accessToken: string; apiUrl: string; + /** Present after a login through the browser: what renews `accessToken`. */ + session?: BrowserSession; } const CONFIG_DIR = join(homedir(), '.thatopen'); @@ -45,6 +48,8 @@ export type ProjectType = 'APP' | 'COMPONENT'; export interface ThatOpenLocalConfig { accessToken: string; apiUrl: string; + /** Present after a login through the browser: what renews `accessToken`. */ + session?: BrowserSession; appId?: string; componentId?: string; /** Project type marker set by `thatopen create`. */ diff --git a/src/core/browser-sign-in.test.ts b/src/core/browser-sign-in.test.ts new file mode 100644 index 0000000..dd59fa0 --- /dev/null +++ b/src/core/browser-sign-in.test.ts @@ -0,0 +1,146 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { refreshBrowserSession, signInWithBrowser } from './browser-sign-in'; + +const answers = (...bodies: unknown[]) => { + const fetchMock = vi.fn(); + for (const body of bodies) { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => body, + }); + } + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +}; + +const started = { + device_code: 'device', + user_code: 'HQ4F-72BD', + verification_uri: 'https://platform/dashboard/connect/device', + verification_uri_complete: + 'https://platform/dashboard/connect/device?userCode=HQ4F-72BD', + expires_in: 600, + interval: 5, +}; + +const options = (onVerification = vi.fn()) => ({ + apiUrl: 'https://platform/', + application: 'That Open CLI', + machine: 'laptop', + version: '1.0.0', + onVerification, +}); + +const bodyOf = (call: unknown[]) => + JSON.parse((call[1] as { body: string }).body); + +describe('signInWithBrowser', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('shows the code, waits for the person and returns the session', async () => { + const fetchMock = answers( + started, + { status: 'pending' }, + { + status: 'confirmed', + installId: 'install', + refreshToken: 'refresh', + accessToken: 'access', + expiresIn: 900, + }, + ); + const onVerification = vi.fn(); + + const signingIn = signInWithBrowser(options(onVerification)); + await vi.advanceTimersByTimeAsync(10_000); + const session = await signingIn; + + expect(onVerification).toHaveBeenCalledWith({ + url: started.verification_uri_complete, + userCode: 'HQ4F-72BD', + manualUrl: started.verification_uri, + }); + expect(session).toMatchObject({ + accessToken: 'access', + installId: 'install', + refreshToken: 'refresh', + }); + expect(fetchMock.mock.calls[0][0]).toBe( + 'https://platform/api/connectors/device/start', + ); + expect(bodyOf(fetchMock.mock.calls[0]).connectorId).toBe('services-sdk'); + expect(bodyOf(fetchMock.mock.calls[1])).toEqual({ + deviceCode: 'device', + connectorId: 'services-sdk', + pluginVersion: '1.0.0', + }); + }); + + it('gives up when the code expires', async () => { + const fetchMock = answers({ ...started, expires_in: 4 }); + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ status: 'pending' }), + }); + + const signingIn = signInWithBrowser(options()); + const expectation = expect(signingIn).rejects.toThrow(/expired/); + await vi.advanceTimersByTimeAsync(10_000); + await expectation; + }); + + it('says what the platform said when it refuses', async () => { + const fetchMock = answers(started); + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 403, + text: async () => JSON.stringify({ message: 'Update to 1.1.0' }), + }); + + const signingIn = signInWithBrowser(options()); + const expectation = expect(signingIn).rejects.toThrow('Update to 1.1.0'); + await vi.advanceTimersByTimeAsync(5_000); + await expectation; + }); + + it('stops waiting when asked to', async () => { + answers(started); + const controller = new AbortController(); + + const signingIn = signInWithBrowser({ + ...options(), + signal: controller.signal, + }); + const expectation = expect(signingIn).rejects.toThrow('cancelled'); + await vi.advanceTimersByTimeAsync(0); + controller.abort(); + await expectation; + }); +}); + +describe('refreshBrowserSession', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('returns the rotated refresh token with the new access token', async () => { + answers({ refreshToken: 'next', accessToken: 'fresh', expiresIn: 900 }); + + const next = await refreshBrowserSession({ + apiUrl: 'https://platform', + accessToken: 'old', + expiresAt: 0, + installId: 'install', + refreshToken: 'first', + }); + + expect(next).toMatchObject({ + accessToken: 'fresh', + refreshToken: 'next', + installId: 'install', + }); + expect(next.expiresAt).toBeGreaterThan(Date.now()); + }); +}); diff --git a/src/core/browser-sign-in.ts b/src/core/browser-sign-in.ts new file mode 100644 index 0000000..d62fc25 --- /dev/null +++ b/src/core/browser-sign-in.ts @@ -0,0 +1,160 @@ +/** + * What `signInWithBrowser` leaves: enough to call the platform now, and to keep + * doing so after the access token expires. + */ +export interface BrowserSession { + apiUrl: string; + accessToken: string; + /** When the access token stops working, in milliseconds since the epoch. */ + expiresAt: number; + installId: string; + refreshToken: string; +} + +export interface SignInWithBrowserOptions { + /** The platform to sign in to. */ + apiUrl: string; + /** + * Shown to the person with the code, so they can tell it is theirs: what is + * asking and where. For example `That Open CLI` and the machine's name. + */ + application: string; + machine: string; + /** This package's version. The platform refuses a version it no longer supports. */ + version: string; + /** + * Called once with the address to open and the code it should show. The + * person compares that code and confirms it there. + */ + onVerification: (verification: BrowserVerification) => void | Promise; + /** Stops waiting for the person. */ + signal?: AbortSignal; +} + +export interface BrowserVerification { + url: string; + userCode: string; + /** Where to type the code, for somebody who cannot follow `url`. */ + manualUrl: string; +} + +/** What this package connects to the platform as. */ +export const SERVICES_SDK_CONNECTOR_ID = 'services-sdk'; + +const post = async (url: string, body: unknown): Promise => { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!response.ok) { + const text = await response.text().catch(() => ''); + let message = text; + try { + message = (JSON.parse(text) as { message?: string }).message ?? text; + } catch { + /* Not JSON: the text is the best there is. */ + } + throw new Error(message || `The platform answered ${response.status}`); + } + return (await response.json()) as T; +}; + +const wait = (ms: number, signal?: AbortSignal) => + new Promise((resolve, reject) => { + const timer = setTimeout(resolve, ms); + signal?.addEventListener( + 'abort', + () => { + clearTimeout(timer); + reject(new Error('Sign-in cancelled')); + }, + { once: true }, + ); + }); + +const deviceUrl = (apiUrl: string, path: string) => + `${apiUrl.replace(/\/$/, '')}/api/connectors/device/${path}`; + +/** + * Signs in through the browser, as the Revit plugin does (RFC 8628): the + * platform gives a code, the person confirms it in their browser, and this waits + * for the answer. No token is ever pasted. + */ +export async function signInWithBrowser( + options: SignInWithBrowserOptions, +): Promise { + const { apiUrl, application, machine, version, onVerification, signal } = + options; + + const start = await post<{ + device_code: string; + user_code: string; + verification_uri: string; + verification_uri_complete: string; + expires_in: number; + interval: number; + }>(deviceUrl(apiUrl, 'start'), { + application, + machine, + connectorId: SERVICES_SDK_CONNECTOR_ID, + }); + + await onVerification({ + url: start.verification_uri_complete, + userCode: start.user_code, + manualUrl: start.verification_uri, + }); + + const deadline = Date.now() + start.expires_in * 1000; + while (Date.now() < deadline) { + await wait(start.interval * 1000, signal); + const claim = await post< + | { status: 'pending' } + | { + status: 'confirmed'; + installId: string; + refreshToken: string; + accessToken: string; + expiresIn: number; + } + >(deviceUrl(apiUrl, 'claim'), { + deviceCode: start.device_code, + connectorId: SERVICES_SDK_CONNECTOR_ID, + pluginVersion: version, + }); + if (claim.status === 'confirmed') { + return { + apiUrl, + accessToken: claim.accessToken, + expiresAt: Date.now() + claim.expiresIn * 1000, + installId: claim.installId, + refreshToken: claim.refreshToken, + }; + } + } + throw new Error('The code expired before it was confirmed. Try again.'); +} + +/** + * A new access token for a session. The refresh token rotates, so the session + * returned replaces the one given: keep this one, wherever it is stored. + */ +export async function refreshBrowserSession( + session: BrowserSession, +): Promise { + const next = await post<{ + refreshToken: string; + accessToken: string; + expiresIn: number; + }>(deviceUrl(session.apiUrl, 'refresh'), { + installId: session.installId, + refreshToken: session.refreshToken, + }); + return { + ...session, + accessToken: next.accessToken, + expiresAt: Date.now() + next.expiresIn * 1000, + refreshToken: next.refreshToken, + }; +} diff --git a/src/core/client.ts b/src/core/client.ts index 818d0cd..dc08a4b 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -1,3 +1,9 @@ +import { + refreshBrowserSession, + signInWithBrowser, + type BrowserSession, + type SignInWithBrowserOptions, +} from './browser-sign-in'; import { io } from 'socket.io-client'; import { ProjectDocuments } from './documents'; import { ProjectHistory } from './history'; @@ -335,6 +341,103 @@ export class EngineServicesClient { } } + /** + * Signs in through the browser, as the Revit plugin does, and returns a client + * that keeps its access token fresh while the process lives. For a program + * outside the platform; an app inside it uses {@link fromPlatformContext}. + * + * Keep the session given to `onSession` to start again without asking the + * person, with {@link fromBrowserSession}. Its refresh token rotates, so each + * one replaces the last. + * + * @example + * ```ts + * const client = await EngineServicesClient.signInWithBrowser({ + * apiUrl: 'https://platform.thatopen.com', + * application: 'My tool', + * machine: os.hostname(), + * version: '1.0.0', + * onVerification: ({ url, userCode }) => { + * console.log(`Confirm ${userCode} at ${url}`); + * }, + * onSession: (session) => save(session), + * }); + * ``` + */ + static async signInWithBrowser( + options: SignInWithBrowserOptions & { + onSession?: (session: BrowserSession) => void; + }, + ): Promise { + const session = await signInWithBrowser(options); + options.onSession?.(session); + return EngineServicesClient.fromBrowserSession(session, options); + } + + /** + * A client from a session kept earlier. It renews the access token shortly + * before it expires, without keeping the process alive for it; call + * {@link stopRenewing} when done with a client in a process that goes on. + */ + static fromBrowserSession( + session: BrowserSession, + options?: { onSession?: (session: BrowserSession) => void }, + ): EngineServicesClient { + const client = new EngineServicesClient( + session.accessToken, + session.apiUrl, + { useBearer: true }, + ); + client.renewFrom(session, options?.onSession); + return client; + } + + private renewal: ReturnType | null = null; + + private renewFrom( + session: BrowserSession, + onSession?: (session: BrowserSession) => void, + ) { + const renewBefore = 60_000; + const delay = Math.max(session.expiresAt - Date.now() - renewBefore, 0); + this.renewal = setTimeout(async () => { + try { + const next = await refreshBrowserSession(session); + this.setAccessToken(next.accessToken); + onSession?.(next); + this.renewFrom(next, onSession); + } catch { + /* Tried again in a minute, keeping the same session: the platform + answers a refresh token just rotated with a fresh pair, and a cut + session goes on failing in the calls that use it, which is where + somebody will read why. */ + this.renewFrom( + { ...session, expiresAt: Date.now() + renewBefore + 60_000 }, + onSession, + ); + } + }, delay); + (this.renewal as { unref?: () => void }).unref?.(); + } + + /** Stops renewing the access token of a client from a browser session. */ + stopRenewing() { + if (this.renewal) clearTimeout(this.renewal); + this.renewal = null; + } + + /** + * Replaces the access token used by every call from now on. + * @param accessToken - The new token. + */ + setAccessToken(accessToken: string) { + this.accessToken = accessToken; + (this as { context: ThatOpenContext }).context = { + ...this.context, + accessToken, + }; + } + /** * Sets the number of automatic retries for failed requests. Retries use * exponential backoff with jitter and honour `Retry-After`; only network diff --git a/src/index.ts b/src/index.ts index 784b3e7..5089b5c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ export * from './core/client'; +export * from './core/browser-sign-in'; export * from './core/platform-client'; export * from './core/channel'; export * from './core/documents';