import { createHash, randomBytes } from 'node:crypto';
import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
import { createInterface } from 'node:readline/promises';
import { readStore } from './account-store.js';
import { listenForCode, openBrowser as defaultOpenBrowser } from './browser-login.js';
const AUTH_URI = 'https://accounts.google.com/o/oauth2/v2/auth';
const TOKEN_URI = 'https://oauth2.googleapis.com/token';
const REDIRECT_URI = 'https://sdk.cloud.google.com/applicationdefaultauthcode.html';
// public installed-app client shipped with google's cloud sdk and colab-cli
const DEFAULT_CLIENT_ID =
'764086051850-6qr4p6gpi6hn506pt8ejuq83di341hur.apps.googleusercontent.com';
const DEFAULT_CLIENT_SECRET = 'd-FL95Q19q7MQmFpd7hHD0Ty';
const SCOPES = [
'openid',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/cloud-platform',
'https://www.googleapis.com/auth/colaboratory',
'https://www.googleapis.com/auth/drive.file',
];
const EXPIRY_SKEW_MS = 60_000;
const TIMEOUT_MS = 30_000;
const LOGIN_TIMEOUT_MS = 300_000;
const LOGIN_HINT = 'run `node src/index.js login`';
export class Auth {
#accountId;
#env;
#fetch;
#creds = null;
#key = null;
#gen = 0;
#refreshing = null;
constructor({ env = process.env, fetch = globalThis.fetch, accountId } = {}) {
this.#accountId = accountId;
this.#env = env;
this.#fetch = fetch;
}
get #managed() {
return Boolean(this.#env.COLAB_MANAGED_CREDENTIALS);
}
// an explicit COLAB_CREDENTIALS file is external: read, never written
get #external() {
return Boolean(this.#env.COLAB_CREDENTIALS);
}
get #localPath() {
const home = this.#env.HOME || this.#env.USERPROFILE || homedir();
return join(home, '.config', 'colab-mcp', 'credentials.json');
}
get #hint() {
if (!this.#managed) return LOGIN_HINT;
const url = this.#env.COLAB_LOGIN_URL;
return url ? `sign in at ${url}` : 'sign in through the hosted colab login page';
}
// the gateway owns the managed file; it wins whenever it exists. fallback to
// an explicit external file only while it is absent, never when unreadable.
async #locate() {
if (this.#managed) {
const path = this.#env.COLAB_MANAGED_CREDENTIALS;
const key = await statKey(path);
if (key) return { path, key, managed: true, writable: false };
if (!this.#external) {
throw new Error(`no managed colab credentials at ${path}; ${this.#hint}`);
}
}
const path = this.#external ? this.#env.COLAB_CREDENTIALS : this.#localPath;
return { path, key: await statKey(path), managed: false, writable: !this.#external };
}
async token() {
if (this.#env.COLAB_ACCESS_TOKEN) return this.#env.COLAB_ACCESS_TOKEN;
// a replaced file mid-refresh restarts once per replacement; bounded
for (let attempt = 0; attempt < 3; attempt++) {
const src = await this.#locate();
const id = `${src.path}:${src.key}`;
if (this.#key !== id) {
const loaded = await this.#load(src);
if (this.#key !== id) {
this.#creds = loaded;
this.#key = id;
this.#gen++;
}
}
if (fresh(this.#creds)) return this.#creds.token;
const gen = this.#gen;
if (this.#refreshing?.gen !== gen) {
const promise = this.#refresh(this.#creds, src, gen).finally(() => {
if (this.#refreshing?.promise === promise) this.#refreshing = null;
});
this.#refreshing = { gen, promise };
}
const creds = await this.#refreshing.promise;
if (creds) return creds.token;
}
throw new Error(`colab credentials kept changing during refresh; ${this.#hint}`);
}
async login({
manual = false,
input = process.stdin,
output = process.stderr,
openBrowser = defaultOpenBrowser,
signal,
timeoutMs = LOGIN_TIMEOUT_MS,
} = {}) {
if (this.#managed) {
throw new Error(`COLAB_MANAGED_CREDENTIALS is set; ${this.#hint}`);
}
if (this.#external) {
throw new Error(
"login writes colab-mcp's own credentials file; unset COLAB_CREDENTIALS first",
);
}
const verifier = randomBytes(32).toString('base64url');
const state = randomBytes(16).toString('base64url');
const { code, redirectUri } = manual
? await this.#manualCode(verifier, state, input, output)
: await this.#loopbackCode(verifier, state, output, openBrowser, signal, timeoutMs);
const body = await this.#post(
{
grant_type: 'authorization_code',
code,
code_verifier: verifier,
redirect_uri: redirectUri,
client_id: this.#clientId(),
client_secret: this.#clientSecret(),
},
signal,
);
if (!body.refresh_token)
throw new Error('google returned no refresh token; try logging in again');
const creds = {
client_id: this.#clientId(),
client_secret: this.#clientSecret(),
refresh_token: body.refresh_token,
...accessFields(body),
};
await this.#save(creds);
this.#creds = creds;
this.#key = `${this.#localPath}:${await statKey(this.#localPath)}`;
this.#gen++;
}
#authUrl(redirectUri, verifier, state, remote) {
const url = new URL(AUTH_URI);
url.search = new URLSearchParams({
client_id: this.#clientId(),
redirect_uri: redirectUri,
response_type: 'code',
scope: SCOPES.join(' '),
access_type: 'offline',
prompt: 'consent',
...(remote && { token_usage: 'remote' }),
state,
code_challenge: createHash('sha256').update(verifier).digest('base64url'),
code_challenge_method: 'S256',
}).toString();
return url;
}
async #manualCode(verifier, state, input, output) {
const url = this.#authUrl(REDIRECT_URI, verifier, state, true);
output.write(`\nto authorize colab-mcp, visit this url in any browser:\n\n ${url}\n\n`);
output.write('after approving, google will display an authorization code.\n');
const rl = createInterface({ input, output });
let code;
try {
code = (await rl.question('enter the authorization code: ')).trim();
} finally {
rl.close();
}
if (!code) throw new Error('no authorization code entered');
return { code, redirectUri: REDIRECT_URI };
}
async #loopbackCode(verifier, state, output, openBrowser, signal, timeoutMs) {
const lb = await listenForCode({ state, signal, timeoutMs });
try {
const url = this.#authUrl(lb.redirectUri, verifier, state, false);
output.write(`\nto authorize colab-mcp, open this url in your browser:\n\n ${url}\n\n`);
output.write('waiting for google to redirect back to this machine...\n');
Promise.resolve()
.then(() => openBrowser(url.toString()))
.catch(() => output.write('could not open a browser; open the url above manually.\n'));
return { code: await lb.code, redirectUri: lb.redirectUri };
} finally {
lb.close();
}
}
#clientId(file) {
return this.#env.COLAB_CLIENT_ID || file?.client_id || DEFAULT_CLIENT_ID;
}
#clientSecret(file) {
return this.#env.COLAB_CLIENT_SECRET || file?.client_secret || DEFAULT_CLIENT_SECRET;
}
async #load({ path, managed }) {
let raw;
try {
raw = this.#accountId
? (await readStore(path)).find((account) => account.account_id === this.#accountId)
: JSON.parse(await readFile(path, 'utf8'));
} catch (err) {
if (err.code === 'ENOENT') throw new Error(`no colab credentials at ${path}; ${this.#hint}`);
throw new Error(
`cannot read colab credentials at ${path} (${err.code ?? 'invalid json'}); ${this.#hint}`,
);
}
if (typeof raw?.refresh_token !== 'string' || !raw.refresh_token) {
throw new Error(`credentials at ${path} have no refresh_token; ${this.#hint}`);
}
if (managed && [raw.client_id, raw.client_secret].some((v) => typeof v !== 'string' || !v)) {
throw new Error(`credentials at ${path} have no client_id/client_secret; ${this.#hint}`);
}
// token_uri in the file is ignored on purpose: it would let a file redirect the refresh token.
// a managed file pins its own client; local env overrides must not mix with it
return {
client_id: managed ? raw.client_id : this.#clientId(raw),
client_secret: managed ? raw.client_secret : this.#clientSecret(raw),
refresh_token: raw.refresh_token,
token: typeof raw.token === 'string' ? raw.token : undefined,
expiry: raw.expiry,
};
}
// a file replaced mid-flight is a newer generation: the caller reloads it,
// whether this refresh succeeded or failed against the old refresh token
async #superseded(src, gen) {
return gen !== this.#gen || (await statKey(src.path)) !== src.key;
}
async #refresh(old, src, gen) {
let body;
try {
body = await this.#post({
grant_type: 'refresh_token',
refresh_token: old.refresh_token,
client_id: old.client_id,
client_secret: old.client_secret,
});
} catch (err) {
if (await this.#superseded(src, gen)) return null;
throw new Error(`${err.message}; ${this.#hint}`);
}
const creds = { ...old, ...accessFields(body) };
if (await this.#superseded(src, gen)) return null;
// best effort only: another process can still replace the file between this
// stat and the rename below; there is no cross-process compare-and-swap
if (src.writable) {
await this.#save(creds).catch(() => {});
this.#key = `${src.path}:${await statKey(src.path)}`;
}
this.#creds = creds;
return creds;
}
async #post(params, signal) {
let res;
const timeout = AbortSignal.timeout(TIMEOUT_MS);
try {
res = await this.#fetch(TOKEN_URI, {
method: 'POST',
redirect: 'error',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(params),
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
});
} catch (err) {
throw new Error(
`google token request failed: ${signal?.aborted ? 'cancelled' : err.name === 'TimeoutError' ? 'timed out' : 'network error'}`,
);
}
const body = await res.json().catch(() => ({}));
if (!res.ok || typeof body.access_token !== 'string') {
const reason = [
'invalid_grant',
'invalid_client',
'unauthorized_client',
'access_denied',
].includes(body.error)
? body.error
: `http ${res.status}`;
throw new Error(`google rejected the token request (${reason})`);
}
return body;
}
async #save(creds) {
const path = this.#localPath;
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
await chmod(dirname(path), 0o700);
const tmp = `${path}.${randomBytes(6).toString('hex')}.tmp`;
const data = { type: 'authorized_user', scopes: SCOPES, ...creds };
try {
await writeFile(tmp, JSON.stringify(data, null, 2), { mode: 0o600 });
await rename(tmp, path);
} catch (err) {
await rm(tmp, { force: true });
throw err;
}
}
}
function accessFields(body) {
const seconds = Number(body.expires_in) || 0;
return { token: body.access_token, expiry: new Date(Date.now() + seconds * 1000).toISOString() };
}
function fresh(creds) {
const expiry = Date.parse(creds.expiry);
return Boolean(creds.token) && expiry - Date.now() > EXPIRY_SKEW_MS;
}
// o(1) identity of a credentials file; null when absent
export async function statKey(path) {
try {
const st = await stat(path, { bigint: true });
return `${st.ino}:${st.mtimeNs}:${st.ctimeNs}:${st.size}`;
} catch (err) {
if (err.code === 'ENOENT') return null;
return `unreadable:${err.code}`;
}
}