Files
extension/packages/auth/tests/flow.test.ts
T
hanzo-dev 780757ca55 feat(auth): one canonical @hanzo/auth core; migrate office; fix vscode HIP-0111
Five surfaces (browser, office, vscode, cli, mcp) each hand-rolled the SAME
IAM OAuth2+PKCE flow — and one drifted and broke: vscode built
hanzo.id/oauth/authorize and iam.hanzo.ai/oauth/token, both MISSING the
/v1/iam prefix (a 404 — the same class of bug as the browser login fix),
plus it POSTed JSON where IAM requires form-urlencoded and sent a
client_secret on a public PKCE client. That is the opposite of one way.

Decomplected into ONE core — packages/auth (@hanzo/auth):
- config.ts   canonical HIP-0111 endpoints (authorize/token/userinfo ALL
              carry /v1/iam) + the one-client-per-surface registry
              (hanzo-browser/office/vscode/cli). URL assembly lives once.
- pkce.ts     the one PKCE (S256), platform-CSPRNG + WebCrypto.
- oauth.ts    buildAuthorizeURL / exchangeCode / refreshTokens / userinfo —
              pure fetch, form-encoded per RFC 6749.
- flow.ts     login / getValidToken (transparent refresh) / logout /
              currentUser, written ONCE against TokenStore + Opener. A
              surface supplies only those two thin adapters — no surface
              re-implements the flow.
23 tests: the drift guard (every path has /v1/iam), client registry, PKCE
RFC-7636 vector, wire shapes, and the full flow with fake adapters.

Migrate office onto it: roamingSettings TokenStore + Dialog-API Opener;
delete office's duplicate pkce.ts + IAM config (now the core's). API-key
path kept. 22 tests; the core bundles into the task pane.

Fix vscode standalone-auth: all three endpoints → ${host}/v1/iam/oauth/*,
token calls form-urlencoded, OIDC scopes, drop the secret on the public
PKCE client. Compiles clean.

CI now gates @hanzo/auth. Browser/mcp/tools already use canonical paths;
migrating them to import the core is the tracked next step (each needs its
own bundler wiring) — the ONE way now exists and has two consumers.
2026-07-03 15:24:01 -07:00

107 lines
4.5 KiB
TypeScript

import { describe, it, expect, vi, afterEach } from 'vitest';
import { login, getValidToken, logout, isExpired, currentUser } from '../src/flow';
import type { TokenStore, TokenSet, Opener } from '../src/types';
import { authClientFor } from '../src/index';
// memStore is the fake per-surface TokenStore — the same seam browser/office/
// vscode/cli implement over their real storage.
function memStore(initial: TokenSet | null = null): TokenStore & { value: TokenSet | null } {
return {
value: initial,
async get() { return this.value; },
async set(t) { this.value = t; },
async clear() { this.value = null; },
};
}
// fakeOpener returns a canned redirect URL (what IAM would land on).
function fakeOpener(redirectUrl: (authorizeUrl: string) => string): Opener {
return { async open(authorizeUrl) { return redirectUrl(authorizeUrl); } };
}
const client = authClientFor('office');
describe('login', () => {
afterEach(() => vi.unstubAllGlobals());
it('runs PKCE → authorize → exchange → store', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => ({ access_token: 'AT', refresh_token: 'RT', expires_in: 3600 }) } as any)));
const store = memStore();
// Echo the state from the authorize URL back in the redirect so it matches.
const opener = fakeOpener((authUrl) => {
const state = new URL(authUrl).searchParams.get('state');
return `https://office.hanzo.ai/auth-callback.html?code=CODE&state=${state}`;
});
const tokens = await login(client, store, opener);
expect(tokens.accessToken).toBe('AT');
expect(store.value?.accessToken).toBe('AT');
});
it('rejects a state mismatch (CSRF guard)', async () => {
vi.stubGlobal('fetch', vi.fn());
const store = memStore();
const opener = fakeOpener(() => 'https://office.hanzo.ai/auth-callback.html?code=CODE&state=WRONG');
await expect(login(client, store, opener)).rejects.toThrow(/state mismatch/);
expect(store.value).toBeNull();
});
it('surfaces an IAM error param', async () => {
const store = memStore();
const opener = fakeOpener(() => 'https://office.hanzo.ai/auth-callback.html?error=access_denied');
await expect(login(client, store, opener)).rejects.toThrow(/access_denied/);
});
});
describe('isExpired', () => {
it('null / past-deadline is expired; no-deadline is not', () => {
expect(isExpired(null)).toBe(true);
expect(isExpired({ accessToken: 'a' })).toBe(false);
expect(isExpired({ accessToken: 'a', expiresAt: 1000 }, 999_999)).toBe(true);
expect(isExpired({ accessToken: 'a', expiresAt: 10_000_000 }, 1000)).toBe(false);
});
});
describe('getValidToken', () => {
afterEach(() => vi.unstubAllGlobals());
it('returns the token when fresh (no network)', async () => {
const store = memStore({ accessToken: 'FRESH', expiresAt: Date.now() + 3600_000 });
expect(await getValidToken(client, store)).toBe('FRESH');
});
it('refreshes transparently when expired', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => ({ access_token: 'NEW', expires_in: 3600 }) } as any)));
const store = memStore({ accessToken: 'OLD', refreshToken: 'RT', expiresAt: Date.now() - 1 });
expect(await getValidToken(client, store)).toBe('NEW');
expect(store.value?.accessToken).toBe('NEW');
expect(store.value?.refreshToken).toBe('RT'); // reused
});
it('returns empty when refresh fails (revoked) so the surface re-prompts', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 401, text: async () => 'invalid_grant' } as any)));
const store = memStore({ accessToken: 'OLD', refreshToken: 'RT', expiresAt: Date.now() - 1 });
expect(await getValidToken(client, store)).toBe('');
});
it('returns empty when never signed in', async () => {
expect(await getValidToken(client, memStore())).toBe('');
});
});
describe('logout + currentUser', () => {
afterEach(() => vi.unstubAllGlobals());
it('logout clears the store', async () => {
const store = memStore({ accessToken: 'a' });
await logout(store);
expect(store.value).toBeNull();
});
it('currentUser returns null with no session, identity with one', async () => {
expect(await currentUser(client, memStore())).toBeNull();
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => ({ sub: 'u1', email: 'z@hanzo.ai' }) } as any)));
const u = await currentUser(client, memStore({ accessToken: 'AT', expiresAt: Date.now() + 3600_000 }));
expect(u?.email).toBe('z@hanzo.ai');
});
});