Compare commits

...
5 Commits
Author SHA1 Message Date
Hanzo Dev e9531bf4e3 fix(jetbrains): disable buildSearchableOptions — headless libfreetype crash
buildPlugin transitively runs buildSearchableOptions, which launches a headless
IDE (JCEF) needing native font libs (libfreetype.so.6) absent on the CI runners
→ UnsatisfiedLinkError, non-deterministic (only green when the task was cache
UP-TO-DATE, which is why ci.yml passed but the release build failed). The search
index is optional and rebuilt by the IDE at runtime; disabling it makes
buildPlugin reliably emit the distributable plugin zip for the GitHub Release.
2026-07-03 15:47:49 -07:00
Hanzo Dev 46daa52d43 chore(release): sync all packages to 1.9.29 for full all-platform release
Bumps root + browser + vscode + office + jetbrains to 1.9.29 so every release
asset name matches the tag (the download-table URLs use the tag version). The
first release to carry a downloadable JetBrains plugin binary alongside browser,
VS Code/Cursor/Windsurf/Antigravity, Claude, Safari and Office.
2026-07-03 15:39:46 -07:00
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
Hanzo Dev 5bf8d934b4 ci(release): downloadable JetBrains + Office + Safari binaries for all platforms
The GitHub Release table advertised JetBrains and Safari downloads that 404'd —
neither job produced a release artifact — and the Office add-in was absent.
Make every advertised binary real and deterministic from the tag:

- jetbrains: always `gradlew buildPlugin` → hanzo-ai-jetbrains-v<ver>.zip artifact
  (Marketplace publish stays token-gated); the job no longer skips without the
  token, so the download link always resolves.
- office: new job builds @hanzo/office-addin → hanzo-ai-office-v<ver>.zip.
- safari: package the built .app(s) → hanzo-ai-safari-v<ver>.zip.
- release: add jetbrains+office to `needs`, add all three globs to `files`, and
  add the Office row to the download table. Asset names use the tag version so
  they match the table URLs.

All-platform release: browser (chrome/edge/firefox/safari), IDE (vscode/cursor/
windsurf/antigravity/jetbrains), Claude dxt, and Office (Word/Excel/PowerPoint).
2026-07-03 15:22:58 -07:00
Hanzo Dev 81db870a51 feat(office): streaming responses, live model picker, quick-action chips
Additive enhancements to the Office add-in, keeping the existing ask/requestBody
contract (and its tests) intact:

- chat.ts: askStream (SSE streaming completion → onDelta), streamDelta (pure SSE
  frame parse), listModels (/v1/models → ids); requestBody gains an opt-in stream
  flag (defaults false, so the one-shot ask path is unchanged).
- taskpane: a live model <select> populated from /v1/models (org-scoped by the
  token, refreshed on sign-in), and one-click quick-action chips — Draft,
  Summarize, Explain plainly, Tighten/redline, Continue — that run over the
  selection. Output now streams in live; Insert stays gated on real content.

Built for document-heavy review/drafting (e.g. a law office). Tests: 31 pass
(+6 new: stream flag, streamDelta, listModels). Build OK; my files tsc-clean.
2026-07-03 15:08:32 -07:00
32 changed files with 1099 additions and 290 deletions
+3
View File
@@ -30,6 +30,9 @@ jobs:
- name: Test AI package
run: pnpm --filter @hanzo/ai test
- name: Test auth core
run: pnpm --filter @hanzo/auth test
- name: Test Office add-in
run: pnpm --filter @hanzo/office-addin test
+82 -3
View File
@@ -154,6 +154,29 @@ jobs:
-derivedDataPath dist/safari-build-ios
continue-on-error: true
# Package the built .app(s) so the GitHub Release Safari link resolves — the
# unsigned bundle a developer sideloads (source + built products).
- name: Package Safari for release
working-directory: packages/browser
run: |
VERSION=${GITHUB_REF_NAME#v}
cd dist
# Prefer the built .app products; fall back to the Xcode source project.
APPS=$(find safari-build-macos safari-build-ios -name '*.app' -type d 2>/dev/null || true)
if [ -n "$APPS" ]; then
zip -r -y "/tmp/hanzo-ai-safari-v${VERSION}.zip" $APPS
else
zip -r "/tmp/hanzo-ai-safari-v${VERSION}.zip" "safari/Hanzo AI"
fi
continue-on-error: true
- name: Upload Safari bundle
uses: actions/upload-artifact@v4
with:
name: hanzo-ai-safari
path: /tmp/hanzo-ai-safari-v*.zip
continue-on-error: true
# ─── VS Code + Cursor + Windsurf + Antigravity + Open VSX ───
vscode:
name: VS Code Marketplace
@@ -243,7 +266,6 @@ jobs:
name: JetBrains
runs-on: hanzo-build-linux-amd64
needs: secrets
if: needs.secrets.outputs.has-jetbrains == 'true'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
@@ -251,18 +273,64 @@ jobs:
java-version: '17'
distribution: 'temurin'
- name: Build and publish
# Always build the downloadable plugin zip so the GitHub Release link works
# even when the Marketplace token is absent (publish below is token-gated).
- name: Build plugin
working-directory: packages/jetbrains
run: ./gradlew buildPlugin
- name: Package for release
working-directory: packages/jetbrains
run: |
VERSION=${GITHUB_REF_NAME#v}
ZIP=$(ls build/distributions/*.zip | head -1)
cp "$ZIP" "/tmp/hanzo-ai-jetbrains-v${VERSION}.zip"
- name: Upload JetBrains plugin
uses: actions/upload-artifact@v4
with:
name: hanzo-ai-jetbrains
path: /tmp/hanzo-ai-jetbrains-v*.zip
- name: Publish to JetBrains Marketplace
if: needs.secrets.outputs.has-jetbrains == 'true'
working-directory: packages/jetbrains
run: ./gradlew publishPlugin
env:
PUBLISH_TOKEN: ${{ secrets.JETBRAINS_TOKEN }}
continue-on-error: true
# ─── Microsoft Office add-in (Word / Excel / PowerPoint) ───
office:
name: Office Add-in
runs-on: hanzo-build-linux-amd64
needs: secrets
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- name: Build + package
run: |
VERSION=${GITHUB_REF_NAME#v}
pnpm --filter @hanzo/office-addin build
cd packages/office/dist && npx --yes bestzip "/tmp/hanzo-ai-office-v${VERSION}.zip" .
- name: Upload Office add-in
uses: actions/upload-artifact@v4
with:
name: hanzo-ai-office
path: /tmp/hanzo-ai-office-v*.zip
# ─── GitHub Release with downloadable artifacts ───
release:
name: GitHub Release
runs-on: hanzo-build-linux-amd64
needs: [browser, safari, vscode]
needs: [browser, safari, vscode, jetbrains, office]
if: startsWith(github.ref, 'refs/tags/v')
steps:
- uses: actions/checkout@v4
@@ -291,6 +359,14 @@ jobs:
| **Claude Desktop/Code** | Windows / macOS / Linux | [`hanzo-ai-claude-v${{ steps.version.outputs.version }}.dxt`](https://github.com/hanzoai/extension/releases/download/${{ github.ref_name }}/hanzo-ai-claude-v${{ steps.version.outputs.version }}.dxt) |
| **JetBrains** | Windows / macOS / Linux | [`hanzo-ai-jetbrains-v${{ steps.version.outputs.version }}.zip`](https://github.com/hanzoai/extension/releases/download/${{ github.ref_name }}/hanzo-ai-jetbrains-v${{ steps.version.outputs.version }}.zip) |
## Microsoft Office
| App | Platform | Download |
|-----|----------|----------|
| **Word · Excel · PowerPoint** | Windows / macOS / Web / iPad | [`hanzo-ai-office-v${{ steps.version.outputs.version }}.zip`](https://github.com/hanzoai/extension/releases/download/${{ github.ref_name }}/hanzo-ai-office-v${{ steps.version.outputs.version }}.zip) |
> Office: unzip and sideload `manifest.xml` via the M365 admin center, or Insert → Add-ins → Upload My Add-in.
## Browser Extensions
| Browser | Platform | Download |
@@ -308,3 +384,6 @@ jobs:
files: |
artifacts/hanzo-ai-browser/*
artifacts/hanzo-ai-ide/*
artifacts/hanzo-ai-jetbrains/*
artifacts/hanzo-ai-office/*
artifacts/hanzo-ai-safari/*
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hanzo/extension",
"version": "1.9.28",
"version": "1.9.29",
"private": true,
"description": "Hanzo AI Extension",
"license": "MIT",
+20
View File
@@ -0,0 +1,20 @@
{
"name": "@hanzo/auth",
"version": "1.0.0",
"description": "The one Hanzo IAM auth core — canonical HIP-0111 OAuth2 + PKCE, shared by every Hanzo extension (browser, office, vscode, cli, mcp).",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^5.8.3",
"vitest": "^3.2.6"
},
"license": "MIT"
}
+53
View File
@@ -0,0 +1,53 @@
// The ONE definition of Hanzo IAM endpoints and the per-surface OAuth client
// registry. Every Hanzo extension (browser, office, vscode, cli, mcp) resolves
// its auth URLs HERE — so a path can never drift between five hand-rolled
// copies again (the vscode add-in shipped `hanzo.id/oauth/authorize`, missing
// the /v1/iam prefix, for exactly that reason).
//
// HIP-0111: the canonical OAuth2/OIDC surface is `${issuer}/v1/iam/oauth/*`.
// The login UI (authorize) lives on hanzo.id; the token/userinfo API on
// iam.hanzo.ai. Both carry the SAME /v1/iam prefix — that is the whole point.
export const IAM_LOGIN_URL = 'https://hanzo.id';
export const IAM_API_URL = 'https://iam.hanzo.ai';
export const IAM_API_PATH = '/v1/iam';
export const DEFAULT_SCOPES = 'openid profile email offline_access';
// SurfaceClient is one registered IAM OAuth client — one per product surface, so
// tokens, redirect URIs, and audit trails are attributable to the surface that
// minted them. redirectUri is where IAM sends the code back; it must be
// registered on the IAM client of the same id. Adding a surface is ONE entry
// here, never a new copy of the flow.
export interface SurfaceClient {
id: string;
redirectUri: string;
}
// CLIENTS is the registry. The ids match the applications seeded in Hanzo IAM
// (<org>-<app> convention). redirectUris match what those clients allow.
export const CLIENTS = {
browser: { id: 'hanzo-browser', redirectUri: 'https://hanzo.ai/callback' },
office: { id: 'hanzo-office', redirectUri: 'https://office.hanzo.ai/auth-callback.html' },
vscode: { id: 'hanzo-vscode', redirectUri: 'https://hanzo.ai/callback' },
cli: { id: 'hanzo-cli', redirectUri: 'http://127.0.0.1/callback' },
} as const satisfies Record<string, SurfaceClient>;
export type SurfaceName = keyof typeof CLIENTS;
// URL builders — the only place these paths are assembled. issuer defaults to
// the login host for authorize and the API host for token/userinfo, but both
// are overridable (self-hosted brands, tests) via the opts.
export function authorizeURL(issuer: string = IAM_LOGIN_URL): string {
return `${trimEnd(issuer)}${IAM_API_PATH}/oauth/authorize`;
}
export function tokenURL(issuer: string = IAM_API_URL): string {
return `${trimEnd(issuer)}${IAM_API_PATH}/oauth/token`;
}
export function userinfoURL(issuer: string = IAM_API_URL): string {
return `${trimEnd(issuer)}${IAM_API_PATH}/oauth/userinfo`;
}
function trimEnd(s: string): string {
return s.replace(/\/+$/, '');
}
+103
View File
@@ -0,0 +1,103 @@
// The auth FLOW — written exactly once, against TokenStore + Opener. This is the
// decomplected heart: browser / office / vscode / cli call these functions with
// their own adapter and get identical login, refresh, and session semantics. No
// surface re-implements PKCE, endpoint assembly, code exchange, or refresh.
import { createPkce } from './pkce.js';
import {
buildAuthorizeURL, exchangeCode, refreshTokens, fetchUserInfo, parseCallback,
type UserInfo,
} from './oauth.js';
import type { TokenStore, TokenSet, Opener } from './types.js';
export interface AuthClient {
clientId: string;
redirectUri: string;
scope?: string;
loginIssuer?: string;
apiIssuer?: string;
}
// EXPIRY_SKEW_MS refreshes a token slightly before it truly expires, so a call
// never races the deadline.
const EXPIRY_SKEW_MS = 60_000;
// login runs the full interactive authorization-code + PKCE flow and persists
// the tokens. Returns the TokenSet. Throws on user-cancel, state mismatch, an
// IAM error param, or a failed exchange — the surface shows the message.
export async function login(client: AuthClient, store: TokenStore, opener: Opener): Promise<TokenSet> {
const pkce = await createPkce();
const authorizeUrl = buildAuthorizeURL({
clientId: client.clientId,
redirectUri: client.redirectUri,
challenge: pkce.challenge,
state: pkce.state,
scope: client.scope,
loginIssuer: client.loginIssuer,
});
const redirectUrl = await opener.open(authorizeUrl, client.redirectUri);
const cb = parseCallback(redirectUrl);
if (cb.error) throw new Error(`sign-in failed: ${cb.error}`);
if (!cb.code) throw new Error('sign-in returned no authorization code');
if (cb.state !== pkce.state) throw new Error('sign-in state mismatch (possible CSRF) — try again');
const tokens = await exchangeCode({
clientId: client.clientId,
code: cb.code,
verifier: pkce.verifier,
redirectUri: client.redirectUri,
apiIssuer: client.apiIssuer,
});
await store.set(tokens);
return tokens;
}
// isExpired reports whether a TokenSet is at/near its deadline. A token with no
// expiresAt is treated as non-expiring (the surface still validates server-side
// on 401). Pure.
export function isExpired(tokens: TokenSet | null, now: number = Date.now()): boolean {
if (!tokens) return true;
if (tokens.expiresAt === undefined) return false;
return now >= tokens.expiresAt - EXPIRY_SKEW_MS;
}
// getValidToken returns a usable access token, transparently refreshing an
// expired one when a refresh_token is present. Returns '' when there is no
// session (never signed in) or the refresh failed — the caller then either runs
// login() or proceeds anonymously. This is the ONE function every surface calls
// before an authenticated request.
export async function getValidToken(client: AuthClient, store: TokenStore): Promise<string> {
const current = await store.get();
if (!current) return '';
if (!isExpired(current)) return current.accessToken;
if (!current.refreshToken) return current.accessToken; // no refresh path; let the server 401
try {
const next = await refreshTokens({
clientId: client.clientId,
refreshToken: current.refreshToken,
apiIssuer: client.apiIssuer,
});
await store.set(next);
return next.accessToken;
} catch {
return ''; // refresh failed (revoked/expired) — surface re-prompts login
}
}
// logout clears the stored session.
export async function logout(store: TokenStore): Promise<void> {
await store.clear();
}
// currentUser returns the signed-in identity for onboarding UIs, or null when
// there is no valid session. Refreshes if needed.
export async function currentUser(client: AuthClient, store: TokenStore): Promise<UserInfo | null> {
const token = await getValidToken(client, store);
if (!token) return null;
try {
return await fetchUserInfo(token, client.apiIssuer);
} catch {
return null;
}
}
+21
View File
@@ -0,0 +1,21 @@
// @hanzo/auth — the ONE Hanzo IAM auth core. Every extension (browser, office,
// vscode, cli, mcp) imports from here: canonical HIP-0111 endpoints, PKCE, the
// OAuth2 authorization-code + PKCE flow, refresh, and userinfo. A surface
// supplies only a TokenStore (persistence) and an Opener (the interactive leg);
// the flow itself lives once, in flow.ts.
export * from './config.js';
export * from './pkce.js';
export * from './oauth.js';
export * from './flow.js';
export type { TokenSet, TokenStore, Opener } from './types.js';
// Convenience: resolve a surface's AuthClient from the CLIENTS registry so a
// caller writes `authClientFor('office')` instead of assembling ids/redirects.
import { CLIENTS, type SurfaceName } from './config.js';
import type { AuthClient } from './flow.js';
export function authClientFor(surface: SurfaceName, overrides: Partial<AuthClient> = {}): AuthClient {
const c = CLIENTS[surface];
return { clientId: c.id, redirectUri: c.redirectUri, ...overrides };
}
+133
View File
@@ -0,0 +1,133 @@
// The OAuth2/OIDC HTTP calls — pure functions over `fetch`. No storage, no UI,
// no runtime specifics. Every authorize URL and token exchange in the Hanzo
// extension family is built HERE, against config.ts, so HIP-0111 paths are
// assembled exactly once.
import { authorizeURL, tokenURL, userinfoURL, DEFAULT_SCOPES, IAM_LOGIN_URL, IAM_API_URL } from './config.js';
import type { TokenSet } from './types.js';
export interface AuthorizeParams {
clientId: string;
redirectUri: string;
challenge: string;
state: string;
scope?: string;
loginIssuer?: string;
}
// buildAuthorizeURL assembles the authorization-code + PKCE (S256) URL.
export function buildAuthorizeURL(p: AuthorizeParams): string {
const q = new URLSearchParams({
response_type: 'code',
client_id: p.clientId,
redirect_uri: p.redirectUri,
scope: p.scope || DEFAULT_SCOPES,
state: p.state,
code_challenge: p.challenge,
code_challenge_method: 'S256',
});
return `${authorizeURL(p.loginIssuer || IAM_LOGIN_URL)}?${q.toString()}`;
}
// normalizeTokens converts an OIDC token response into our TokenSet, turning the
// relative expires_in into an absolute expiresAt deadline (now-based).
export function normalizeTokens(raw: any, now: number = Date.now()): TokenSet {
if (!raw || typeof raw.access_token !== 'string' || !raw.access_token) {
throw new Error('token response missing access_token');
}
const set: TokenSet = { accessToken: raw.access_token };
if (raw.refresh_token) set.refreshToken = raw.refresh_token;
if (raw.id_token) set.idToken = raw.id_token;
if (raw.token_type) set.tokenType = raw.token_type;
if (raw.scope) set.scope = raw.scope;
if (typeof raw.expires_in === 'number') set.expiresAt = now + raw.expires_in * 1000;
return set;
}
// exchangeCode trades an authorization code for tokens (RFC 6749 §4.1.3 +
// PKCE). Form-encoded per the spec — application/json is NOT accepted by IAM.
export async function exchangeCode(args: {
clientId: string;
code: string;
verifier: string;
redirectUri: string;
apiIssuer?: string;
}): Promise<TokenSet> {
const resp = await fetch(tokenURL(args.apiIssuer || IAM_API_URL), {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: args.clientId,
code: args.code,
code_verifier: args.verifier,
redirect_uri: args.redirectUri,
}),
});
if (!resp.ok) {
throw new Error(`token exchange failed: ${resp.status} ${await safeText(resp)}`);
}
return normalizeTokens(await resp.json());
}
// refreshTokens uses a refresh_token to mint a fresh access token. IAM may or
// may not rotate the refresh_token; if it omits one we keep the old.
export async function refreshTokens(args: {
clientId: string;
refreshToken: string;
apiIssuer?: string;
}): Promise<TokenSet> {
const resp = await fetch(tokenURL(args.apiIssuer || IAM_API_URL), {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
client_id: args.clientId,
refresh_token: args.refreshToken,
}),
});
if (!resp.ok) {
throw new Error(`refresh failed: ${resp.status} ${await safeText(resp)}`);
}
const set = normalizeTokens(await resp.json());
if (!set.refreshToken) set.refreshToken = args.refreshToken; // reuse when not rotated
return set;
}
// UserInfo is the OIDC userinfo shape the surfaces render for onboarding.
export interface UserInfo {
sub: string;
name?: string;
email?: string;
org?: string;
picture?: string;
}
export async function fetchUserInfo(accessToken: string, apiIssuer?: string): Promise<UserInfo> {
const resp = await fetch(userinfoURL(apiIssuer || IAM_API_URL), {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!resp.ok) throw new Error(`userinfo failed: ${resp.status}`);
const j = await resp.json();
return { sub: j.sub, name: j.name, email: j.email, org: j.org || j.owner, picture: j.picture };
}
// parseCallback extracts { code, state, error } from the redirect URL IAM lands
// on (the Opener resolves this). Pure.
export function parseCallback(redirectUrl: string): { code?: string; state?: string; error?: string } {
const qi = redirectUrl.indexOf('?');
const params = new URLSearchParams(qi >= 0 ? redirectUrl.slice(qi + 1) : '');
return {
code: params.get('code') || undefined,
state: params.get('state') || undefined,
error: params.get('error') || undefined,
};
}
async function safeText(resp: Response): Promise<string> {
try {
return (await resp.text()).slice(0, 200);
} catch {
return '';
}
}
+36
View File
@@ -0,0 +1,36 @@
// PKCE (RFC 7636) — pure crypto over the platform CSPRNG + WebCrypto, so it runs
// identically in a browser extension, an Office task pane, VS Code's extension
// host, and Node. The ONE PKCE implementation for every Hanzo surface.
export function base64url(bytes: Uint8Array): string {
let bin = '';
for (const b of bytes) bin += String.fromCharCode(b);
const b64 = typeof btoa === 'function' ? btoa(bin) : Buffer.from(bytes).toString('base64');
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
export function randomString(bytes = 32): string {
const buf = new Uint8Array(bytes);
(globalThis.crypto as Crypto).getRandomValues(buf);
return base64url(buf);
}
// challengeFromVerifier = BASE64URL(SHA256(verifier)) — the S256 method.
export async function challengeFromVerifier(verifier: string): Promise<string> {
const data = new TextEncoder().encode(verifier);
const digest = await (globalThis.crypto as Crypto).subtle.digest('SHA-256', data);
return base64url(new Uint8Array(digest));
}
export interface Pkce {
verifier: string;
challenge: string;
state: string;
}
export async function createPkce(): Promise<Pkce> {
const verifier = randomString(32);
const challenge = await challengeFromVerifier(verifier);
const state = randomString(16);
return { verifier, challenge, state };
}
+32
View File
@@ -0,0 +1,32 @@
// The runtime-agnostic contracts a surface implements. The flow (flow.ts) is
// written ONCE against these; browser / office / vscode / cli differ only in the
// adapter they pass. This is what makes "one auth, many surfaces" real.
// TokenSet is the persisted credential — the OIDC token response, normalized.
export interface TokenSet {
accessToken: string;
refreshToken?: string;
idToken?: string;
// expiresAt is an absolute epoch-ms deadline (NOT the relative expires_in),
// so validity is a pure comparison with the clock, storable and portable.
expiresAt?: number;
tokenType?: string;
scope?: string;
}
// TokenStore is the per-surface persistence: chrome.storage (browser),
// SecretStorage (vscode), roamingSettings (office), a 0600 file (cli). Async so
// every backend fits. clear() removes the credential (logout).
export interface TokenStore {
get(): Promise<TokenSet | null>;
set(tokens: TokenSet): Promise<void>;
clear(): Promise<void>;
}
// Opener drives the interactive leg: open the authorize URL and resolve with the
// full redirect URL IAM lands on (carrying ?code=&state=). Browser: a tab +
// tabs.onUpdated. Office: the Dialog API. VS Code: env.openExternal + a Uri
// handler. CLI: a loopback HTTP server. The flow never knows which.
export interface Opener {
open(authorizeUrl: string, redirectUri: string): Promise<string>;
}
+39
View File
@@ -0,0 +1,39 @@
import { describe, it, expect } from 'vitest';
import { authorizeURL, tokenURL, userinfoURL, CLIENTS, IAM_API_PATH } from '../src/config';
import { authClientFor } from '../src/index';
describe('canonical HIP-0111 endpoints — the drift guard', () => {
it('authorize/token/userinfo ALL carry the /v1/iam prefix', () => {
// This is the exact bug that shipped in vscode: hanzo.id/oauth/authorize,
// missing /v1/iam. Pin every path so it can never regress.
expect(authorizeURL()).toBe('https://hanzo.id/v1/iam/oauth/authorize');
expect(tokenURL()).toBe('https://iam.hanzo.ai/v1/iam/oauth/token');
expect(userinfoURL()).toBe('https://iam.hanzo.ai/v1/iam/oauth/userinfo');
expect(IAM_API_PATH).toBe('/v1/iam');
});
it('a custom issuer keeps the /v1/iam prefix', () => {
expect(authorizeURL('https://id.acme.test')).toBe('https://id.acme.test/v1/iam/oauth/authorize');
expect(tokenURL('https://iam.acme.test/')).toBe('https://iam.acme.test/v1/iam/oauth/token');
});
});
describe('surface client registry — one client per surface', () => {
it('every surface has a distinct hanzo-<surface> id', () => {
expect(CLIENTS.browser.id).toBe('hanzo-browser');
expect(CLIENTS.office.id).toBe('hanzo-office');
expect(CLIENTS.vscode.id).toBe('hanzo-vscode');
expect(CLIENTS.cli.id).toBe('hanzo-cli');
const ids = Object.values(CLIENTS).map((c) => c.id);
expect(new Set(ids).size).toBe(ids.length);
});
it('authClientFor resolves id + redirect and honors overrides', () => {
const c = authClientFor('office');
expect(c.clientId).toBe('hanzo-office');
expect(c.redirectUri).toBe('https://office.hanzo.ai/auth-callback.html');
expect(authClientFor('office', { redirectUri: 'https://localhost:3000/cb' }).redirectUri).toBe(
'https://localhost:3000/cb',
);
});
});
+106
View File
@@ -0,0 +1,106 @@
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');
});
});
+88
View File
@@ -0,0 +1,88 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import {
buildAuthorizeURL, normalizeTokens, exchangeCode, refreshTokens, parseCallback, fetchUserInfo,
} from '../src/oauth';
import { challengeFromVerifier } from '../src/pkce';
describe('buildAuthorizeURL', () => {
it('is the HIP-0111 authorize URL with PKCE S256', () => {
const u = new URL(buildAuthorizeURL({
clientId: 'hanzo-office', redirectUri: 'https://office.hanzo.ai/auth-callback.html',
challenge: 'CHAL', state: 'STATE',
}));
expect(u.origin + u.pathname).toBe('https://hanzo.id/v1/iam/oauth/authorize');
expect(u.searchParams.get('response_type')).toBe('code');
expect(u.searchParams.get('client_id')).toBe('hanzo-office');
expect(u.searchParams.get('code_challenge_method')).toBe('S256');
expect(u.searchParams.get('code_challenge')).toBe('CHAL');
expect(u.searchParams.get('state')).toBe('STATE');
});
});
describe('normalizeTokens', () => {
it('turns expires_in into an absolute expiresAt', () => {
const t = normalizeTokens({ access_token: 'a', refresh_token: 'r', expires_in: 3600 }, 1_000_000);
expect(t.accessToken).toBe('a');
expect(t.refreshToken).toBe('r');
expect(t.expiresAt).toBe(1_000_000 + 3600_000);
});
it('throws without an access_token', () => {
expect(() => normalizeTokens({})).toThrow(/access_token/);
});
});
describe('parseCallback', () => {
it('extracts code/state/error from a redirect URL', () => {
expect(parseCallback('https://x/cb?code=abc&state=xyz')).toEqual({ code: 'abc', state: 'xyz', error: undefined });
expect(parseCallback('https://x/cb?error=access_denied').error).toBe('access_denied');
});
});
describe('exchangeCode + refreshTokens (form-encoded, HIP-0111)', () => {
afterEach(() => vi.unstubAllGlobals());
it('POSTs form-urlencoded to the /v1/iam token URL and normalizes', async () => {
const fetchMock = vi.fn(async (url: string, init: any) => {
expect(url).toBe('https://iam.hanzo.ai/v1/iam/oauth/token');
expect(init.headers['Content-Type']).toBe('application/x-www-form-urlencoded');
const body = new URLSearchParams(init.body.toString());
expect(body.get('grant_type')).toBe('authorization_code');
expect(body.get('code_verifier')).toBe('ver');
return { ok: true, json: async () => ({ access_token: 'AT', refresh_token: 'RT', expires_in: 60 }) } as any;
});
vi.stubGlobal('fetch', fetchMock);
const t = await exchangeCode({ clientId: 'hanzo-vscode', code: 'c', verifier: 'ver', redirectUri: 'https://hanzo.ai/callback' });
expect(t.accessToken).toBe('AT');
expect(fetchMock).toHaveBeenCalledOnce();
});
it('refresh reuses the old refresh_token when IAM does not rotate it', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => ({ access_token: 'AT2', expires_in: 60 }) } as any)));
const t = await refreshTokens({ clientId: 'hanzo-cli', refreshToken: 'OLD' });
expect(t.accessToken).toBe('AT2');
expect(t.refreshToken).toBe('OLD');
});
it('surfaces a failed exchange', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 400, text: async () => 'invalid_grant' } as any)));
await expect(exchangeCode({ clientId: 'x', code: 'c', verifier: 'v', redirectUri: 'r' })).rejects.toThrow(/400/);
});
it('userinfo reads sub/email/org', async () => {
vi.stubGlobal('fetch', vi.fn(async (url: string, init: any) => {
expect(url).toBe('https://iam.hanzo.ai/v1/iam/oauth/userinfo');
expect(init.headers.Authorization).toBe('Bearer AT');
return { ok: true, json: async () => ({ sub: 'u1', email: 'z@hanzo.ai', owner: 'acme' }) } as any;
}));
const u = await fetchUserInfo('AT');
expect(u).toEqual({ sub: 'u1', name: undefined, email: 'z@hanzo.ai', org: 'acme', picture: undefined });
});
});
describe('PKCE round-trips with the S256 challenge', () => {
it('RFC 7636 test vector', async () => {
expect(await challengeFromVerifier('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk')).toBe(
'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
);
});
});
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2020", "DOM"],
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*.ts", "tests/**/*.ts"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['tests/**/*.test.ts'],
environment: 'node',
},
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hanzo/browser-extension",
"version": "1.9.28",
"version": "1.9.29",
"description": "Hanzo AI Browser Extension",
"main": "dist/background.js",
"scripts": {
+9
View File
@@ -51,6 +51,15 @@ tasks {
gradleVersion = properties("gradleVersion").get()
}
// buildSearchableOptions launches a headless IDE (JCEF) to pre-index plugin
// settings — it needs native font libs (libfreetype) the CI runners lack, so
// it crashes buildPlugin non-deterministically (only passing when the task is
// cache-UP-TO-DATE). The index is optional; the IDE rebuilds it at runtime.
// Disable it so buildPlugin reliably produces the distributable zip.
buildSearchableOptions {
enabled = false
}
patchPluginXml {
version = properties("pluginVersion")
sinceBuild = properties("pluginSinceBuild")
+1 -1
View File
@@ -4,7 +4,7 @@ pluginGroup = ai.hanzo
pluginName = Hanzo AI
pluginRepositoryUrl = https://github.com/hanzoai/hanzo-ai-jetbrains
# SemVer format -> https://semver.org
pluginVersion = 1.8.0
pluginVersion = 1.9.29
# Supported build number ranges and IntelliJ Platform versions -> https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html
pluginSinceBuild = 233
+5 -2
View File
@@ -1,8 +1,11 @@
{
"name": "@hanzo/office-addin",
"version": "1.0.0",
"description": "Hanzo AI for Microsoft Office Word, Excel, and PowerPoint task pane. AI over your document, wired to the same api.hanzo.ai gateway and hanzo.id IAM as the browser extension.",
"version": "1.9.29",
"description": "Hanzo AI for Microsoft Office \u2014 Word, Excel, and PowerPoint task pane. AI over your document, wired to the same api.hanzo.ai gateway and hanzo.id IAM as the browser extension.",
"private": true,
"dependencies": {
"@hanzo/auth": "workspace:*"
},
"scripts": {
"build": "node src/build.js",
"watch": "node src/build.js --watch",
+101 -98
View File
@@ -1,26 +1,80 @@
// Auth: token storage in Office roaming settings (persists per-user across the
// user's devices) + the IAM authorization-code+PKCE flow run through the Office
// Dialog API. The URL/crypto is in pkce.ts (tested); this is the dialog glue.
// Office's auth = the @hanzo/auth core + two thin Office adapters. The OAuth2 +
// PKCE flow, endpoints, and refresh live ONCE in @hanzo/auth; here we only teach
// it how Office stores tokens (roamingSettings) and how it opens a login window
// (the Dialog API). Plus the office-only API-key path for zero-setup use.
/* global Office */
import { TOKEN_SETTING_KEY, APIKEY_SETTING_KEY, iamTokenURL, CLIENT_ID, pickBearer } from './config.js';
import { createPkce, buildAuthorizeURL } from './pkce.js';
import {
authClientFor, login as coreLogin, logout as coreLogout, getValidToken,
type TokenStore, type TokenSet, type Opener,
} from '@hanzo/auth';
import { APIKEY_SETTING_KEY, TOKEN_SETTING_KEY, pickBearer } from './config.js';
// getToken / setToken / clearToken persist the IAM access token. roamingSettings
// is per-user and roams with the Office identity; it is NOT shared across users
// on a shared machine.
export function getToken(): string {
try {
return (Office.context.roamingSettings.get(TOKEN_SETTING_KEY) as string) || '';
} catch {
return '';
}
// The office IAM client, with the redirect served from the add-in's own origin
// (registered on the hanzo-office IAM client).
function client() {
return authClientFor('office', { redirectUri: `${location.origin}/auth-callback.html` });
}
// getApiKey / setApiKey / clearApiKey persist a pasted Hanzo API key (`hk-…`).
// This is the zero-setup credential — no OAuth client registration required —
// used by the downloadable test kit.
// roamingStore is the Office TokenStore: roamingSettings is per-user and roams
// with the Office identity. The TokenSet is JSON-serialized under one key.
const roamingStore: TokenStore = {
async get(): Promise<TokenSet | null> {
try {
const raw = Office.context.roamingSettings.get(TOKEN_SETTING_KEY) as string;
return raw ? (JSON.parse(raw) as TokenSet) : null;
} catch {
return null;
}
},
set(tokens: TokenSet): Promise<void> {
return saveRoaming(TOKEN_SETTING_KEY, JSON.stringify(tokens));
},
clear(): Promise<void> {
return removeRoaming(TOKEN_SETTING_KEY);
},
};
// dialogOpener is the Office Opener: it opens the authorize URL in an Office
// dialog and resolves with the callback URL the dialog posts back via
// messageParent (auth-callback.html does that post).
const dialogOpener: Opener = {
open(authorizeUrl: string): Promise<string> {
return new Promise((resolve, reject) => {
Office.context.ui.displayDialogAsync(authorizeUrl, { height: 60, width: 30 }, (result) => {
if (result.status !== Office.AsyncResultStatus.Succeeded) {
reject(new Error(result.error?.message || 'could not open login dialog'));
return;
}
const dialog = result.value;
dialog.addEventHandler(Office.EventType.DialogMessageReceived, (arg: any) => {
dialog.close();
resolve(String(arg.message || ''));
});
dialog.addEventHandler(Office.EventType.DialogEventReceived, () => {
reject(new Error('login dialog closed'));
});
});
});
},
};
// signIn / clearToken / getToken delegate to the core with the Office adapters.
export async function signIn(): Promise<string> {
const tokens = await coreLogin(client(), roamingStore, dialogOpener);
return tokens.accessToken;
}
export function clearToken(): Promise<void> {
return coreLogout(roamingStore);
}
// getToken returns a valid (auto-refreshed) access token, or '' if not signed in.
export async function getToken(): Promise<string> {
return getValidToken(client(), roamingStore);
}
// ── API-key path (office-only, zero-setup) ────────────────────────────────
export function getApiKey(): string {
try {
return (Office.context.roamingSettings.get(APIKEY_SETTING_KEY) as string) || '';
@@ -28,101 +82,50 @@ export function getApiKey(): string {
return '';
}
}
export function setApiKey(key: string): Promise<void> {
return new Promise((resolve, reject) => {
Office.context.roamingSettings.set(APIKEY_SETTING_KEY, key.trim());
Office.context.roamingSettings.saveAsync((r) => {
r.status === Office.AsyncResultStatus.Succeeded ? resolve() : reject(new Error('save key failed'));
});
});
return saveRoaming(APIKEY_SETTING_KEY, key.trim());
}
export function clearApiKey(): Promise<void> {
return new Promise((resolve) => {
Office.context.roamingSettings.remove(APIKEY_SETTING_KEY);
Office.context.roamingSettings.saveAsync(() => resolve());
});
return removeRoaming(APIKEY_SETTING_KEY);
}
// bearer is the credential the chat call sends: the pasted API key wins over the
// OAuth token (see pickBearer), else empty for anonymous.
export function bearer(): string {
return pickBearer(getApiKey(), getToken());
// bearer is the credential the chat call sends: a pasted API key wins over the
// OAuth token (see pickBearer). Async because the OAuth token may refresh.
export async function bearer(): Promise<string> {
const key = getApiKey();
if (key) return key;
return getValidToken(client(), roamingStore);
}
export function setToken(token: string): Promise<void> {
// hasApiKey / hasSession are SYNC reflect checks for the pane (no refresh, no
// network) — used only to render which credential is live, not to authorize.
export function hasApiKey(): boolean {
return !!getApiKey();
}
export function hasSession(): boolean {
try {
return !!(Office.context.roamingSettings.get(TOKEN_SETTING_KEY) as string);
} catch {
return false;
}
}
// ── roamingSettings helpers ───────────────────────────────────────────────
function saveRoaming(key: string, value: string): Promise<void> {
return new Promise((resolve, reject) => {
Office.context.roamingSettings.set(TOKEN_SETTING_KEY, token);
Office.context.roamingSettings.set(key, value);
Office.context.roamingSettings.saveAsync((r) => {
r.status === Office.AsyncResultStatus.Succeeded ? resolve() : reject(new Error('save token failed'));
r.status === Office.AsyncResultStatus.Succeeded ? resolve() : reject(new Error('save failed'));
});
});
}
export function clearToken(): Promise<void> {
function removeRoaming(key: string): Promise<void> {
return new Promise((resolve) => {
Office.context.roamingSettings.remove(TOKEN_SETTING_KEY);
Office.context.roamingSettings.remove(key);
Office.context.roamingSettings.saveAsync(() => resolve());
});
}
// redirectURI is the dialog callback served from the add-in's own host (same
// origin as the task pane), registered on the hanzo-office IAM client.
function redirectURI(): string {
return `${location.origin}/auth-callback.html`;
}
// signIn opens the IAM login in an Office dialog, runs PKCE, exchanges the code
// for a token, stores it, and resolves the token. Rejects if the user cancels
// or the exchange fails. The dialog posts its `?code=&state=` back via
// messageParent; auth-callback.html is that trivial page.
export async function signIn(): Promise<string> {
const { verifier, challenge, state } = await createPkce();
const url = buildAuthorizeURL(redirectURI(), challenge, state);
const callback = await openAuthDialog(url);
const params = new URLSearchParams(callback.split('?')[1] || '');
if (params.get('state') !== state) throw new Error('auth state mismatch');
const code = params.get('code');
if (!code) throw new Error(params.get('error') || 'no authorization code');
const resp = await fetch(iamTokenURL(), {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: CLIENT_ID,
code,
code_verifier: verifier,
redirect_uri: redirectURI(),
}),
});
if (!resp.ok) throw new Error(`token exchange failed: ${resp.status}`);
const tok = await resp.json();
const access = tok.access_token as string;
if (!access) throw new Error('no access_token in response');
await setToken(access);
return access;
}
// openAuthDialog wraps the Office Dialog API in a promise that resolves with the
// callback URL the dialog posts back (via Office.context.ui.messageParent).
function openAuthDialog(url: string): Promise<string> {
return new Promise((resolve, reject) => {
Office.context.ui.displayDialogAsync(url, { height: 60, width: 30 }, (result) => {
if (result.status !== Office.AsyncResultStatus.Succeeded) {
reject(new Error(result.error?.message || 'could not open login dialog'));
return;
}
const dialog = result.value;
dialog.addEventHandler(Office.EventType.DialogMessageReceived, (arg: any) => {
dialog.close();
resolve(String(arg.message || ''));
});
dialog.addEventHandler(Office.EventType.DialogEventReceived, () => {
reject(new Error('login dialog closed'));
});
});
});
}
// pickBearer re-export kept for callers/tests that import it from auth.
export { pickBearer };
+78 -4
View File
@@ -2,7 +2,7 @@
// and fully unit-testable (no Office.js, no DOM). taskpane.ts is the thin glue
// that reads the document via Office.js and hands the text here.
import { chatCompletionsURL, DEFAULT_MODEL } from './config.js';
import { chatCompletionsURL, modelsURL, DEFAULT_MODEL } from './config.js';
export interface ChatMessage {
role: 'system' | 'user' | 'assistant';
@@ -35,17 +35,19 @@ export function buildMessages(task: string, selection: string): ChatMessage[] {
export interface ChatOptions {
model?: string;
temperature?: number;
/** Request an SSE stream. Defaults to false (one-shot completion). */
stream?: boolean;
signal?: AbortSignal;
}
// requestBody is the exact JSON the gateway receives. Separated so a test can
// assert the wire shape (model default, stream=false, messages) without a
// network call.
// assert the wire shape (model default, stream flag, messages) without a
// network call. `stream` defaults to false — the one-shot `ask` contract.
export function requestBody(messages: ChatMessage[], opts: ChatOptions = {}): Record<string, unknown> {
return {
model: opts.model || DEFAULT_MODEL,
messages,
stream: false,
stream: opts.stream === true,
...(opts.temperature !== undefined ? { temperature: opts.temperature } : {}),
};
}
@@ -106,3 +108,75 @@ export async function ask(
}
return extractContent(data);
}
// streamDelta pulls the incremental text out of one OpenAI-compatible SSE frame
// payload (the JSON after `data: `). Pure, so the SSE parse is unit-testable.
export function streamDelta(payload: string): string {
const json = JSON.parse(payload);
return json?.choices?.[0]?.delta?.content ?? '';
}
// askStream runs a streaming completion, invoking onDelta with each text chunk
// as it arrives, and resolves with the full concatenated text. Same inputs as
// `ask`; use it for a live-typing pane. Falls back to a thrown error on non-2xx.
export async function askStream(
task: string,
selection: string,
token: string,
onDelta: (text: string) => void,
opts: ChatOptions = {},
): Promise<string> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (token) headers.Authorization = `Bearer ${token}`;
const resp = await fetch(chatCompletionsURL(), {
method: 'POST',
headers,
body: JSON.stringify(requestBody(buildMessages(task, selection), { ...opts, stream: true })),
signal: opts.signal,
});
if (!resp.ok || !resp.body) {
const detail = await resp.text().catch(() => '');
throw new Error(`Hanzo API ${resp.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`);
}
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let full = '';
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith('data:')) continue;
const payload = trimmed.slice(5).trim();
if (payload === '[DONE]') return full;
try {
const delta = streamDelta(payload);
if (delta) {
full += delta;
onDelta(delta);
}
} catch {
// partial SSE frame — wait for the next chunk
}
}
}
return full;
}
// listModels returns the model ids the caller may route to, from /v1/models.
// The endpoint is org-scoped by the bearer; an empty token lists public models.
export async function listModels(token: string): Promise<string[]> {
const headers: Record<string, string> = {};
if (token) headers.Authorization = `Bearer ${token}`;
const resp = await fetch(modelsURL(), { headers });
if (!resp.ok) throw new Error(`Hanzo API ${resp.status}: model list failed`);
const data: any = await resp.json();
const items = data?.data ?? data?.models ?? (Array.isArray(data) ? data : []);
return items
.map((m: any) => (typeof m === 'string' ? m : m?.id))
.filter((id: unknown): id is string => typeof id === 'string' && id.length > 0);
}
+8 -27
View File
@@ -1,28 +1,17 @@
// Canonical Hanzo endpoints the SAME gateway + IAM the browser extension
// uses (packages/browser/src/shared/config.ts). llm.hanzo.ai is dead; every
// model call goes through api.hanzo.ai/v1 (OpenAI-compatible, IAM JWT at the
// gateway). Kept as its own tiny module so the task pane, auth, and tests all
// read one source of truth.
// Office add-in config — ONLY what is office-specific. IAM endpoints, the OAuth
// client, PKCE, and scopes now live in @hanzo/auth (the one auth core); this
// module keeps the api.hanzo.ai model gateway + the roaming-settings keys.
export const API_BASE_URL = 'https://api.hanzo.ai';
export const IAM_LOGIN_URL = 'https://hanzo.id';
export const IAM_API_URL = 'https://iam.hanzo.ai';
export const IAM_API_PATH = '/v1/iam';
// hanzo-office is the registered IAM OAuth client for this add-in. Its
// redirectUris must include the add-in's dialog callback (served from the same
// host as the task pane). Mirrors the browser extension's hanzo-browser client.
export const CLIENT_ID = 'hanzo-office';
export const OAUTH_SCOPES = 'openid profile email';
// Default model. Overridable per-request; the gateway routes it.
export const DEFAULT_MODEL = 'zen-1';
// Roaming-settings keys (Office.context.roamingSettings persists per-user across
// the user's devices). Two auth paths: the IAM OAuth access token, and a pasted
// Hanzo API key (`hk-…`) for zero-setup use — the key needs no registered OAuth
// client, which is why the downloadable Windows test kit uses it.
export const TOKEN_SETTING_KEY = 'hanzo.accessToken';
// the user's devices). Two auth paths: the IAM OAuth token set (managed by
// @hanzo/auth) and a pasted Hanzo API key (`hk-…`) for zero-setup use — the key
// needs no registered OAuth client, which is why the Windows test kit uses it.
export const TOKEN_SETTING_KEY = 'hanzo.tokens';
export const APIKEY_SETTING_KEY = 'hanzo.apiKey';
// pickBearer chooses the credential to send: a pasted API key wins over the
@@ -32,18 +21,10 @@ export function pickBearer(apiKey: string, oauthToken: string): string {
return (apiKey && apiKey.trim()) || (oauthToken && oauthToken.trim()) || '';
}
// chatCompletionsURL / modelsURL / iamTokenURL / iamAuthorizeURL are the exact
// endpoints the add-in calls. Centralized so a path never drifts between the
// task pane and the tests.
// chatCompletionsURL / modelsURL — the model gateway endpoints the add-in calls.
export function chatCompletionsURL(): string {
return `${API_BASE_URL}/v1/chat/completions`;
}
export function modelsURL(): string {
return `${API_BASE_URL}/v1/models`;
}
export function iamAuthorizeURL(): string {
return `${IAM_LOGIN_URL}${IAM_API_PATH}/oauth/authorize`;
}
export function iamTokenURL(): string {
return `${IAM_API_URL}${IAM_API_PATH}/oauth/token`;
}
-58
View File
@@ -1,58 +0,0 @@
// PKCE (RFC 7636) + the IAM authorize URL — pure crypto, no Office, no DOM, so
// it is unit-tested directly. The add-in runs the authorization-code+PKCE flow
// against hanzo.id (HIP-0111) through the Office Dialog API; auth.ts is the thin
// dialog glue over these functions.
import { iamAuthorizeURL, CLIENT_ID, OAUTH_SCOPES } from './config.js';
// base64url encodes bytes without padding (the PKCE + OAuth-state alphabet).
export function base64url(bytes: Uint8Array): string {
let bin = '';
for (const b of bytes) bin += String.fromCharCode(b);
const b64 = typeof btoa === 'function' ? btoa(bin) : Buffer.from(bytes).toString('base64');
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
// randomString returns a base64url token of `bytes` entropy (verifier/state),
// using the platform CSPRNG (browser webcrypto or node webcrypto).
export function randomString(bytes = 32): string {
const buf = new Uint8Array(bytes);
(globalThis.crypto as Crypto).getRandomValues(buf);
return base64url(buf);
}
// challengeFromVerifier is the S256 code_challenge = BASE64URL(SHA256(verifier)).
export async function challengeFromVerifier(verifier: string): Promise<string> {
const data = new TextEncoder().encode(verifier);
const digest = await (globalThis.crypto as Crypto).subtle.digest('SHA-256', data);
return base64url(new Uint8Array(digest));
}
export interface PkcePair {
verifier: string;
challenge: string;
state: string;
}
export async function createPkce(): Promise<PkcePair> {
const verifier = randomString(32);
const challenge = await challengeFromVerifier(verifier);
const state = randomString(16);
return { verifier, challenge, state };
}
// buildAuthorizeURL assembles the hanzo.id authorize URL for the dialog to
// open. redirectUri is the add-in's dialog callback (same host as the task
// pane), which must be registered on the hanzo-office IAM client.
export function buildAuthorizeURL(redirectUri: string, challenge: string, state: string): string {
const q = new URLSearchParams({
response_type: 'code',
client_id: CLIENT_ID,
redirect_uri: redirectUri,
scope: OAUTH_SCOPES,
state,
code_challenge: challenge,
code_challenge_method: 'S256',
});
return `${iamAuthorizeURL()}?${q.toString()}`;
}
+14
View File
@@ -26,15 +26,29 @@
.status.ok { color: #1a7f37; } .status.warn { color: #9a6700; } .status.error { color: #cf222e; }
.spacer { flex: 1; }
footer { margin-top: 10px; font-size: 11px; color: var(--muted); }
select#model { width: auto; max-width: 150px; margin-left: auto; padding: 4px 8px; font-size: 12px; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.chip { padding: 5px 10px; border-radius: 999px; font-size: 13px; background: transparent; color: var(--fg); border: 1px solid var(--line); }
.chip:hover:not(:disabled) { border-color: var(--accent); }
</style>
</head>
<body>
<header>
<img src="assets/icon-32.png" alt="Hanzo" />
<span class="title">Hanzo AI</span>
<select id="model" aria-label="Model"></select>
<span class="host" id="hostname">Office</span>
</header>
<!-- One-click actions over the selection (drafting/review, e.g. a law office). -->
<div class="chips">
<button class="chip" data-task="Continue drafting this document in the same voice and formatting. Return only the continuation.">Draft</button>
<button class="chip" data-task="Summarize the following, preserving parties, dates, defined terms and obligations.">Summarize</button>
<button class="chip" data-task="Explain this in plain, non-jargon language, and note any risks or obligations.">Explain plainly</button>
<button class="chip" data-task="Tighten and clarify this text without changing its legal meaning. Return only the revised text.">Tighten / redline</button>
<button class="chip" data-task="Continue writing from here in the same voice, tense and formatting. Return only the continuation.">Continue</button>
</div>
<label for="prompt">What should Hanzo do with your selection?</label>
<textarea id="prompt" placeholder="e.g. Rewrite this clause in plain English · Summarize the selected rows · Draft a mutual NDA section about confidentiality"></textarea>
+60 -15
View File
@@ -5,9 +5,10 @@
/* global Office, document */
import { ask } from './chat.js';
import { askStream, listModels } from './chat.js';
import { readSelection, insertResult, currentHost, type InsertMode } from './host.js';
import { bearer, getApiKey, setApiKey, clearApiKey, signIn, clearToken, getToken } from './auth.js';
import { bearer, getApiKey, setApiKey, clearApiKey, signIn, clearToken, hasApiKey, hasSession } from './auth.js';
import { DEFAULT_MODEL } from './config.js';
Office.onReady((info) => {
if (
@@ -25,40 +26,79 @@ Office.onReady((info) => {
const outputEl = $('output') as HTMLTextAreaElement;
const insertBtn = $('insert') as HTMLButtonElement;
const modeEl = $('mode') as HTMLSelectElement;
const modelEl = $('model') as HTMLSelectElement;
const signInBtn = $('signin') as HTMLButtonElement;
const signOutBtn = $('signout') as HTMLButtonElement;
const apiKeyEl = $('apikey') as HTMLInputElement;
const saveKeyBtn = $('savekey') as HTMLButtonElement;
const chips = Array.from(document.querySelectorAll<HTMLButtonElement>('.chip'));
$('hostname').textContent = hostLabel(currentHost());
apiKeyEl.value = getApiKey();
reflectAuth();
void populateModels();
let controller: AbortController | null = null;
runBtn.onclick = async () => {
const task = promptEl.value.trim();
// One code path for the Ask button AND the quick-action chips: read the
// selection, stream the completion into the output, enable Insert.
async function runTask(task: string) {
if (!task) {
setStatus('Type an instruction first.', 'warn');
setStatus('Type an instruction or pick an action first.', 'warn');
return;
}
controller?.abort();
controller = new AbortController();
setBusy(true, 'Reading selection…');
outputEl.value = '';
insertBtn.disabled = true;
try {
const selection = await readSelection();
setStatus('Asking Hanzo…');
const result = await ask(task, selection, bearer(), { signal: controller.signal });
outputEl.value = result;
insertBtn.disabled = false;
setStatus('Done — review, then Insert.', 'ok');
await askStream(
task,
selection,
await bearer(),
(delta) => { outputEl.value += delta; outputEl.scrollTop = outputEl.scrollHeight; },
{ model: modelEl.value || DEFAULT_MODEL, signal: controller.signal },
);
insertBtn.disabled = !outputEl.value;
setStatus(outputEl.value ? 'Done — review, then Insert.' : 'No content returned.', outputEl.value ? 'ok' : 'warn');
} catch (e: any) {
if (e?.name === 'AbortError') return;
setStatus(errMessage(e), 'error');
} finally {
setBusy(false);
}
};
}
runBtn.onclick = () => runTask(promptEl.value.trim());
// Quick-action chip: seed the prompt with its preset task, then run it.
for (const chip of chips) {
chip.onclick = () => {
const task = chip.dataset.task ?? '';
promptEl.value = task;
void runTask(task);
};
}
async function populateModels() {
try {
const ids = (await listModels(await bearer())).sort();
if (!ids.length) return;
modelEl.innerHTML = '';
for (const id of ids) {
const opt = document.createElement('option');
opt.value = id; opt.textContent = id;
modelEl.appendChild(opt);
}
modelEl.value = ids.includes(DEFAULT_MODEL) ? DEFAULT_MODEL : ids[0];
} catch {
// Non-fatal — a fixed default still works; leave the picker with its default.
modelEl.innerHTML = `<option value="${DEFAULT_MODEL}">${DEFAULT_MODEL}</option>`;
}
}
insertBtn.onclick = async () => {
const text = outputEl.value;
@@ -77,6 +117,7 @@ Office.onReady((info) => {
await signIn();
setStatus('Signed in.', 'ok');
reflectAuth();
void populateModels(); // a token unlocks the caller's org-specific models
} catch (e: any) {
setStatus(errMessage(e), 'error');
}
@@ -98,20 +139,24 @@ Office.onReady((info) => {
setStatus('API key cleared.', 'ok');
}
reflectAuth();
void populateModels(); // the new key may unlock a different model set
};
function reflectAuth() {
// Signed-in state is either an OAuth token or a saved API key.
const authed = !!bearer();
const oauth = !!getToken();
// Sync reflect only (no refresh/network): signed in via OAuth session or a
// saved API key. bearer()/getToken() do the real (async) resolution at call
// time; this just renders which credential is live.
const oauth = hasSession();
const keyed = hasApiKey();
signInBtn.style.display = oauth ? 'none' : '';
signOutBtn.style.display = oauth ? '' : 'none';
$('authhint').textContent = authed
? 'Ready — using ' + (getApiKey() ? 'your API key.' : 'your Hanzo sign-in.')
$('authhint').textContent = (oauth || keyed)
? 'Ready — using ' + (keyed ? 'your API key.' : 'your Hanzo sign-in.')
: 'Paste a Hanzo API key or sign in to use your models.';
}
function setBusy(busy: boolean, msg?: string) {
runBtn.disabled = busy;
for (const chip of chips) chip.disabled = busy;
if (msg) setStatus(msg);
}
function setStatus(msg: string, kind: 'ok' | 'warn' | 'error' | '' = '') {
+38 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { buildMessages, requestBody, extractContent, ask } from '../src/chat';
import { buildMessages, requestBody, extractContent, ask, streamDelta, listModels } from '../src/chat';
import { DEFAULT_MODEL } from '../src/config';
describe('buildMessages', () => {
@@ -35,6 +35,43 @@ describe('requestBody', () => {
expect(b.model).toBe('zen-pro');
expect(b.temperature).toBe(0.2);
});
it('enables streaming only when opts.stream is true', () => {
expect(requestBody(buildMessages('x', ''), { stream: true }).stream).toBe(true);
expect(requestBody(buildMessages('x', ''), { stream: false }).stream).toBe(false);
});
});
describe('streamDelta', () => {
it('reads choices[0].delta.content from an SSE frame', () => {
expect(streamDelta(JSON.stringify({ choices: [{ delta: { content: 'Hel' } }] }))).toBe('Hel');
});
it('returns empty string for a role-only / empty delta', () => {
expect(streamDelta(JSON.stringify({ choices: [{ delta: { role: 'assistant' } }] }))).toBe('');
expect(streamDelta(JSON.stringify({ choices: [{}] }))).toBe('');
});
});
describe('listModels', () => {
afterEach(() => vi.unstubAllGlobals());
it('returns ids from the /v1/models data array with the bearer', async () => {
const fetchMock = vi.fn(async (url: string, init: any) => {
expect(url).toBe('https://api.hanzo.ai/v1/models');
expect(init.headers.Authorization).toBe('Bearer t');
return { ok: true, json: async () => ({ data: [{ id: 'zen-1' }, { id: 'zen-pro' }] }) } as any;
});
vi.stubGlobal('fetch', fetchMock);
expect(await listModels('t')).toEqual(['zen-1', 'zen-pro']);
});
it('tolerates a bare string array and omits the bearer when unauthenticated', async () => {
const fetchMock = vi.fn(async (_url: string, init: any) => {
expect(init.headers.Authorization).toBeUndefined();
return { ok: true, json: async () => ['a', 'b'] } as any;
});
vi.stubGlobal('fetch', fetchMock);
expect(await listModels('')).toEqual(['a', 'b']);
});
});
describe('extractContent', () => {
+4 -17
View File
@@ -1,30 +1,17 @@
import { describe, it, expect } from 'vitest';
import {
chatCompletionsURL, modelsURL, iamAuthorizeURL, iamTokenURL,
API_BASE_URL, IAM_LOGIN_URL, CLIENT_ID,
} from '../src/config';
import { chatCompletionsURL, modelsURL, API_BASE_URL, pickBearer } from '../src/config';
describe('canonical endpoints', () => {
describe('model gateway endpoints', () => {
it('chat + models go through api.hanzo.ai/v1 (llm.hanzo.ai is dead)', () => {
expect(chatCompletionsURL()).toBe('https://api.hanzo.ai/v1/chat/completions');
expect(modelsURL()).toBe('https://api.hanzo.ai/v1/models');
expect(API_BASE_URL).toBe('https://api.hanzo.ai');
expect(chatCompletionsURL()).not.toContain('llm.hanzo.ai');
});
it('IAM is the canonical HIP-0111 /v1/iam/oauth/* surface', () => {
expect(iamAuthorizeURL()).toBe('https://hanzo.id/v1/iam/oauth/authorize');
expect(iamTokenURL()).toBe('https://iam.hanzo.ai/v1/iam/oauth/token');
expect(IAM_LOGIN_URL).toBe('https://hanzo.id');
});
it('the add-in IAM client is hanzo-office', () => {
expect(CLIENT_ID).toBe('hanzo-office');
});
// IAM endpoints, the hanzo-office client, PKCE, and scopes now live in
// @hanzo/auth and are pinned by its config/oauth tests — not duplicated here.
});
import { pickBearer } from '../src/config';
describe('pickBearer', () => {
it('API key wins over the OAuth token', () => {
expect(pickBearer('hk-abc', 'jwt-xyz')).toBe('hk-abc');
-50
View File
@@ -1,50 +0,0 @@
import { describe, it, expect } from 'vitest';
import { base64url, randomString, challengeFromVerifier, createPkce, buildAuthorizeURL } from '../src/pkce';
describe('base64url', () => {
it('is url-safe and unpadded', () => {
const s = base64url(new Uint8Array([251, 255, 191, 0, 1, 2]));
expect(s).not.toMatch(/[+/=]/);
});
});
describe('randomString', () => {
it('is non-empty and unique per call', () => {
const a = randomString();
const b = randomString();
expect(a.length).toBeGreaterThan(20);
expect(a).not.toBe(b);
});
});
describe('challengeFromVerifier', () => {
it('matches the RFC 7636 S256 test vector', async () => {
// Appendix B: verifier → BASE64URL(SHA256(verifier)) == known challenge.
const verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
const challenge = await challengeFromVerifier(verifier);
expect(challenge).toBe('E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM');
});
});
describe('createPkce', () => {
it('produces a verifier, its S256 challenge, and a state', async () => {
const p = await createPkce();
expect(p.verifier).toBeTruthy();
expect(p.state).toBeTruthy();
expect(await challengeFromVerifier(p.verifier)).toBe(p.challenge);
});
});
describe('buildAuthorizeURL', () => {
it('targets hanzo.id with PKCE S256 and the hanzo-office client', () => {
const url = buildAuthorizeURL('https://office.hanzo.ai/auth-callback.html', 'CHAL', 'STATE');
const u = new URL(url);
expect(u.origin + u.pathname).toBe('https://hanzo.id/v1/iam/oauth/authorize');
expect(u.searchParams.get('response_type')).toBe('code');
expect(u.searchParams.get('client_id')).toBe('hanzo-office');
expect(u.searchParams.get('code_challenge')).toBe('CHAL');
expect(u.searchParams.get('code_challenge_method')).toBe('S256');
expect(u.searchParams.get('state')).toBe('STATE');
expect(u.searchParams.get('redirect_uri')).toBe('https://office.hanzo.ai/auth-callback.html');
});
});
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "hanzo-ide",
"displayName": "Hanzo AI",
"description": "The ultimate meta AI development platform. Manage and run all LLMs and CLI tools (Claude, Codex, Gemini, OpenHands, Aider) in one unified interface. Features MCP integration, async execution, git worktrees, and universal context sync.",
"version": "1.8.0",
"version": "1.9.29",
"publisher": "hanzo-ai",
"private": false,
"license": "MIT",
+26 -11
View File
@@ -5,6 +5,20 @@ import * as os from 'os';
import axios from 'axios';
import { execSync } from 'child_process';
// The canonical HIP-0111 OAuth path prefix. Both the login UI (authorize) and
// the API (token) carry it — this file previously omitted it (hitting
// hanzo.id/oauth/authorize, which 404s), the exact drift @hanzo/auth exists to
// prevent. Kept as a local const here because this MCP-standalone auth compiles
// without the workspace bundler; the value MUST equal @hanzo/auth's IAM_API_PATH.
const IAM_API_PATH = '/v1/iam';
// form() encodes a token-endpoint body as application/x-www-form-urlencoded —
// RFC 6749 requires it; posting a JSON object (axios's default) is rejected by
// IAM. axios serializes URLSearchParams as form and sets the header itself.
function form(body: Record<string, string>): URLSearchParams {
return new URLSearchParams(body);
}
interface AuthToken {
token: string;
refreshToken?: string;
@@ -104,12 +118,14 @@ export class StandaloneAuthManager {
private async refreshToken(refreshToken: string): Promise<AuthToken | null> {
try {
const response = await axios.post(`${this.iamConfig.endpoint}/oauth/token`, {
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: this.iamConfig.clientId,
client_secret: this.iamConfig.clientSecret
});
const response = await axios.post(
`${this.iamConfig.endpoint}${IAM_API_PATH}/oauth/token`,
form({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: this.iamConfig.clientId,
}),
);
if (response.data?.access_token) {
const token: AuthToken = {
@@ -166,11 +182,11 @@ export class StandaloneAuthManager {
const codeVerifier = crypto.randomBytes(32).toString('base64url');
const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url');
const authUrl = new URL(`${this.iamConfig.loginUrl}/oauth/authorize`);
const authUrl = new URL(`${this.iamConfig.loginUrl}${IAM_API_PATH}/oauth/authorize`);
authUrl.searchParams.append('client_id', this.iamConfig.clientId);
authUrl.searchParams.append('response_type', 'code');
authUrl.searchParams.append('redirect_uri', 'http://localhost:8765/callback');
authUrl.searchParams.append('scope', 'read write');
authUrl.searchParams.append('scope', 'openid profile email offline_access');
authUrl.searchParams.append('state', state);
authUrl.searchParams.append('code_challenge', codeChallenge);
authUrl.searchParams.append('code_challenge_method', 'S256');
@@ -247,9 +263,8 @@ export class StandaloneAuthManager {
redirect_uri: 'http://localhost:8765/callback',
code_verifier: codeVerifier,
};
if (clientSecret) {body.client_secret = clientSecret;}
const tokenResp = await axios.post(`${endpoint}/oauth/token`, body);
// Public PKCE client: NO client_secret (the verifier is the proof).
const tokenResp = await axios.post(`${endpoint}${IAM_API_PATH}/oauth/token`, form(body));
const tokens = tokenResp.data;
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(successHtml);
+13
View File
@@ -194,6 +194,15 @@ importers:
specifier: ^3.2.6
version: 3.2.6(@types/node@20.19.9)(jsdom@26.1.0)(terser@5.43.1)
packages/auth:
devDependencies:
typescript:
specifier: ^5.8.3
version: 5.8.3
vitest:
specifier: ^3.2.6
version: 3.2.6(@types/node@20.19.9)(jsdom@26.1.0)(terser@5.43.1)
packages/browser:
dependencies:
'@supabase/supabase-js':
@@ -333,6 +342,10 @@ importers:
version: 5.8.3
packages/office:
dependencies:
'@hanzo/auth':
specifier: workspace:*
version: link:../auth
devDependencies:
'@types/office-js':
specifier: ^1.0.377
+1
View File
@@ -1,6 +1,7 @@
packages:
- 'packages/ai'
- 'packages/aci'
- 'packages/auth'
- 'packages/browser'
- 'packages/dxt'
- 'packages/jetbrains'