Files
zeekay 7f26cb96cd fix(clio): correct OAuth refresh + Custom Action nonce to real Clio v4 contract
Post-review corrections verified against Clio's own developer docs and the
official clio/example-third-party-application, fixing three ways the first
cut would have failed against real Clio:

1. OAuth: removed access_type=offline (a Google-ism Clio ignores — it would
   have yielded NO refresh_token). Clio returns a refresh_token on the
   authorization_code grant by default; added the real redirect_on_decline
   option instead. Wired the refresh path into the SPA (ensureClioToken →
   /oauth/refresh) so a long matter session survives token expiry — the
   refresh apparatus is no longer dead code.

2. Custom Action nonce: the nonce is NOT a locally-verifiable HMAC. It is a
   single-use, 60s code that the app must REPLAY to the Clio API, which
   validates server-side (403 on bad/stale). Removed the wrong local HMAC
   verify (and node:crypto from the service); /custom-action now parses the
   deep-link and redirects into the SPA carrying matter_id + the nonce, and
   getMatter replays it (custom_action_nonce query param) on the first
   authenticated call. Corrected the deep-link params to Clio's actual set
   (custom_action_id/user_id/subject_url/custom_action_nonce; subject_url is
   URL-encoded) — subject_id was never sent.

3. Documents: gate downloads on content_type (isTextExtractable) — PDF/DOCX
   (the bulk of legal docs) would have been fed to the model as mojibake,
   wasting the context budget. Binary docs now contribute name + type only.
   The MIME check matches the subtype so DOCX (whose vendor type contains the
   substring "xml") is correctly binary.

tsc clean, 89 vitest tests pass, esbuild build succeeds; re-verified the
browser bundle has zero secret/crypto refs.
2026-07-03 17:21:01 -07:00

129 lines
4.7 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import {
oauthAuthorizeUrl,
tokenExchange,
tokenRefresh,
parseTokenResponse,
isExpired,
} from '../src/clio-oauth';
import { clioBase } from '../src/config';
const base = clioBase('us');
describe('oauthAuthorizeUrl', () => {
it('builds the authorize URL with all required params', () => {
const url = oauthAuthorizeUrl({
base,
clientId: 'CID',
redirectUri: 'https://clio.hanzo.ai/oauth/callback',
state: 'st4te',
});
const u = new URL(url);
expect(u.origin + u.pathname).toBe('https://app.clio.com/oauth/authorize');
expect(u.searchParams.get('response_type')).toBe('code');
expect(u.searchParams.get('client_id')).toBe('CID');
expect(u.searchParams.get('redirect_uri')).toBe('https://clio.hanzo.ai/oauth/callback');
expect(u.searchParams.get('state')).toBe('st4te');
expect(u.searchParams.get('scope')).toBe('read write');
// Clio returns a refresh_token by default — NO Google-style access_type.
expect(u.searchParams.has('access_type')).toBe(false);
// redirect_on_decline is off unless requested.
expect(u.searchParams.has('redirect_on_decline')).toBe(false);
});
it('honors a custom scope and can opt into redirect_on_decline', () => {
const url = oauthAuthorizeUrl({
base,
clientId: 'CID',
redirectUri: 'https://x/cb',
state: 's',
scope: 'read',
redirectOnDecline: true,
});
const u = new URL(url);
expect(u.searchParams.get('scope')).toBe('read');
expect(u.searchParams.get('redirect_on_decline')).toBe('true');
expect(u.searchParams.has('access_type')).toBe(false);
});
it('targets the eu authorize host when built from the eu base', () => {
const url = oauthAuthorizeUrl({ base: clioBase('eu'), clientId: 'C', redirectUri: 'https://x/cb', state: 's' });
expect(url.startsWith('https://eu.app.clio.com/oauth/authorize?')).toBe(true);
});
});
describe('tokenExchange', () => {
it('shapes an authorization_code POST as urlencoded form', () => {
const req = tokenExchange({
base,
clientId: 'CID',
clientSecret: 'SECRET',
redirectUri: 'https://x/cb',
code: 'auth-code',
});
expect(req.url).toBe('https://app.clio.com/oauth/token');
expect(req.headers['Content-Type']).toBe('application/x-www-form-urlencoded');
expect(req.headers.Accept).toBe('application/json');
const form = new URLSearchParams(req.body);
expect(form.get('grant_type')).toBe('authorization_code');
expect(form.get('code')).toBe('auth-code');
expect(form.get('client_id')).toBe('CID');
expect(form.get('client_secret')).toBe('SECRET');
expect(form.get('redirect_uri')).toBe('https://x/cb');
});
});
describe('tokenRefresh', () => {
it('shapes a refresh_token POST', () => {
const req = tokenRefresh({ base, clientId: 'CID', clientSecret: 'SECRET', refreshToken: 'r3fresh' });
expect(req.url).toBe('https://app.clio.com/oauth/token');
const form = new URLSearchParams(req.body);
expect(form.get('grant_type')).toBe('refresh_token');
expect(form.get('refresh_token')).toBe('r3fresh');
expect(form.get('client_id')).toBe('CID');
expect(form.get('client_secret')).toBe('SECRET');
});
});
describe('parseTokenResponse', () => {
it('computes an absolute expiry from expires_in against injected now', () => {
const now = 1_000_000;
const t = parseTokenResponse(
{ access_token: 'AT', refresh_token: 'RT', token_type: 'bearer', expires_in: 3600 },
now,
);
expect(t.accessToken).toBe('AT');
expect(t.refreshToken).toBe('RT');
expect(t.tokenType).toBe('bearer');
expect(t.expiresAt).toBe(now + 3600 * 1000);
});
it('defaults token_type and tolerates a missing refresh_token', () => {
const t = parseTokenResponse({ access_token: 'AT', expires_in: 60 }, 0);
expect(t.tokenType).toBe('bearer');
expect(t.refreshToken).toBe('');
});
it('throws on an OAuth error payload with the description', () => {
expect(() => parseTokenResponse({ error: 'invalid_grant', error_description: 'bad code' })).toThrow(/bad code/);
});
it('throws when access_token is absent', () => {
expect(() => parseTokenResponse({ token_type: 'bearer' })).toThrow(/no access_token/);
});
});
describe('isExpired', () => {
const t = parseTokenResponse({ access_token: 'AT', expires_in: 3600 }, 0);
it('is false well before expiry', () => {
expect(isExpired(t, 1000)).toBe(false);
});
it('is true past expiry', () => {
expect(isExpired(t, 3600 * 1000 + 1)).toBe(true);
});
it('refreshes early within the skew window', () => {
// 30s before the 3600s expiry, default 60s skew → treated as expired
expect(isExpired(t, 3600 * 1000 - 30_000)).toBe(true);
});
});