Skip to content
Merged
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
208 changes: 208 additions & 0 deletions clients/chrome-extension/background/__tests__/cloud-auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
/**
* Tests for the cloud OAuth state machine.
*
* These tests mock the `chrome.identity.launchWebAuthFlow` and
* `chrome.storage.local` surfaces so they can run under bun:test without
* a real Chrome runtime.
*/

import { describe, test, expect, beforeEach, afterEach } from 'bun:test';

import {
getStoredToken,
clearStoredToken,
signInCloud,
type CloudAuthConfig,
type StoredCloudToken,
} from '../cloud-auth.js';

const STORAGE_KEY = 'vellum.cloudAuthToken';

interface FakeStorage {
data: Record<string, unknown>;
get(key: string | string[]): Promise<Record<string, unknown>>;
set(items: Record<string, unknown>): Promise<void>;
remove(key: string | string[]): Promise<void>;
}

function createFakeStorage(): FakeStorage {
const data: Record<string, unknown> = {};
return {
data,
async get(key) {
const keys = Array.isArray(key) ? key : [key];
const result: Record<string, unknown> = {};
for (const k of keys) {
if (k in data) result[k] = data[k];
}
return result;
},
async set(items) {
Object.assign(data, items);
},
async remove(key) {
const keys = Array.isArray(key) ? key : [key];
for (const k of keys) delete data[k];
},
};
}

const originalChrome = (globalThis as { chrome?: unknown }).chrome;

let fakeStorage: FakeStorage;
let launchWebAuthFlowImpl: (details: { url: string; interactive: boolean }) => Promise<string | undefined>;

beforeEach(() => {
fakeStorage = createFakeStorage();
launchWebAuthFlowImpl = async () => undefined;
(globalThis as { chrome?: unknown }).chrome = {
storage: {
local: fakeStorage,
},
identity: {
getRedirectURL: (path: string) => `https://fakeextid.chromiumapp.org/${path}`,
launchWebAuthFlow: (details: { url: string; interactive: boolean }) => launchWebAuthFlowImpl(details),
},
};
});

afterEach(() => {
(globalThis as { chrome?: unknown }).chrome = originalChrome;
});

const config: CloudAuthConfig = {
gatewayBaseUrl: 'https://api.vellum.ai',
clientId: 'test-client-id',
};

describe('signInCloud', () => {
test('happy path stores a token and returns it', async () => {
launchWebAuthFlowImpl = async (details) => {
// The redirect URL the gateway would send back.
expect(details.url).toContain('https://api.vellum.ai/oauth/chrome-extension/start');
expect(details.url).toContain('client_id=test-client-id');
expect(details.interactive).toBe(true);
return 'https://fakeextid.chromiumapp.org/cloud-auth#token=abc123&expires_in=3600&guardian_id=g-42';
};

const before = Date.now();
const result = await signInCloud(config);
const after = Date.now();

expect(result.token).toBe('abc123');
expect(result.guardianId).toBe('g-42');
expect(result.expiresAt).toBeGreaterThanOrEqual(before + 3600 * 1000);
expect(result.expiresAt).toBeLessThanOrEqual(after + 3600 * 1000);

// Verify it was persisted.
expect(fakeStorage.data[STORAGE_KEY]).toEqual(result);
});

test('missing token rejects with "incomplete payload"', async () => {
launchWebAuthFlowImpl = async () =>
'https://fakeextid.chromiumapp.org/cloud-auth#expires_in=3600&guardian_id=g-42';

await expect(signInCloud(config)).rejects.toThrow('incomplete payload');
expect(fakeStorage.data[STORAGE_KEY]).toBeUndefined();
});

test('missing expires_in rejects with "incomplete payload"', async () => {
launchWebAuthFlowImpl = async () =>
'https://fakeextid.chromiumapp.org/cloud-auth#token=abc123&guardian_id=g-42';

await expect(signInCloud(config)).rejects.toThrow('incomplete payload');
});

test('missing guardian_id rejects with "incomplete payload"', async () => {
launchWebAuthFlowImpl = async () =>
'https://fakeextid.chromiumapp.org/cloud-auth#token=abc123&expires_in=3600';

await expect(signInCloud(config)).rejects.toThrow('incomplete payload');
});

test('cancelled flow rejects with "cancelled"', async () => {
launchWebAuthFlowImpl = async () => undefined;

await expect(signInCloud(config)).rejects.toThrow('cancelled');
expect(fakeStorage.data[STORAGE_KEY]).toBeUndefined();
});

test('trims trailing slash on gatewayBaseUrl', async () => {
let seenUrl = '';
launchWebAuthFlowImpl = async (details) => {
seenUrl = details.url;
return 'https://fakeextid.chromiumapp.org/cloud-auth#token=abc&expires_in=60&guardian_id=g1';
};
await signInCloud({ gatewayBaseUrl: 'https://api.vellum.ai/', clientId: 'cid' });
expect(seenUrl).toContain('https://api.vellum.ai/oauth/chrome-extension/start');
expect(seenUrl).not.toContain('api.vellum.ai//oauth');
});
});

describe('getStoredToken', () => {
test('returns null when nothing is stored', async () => {
expect(await getStoredToken()).toBeNull();
});

test('returns the stored token when valid', async () => {
const token: StoredCloudToken = {
token: 'valid-token',
expiresAt: Date.now() + 60_000,
guardianId: 'g-1',
};
fakeStorage.data[STORAGE_KEY] = token;

expect(await getStoredToken()).toEqual(token);
});

test('returns null when the token is expired', async () => {
fakeStorage.data[STORAGE_KEY] = {
token: 'expired',
expiresAt: Date.now() - 1_000,
guardianId: 'g-1',
} satisfies StoredCloudToken;

expect(await getStoredToken()).toBeNull();
});

test('returns null when the stored value is malformed', async () => {
fakeStorage.data[STORAGE_KEY] = { token: 42, expiresAt: 'soon' };

expect(await getStoredToken()).toBeNull();
});

test('returns null when guardianId is missing or non-string', async () => {
// Missing guardianId entirely — would otherwise render as "guardian:undefined" in the popup.
fakeStorage.data[STORAGE_KEY] = {
token: 'valid-token',
expiresAt: Date.now() + 60_000,
};
expect(await getStoredToken()).toBeNull();

// Non-string guardianId (e.g. a number).
fakeStorage.data[STORAGE_KEY] = {
token: 'valid-token',
expiresAt: Date.now() + 60_000,
guardianId: 42,
};
expect(await getStoredToken()).toBeNull();
});
});

describe('clearStoredToken', () => {
test('removes the key from storage', async () => {
fakeStorage.data[STORAGE_KEY] = {
token: 'to-clear',
expiresAt: Date.now() + 60_000,
guardianId: 'g-1',
} satisfies StoredCloudToken;

await clearStoredToken();
expect(fakeStorage.data[STORAGE_KEY]).toBeUndefined();
});

test('is a no-op when nothing is stored', async () => {
await clearStoredToken();
expect(fakeStorage.data[STORAGE_KEY]).toBeUndefined();
});
});
78 changes: 78 additions & 0 deletions clients/chrome-extension/background/cloud-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* Cloud OAuth sign-in state machine for the Vellum chrome extension.
*
* Launches chrome.identity.launchWebAuthFlow against the Vellum gateway and
* persists the guardian-bound JWT in chrome.storage.local. The token is used
* by later PRs to authenticate the browser-relay WebSocket against the cloud
* gateway — this module is the storage + state machine layer only.
*/

export interface CloudAuthConfig {
/** Gateway base URL, e.g. https://api.vellum.ai */
gatewayBaseUrl: string;
/** OAuth client id registered for the chrome extension. */
clientId: string;
}

export interface StoredCloudToken {
token: string;
expiresAt: number; // ms since epoch
guardianId: string;
}

const STORAGE_KEY = 'vellum.cloudAuthToken';

export async function getStoredToken(): Promise<StoredCloudToken | null> {
const result = await chrome.storage.local.get(STORAGE_KEY);
const raw = result[STORAGE_KEY];
if (!raw || typeof raw !== 'object') return null;
const token = raw as StoredCloudToken;
if (
typeof token.token !== 'string' ||
typeof token.expiresAt !== 'number' ||
typeof token.guardianId !== 'string'
) {
return null;
}
if (token.expiresAt <= Date.now()) return null;
return token;
}

export async function clearStoredToken(): Promise<void> {
await chrome.storage.local.remove(STORAGE_KEY);
}

async function persistToken(token: StoredCloudToken): Promise<void> {
await chrome.storage.local.set({ [STORAGE_KEY]: token });
}

/**
* Launches chrome.identity.launchWebAuthFlow to obtain a guardian-bound JWT.
* The extension receives the token via the redirect URI fragment.
*/
export async function signInCloud(config: CloudAuthConfig): Promise<StoredCloudToken> {
const redirectUri = chrome.identity.getRedirectURL('cloud-auth');
const authUrl =
`${config.gatewayBaseUrl.replace(/\/$/, '')}/oauth/chrome-extension/start` +
`?client_id=${encodeURIComponent(config.clientId)}` +
`&redirect_uri=${encodeURIComponent(redirectUri)}`;

const responseUrl = await chrome.identity.launchWebAuthFlow({ url: authUrl, interactive: true });
if (!responseUrl) throw new Error('cloud sign-in cancelled');

const hash = new URL(responseUrl).hash.replace(/^#/, '');
const params = new URLSearchParams(hash);
const token = params.get('token');
const expiresIn = parseInt(params.get('expires_in') ?? '0', 10);
const guardianId = params.get('guardian_id') ?? '';
if (!token || !expiresIn || !guardianId) {
throw new Error('cloud sign-in returned incomplete payload');
}
const stored: StoredCloudToken = {
token,
expiresAt: Date.now() + expiresIn * 1000,
guardianId,
};
await persistToken(stored);
return stored;
}
25 changes: 25 additions & 0 deletions clients/chrome-extension/background/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@
*/

import type { ExtensionCommand, ExtensionResponse, ExtensionHeartbeat } from '../../../assistant/src/browser-extension-relay/protocol.js';
import { signInCloud, type CloudAuthConfig, type StoredCloudToken } from './cloud-auth.js';

// Cloud OAuth defaults — kept here so the popup can stay a thin client and the
// service worker is the single owner of the launchWebAuthFlow lifecycle. This
// avoids the MV3 popup teardown race where closing the popup mid-auth kills
// the awaited promise before the token is persisted.
//
// PR 14 will plumb these through config; hard-coded for the Phase 2 skeleton.
const CLOUD_GATEWAY_BASE_URL = 'https://api.vellum.ai';
const CLOUD_OAUTH_CLIENT_ID = 'vellum-chrome-extension';

const DEFAULT_RELAY_PORT = 7830;
const HEARTBEAT_INTERVAL_MS = 30_000;
Expand Down Expand Up @@ -343,6 +353,21 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
});
return false;
}
if (message.type === 'cloud-auth-sign-in') {
// Run the OAuth flow in the service worker — not the popup — so the
// awaited promise survives the popup losing focus during the Chrome
// identity window. The popup just awaits this message response.
const config: CloudAuthConfig = {
gatewayBaseUrl:
typeof message.gatewayBaseUrl === 'string' ? message.gatewayBaseUrl : CLOUD_GATEWAY_BASE_URL,
clientId:
typeof message.clientId === 'string' ? message.clientId : CLOUD_OAUTH_CLIENT_ID,
};
signInCloud(config)
.then((stored: StoredCloudToken) => sendResponse({ ok: true, token: stored }))
.catch((err) => sendResponse({ ok: false, error: err instanceof Error ? err.message : String(err) }));
return true; // async
}
});

// Auto-connect on service worker start if previously connected.
Expand Down
7 changes: 4 additions & 3 deletions clients/chrome-extension/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@
"description": "Bridges the Vellum assistant to your live browser session — no CDP, no spoofing.",

"permissions": [
"tabs",
"activeTab",
"scripting",
"cookies",
"debugger",
"identity",
"scripting",
"storage",
"debugger"
"tabs"
],
"host_permissions": [
"<all_urls>"
Expand Down
37 changes: 37 additions & 0 deletions clients/chrome-extension/popup/popup.html
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,37 @@
}
#btn-disconnect:hover:not(:disabled) { background: #e5e7eb; }

.divider {
height: 1px;
background: #e5e7eb;
margin: 16px 0 14px 0;
}

.section-label {
font-size: 11px;
font-weight: 600;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 8px;
}

#btn-cloud-signin {
width: 100%;
background: #111827;
color: #fff;
margin-bottom: 8px;
}
#btn-cloud-signin:hover:not(:disabled) { background: #1f2937; }

.cloud-status {
font-size: 12px;
color: #4b5563;
margin-bottom: 4px;
word-break: break-all;
}
.cloud-status.signed-in { color: #0f766e; }

.hint {
font-size: 11px;
color: #9ca3af;
Expand Down Expand Up @@ -163,6 +194,12 @@ <h1>Vellum Relay</h1>

<p class="hint">Token is auto-fetched from the local gateway. Port defaults to 7830.</p>

<div class="divider"></div>

<p class="section-label">Cloud</p>
<p class="cloud-status" id="cloud-status">Not signed in</p>
<button id="btn-cloud-signin" type="button">Sign in with Vellum (cloud)</button>

<script type="module" src="popup.js"></script>
</body>
</html>
Loading