From daa1c686227d7a843c76d35b4fa7631225d558fc Mon Sep 17 00:00:00 2001 From: Roberto Oliveros Date: Tue, 6 Oct 2026 15:26:08 -0600 Subject: [PATCH] feat(clio): support regional OAuth hosts and credentials Each Clio region is a separate instance with its own OAuth server and developer app, and tokens are not valid across regions. The module sent every OAuth exchange to app.clio.com with the US app's credentials, so non-US accounts could not connect. - setRegion() now switches the API base URL, OAuth authorize/token URLs and client credentials together; the constructor goes through it - getToken honours params.region so the code is exchanged in the region that issued it (Quo sends { code, region: "ca" }) - new CLIO_{EU,CA,AU}_CLIENT_ID/SECRET env vars; a region without its own credentials falls back to CLIO_CLIENT_ID/SECRET - a saved region of null or '' is treated as US - first unit tests for the package (region routing, exchange, refresh) US behaviour is unchanged. Co-Authored-By: Claude Opus 5.5 --- packages/v1-ready/clio/src/api.ts | 55 ++++++---- packages/v1-ready/clio/src/definition.ts | 25 ++++- packages/v1-ready/clio/src/frigg-core.d.ts | 2 +- packages/v1-ready/clio/src/types.ts | 10 ++ packages/v1-ready/clio/test/regions.test.ts | 114 ++++++++++++++++++++ 5 files changed, 181 insertions(+), 25 deletions(-) create mode 100644 packages/v1-ready/clio/test/regions.test.ts diff --git a/packages/v1-ready/clio/src/api.ts b/packages/v1-ready/clio/src/api.ts index d49843c4..d6e89832 100644 --- a/packages/v1-ready/clio/src/api.ts +++ b/packages/v1-ready/clio/src/api.ts @@ -2,6 +2,7 @@ import { OAuth2Requester, get } from '@friggframework/core'; import crypto from 'crypto'; import { ClioRegion, + ClioClientCredentials, ClioOAuth2Options, ClioResponse, ClioUser, @@ -30,17 +31,14 @@ import { ListMattersParams, } from './types'; -const REGION_BASE_URLS: Record = { - us: 'https://app.clio.com/api/v4', - eu: 'https://eu.app.clio.com/api/v4', - ca: 'https://ca.app.clio.com/api/v4', - au: 'https://au.app.clio.com/api/v4', +// Each region is a separate Clio instance with its own OAuth server and apps +const REGION_HOSTS: Record = { + us: 'https://app.clio.com', + eu: 'https://eu.app.clio.com', + ca: 'https://ca.app.clio.com', + au: 'https://au.app.clio.com', }; -// OAuth always uses US domain regardless of region -// Only the API base URL changes per region -const OAUTH_BASE_URL = 'https://app.clio.com'; - const API_VERSION = '4.0.12'; const CLIO_HEADERS = { @@ -53,7 +51,9 @@ const CLIO_HEADERS_JSON = { }; export class Api extends OAuth2Requester { - region: ClioRegion; + region!: ClioRegion; + defaultCredentials: { client_id: string; client_secret: string }; + regionalCredentials: Partial>; URLs: { whoAmI: string; contacts: string; @@ -77,15 +77,13 @@ export class Api extends OAuth2Requester { constructor(params: ClioOAuth2Options = {}) { super(params); - // Set region for API calls (defaults to 'us') - this.region = get(params, 'region', 'us') as ClioRegion; - this.baseUrl = REGION_BASE_URLS[this.region]; - - // OAuth URLs - ALWAYS use US domain regardless of region - this.authorizationUri = encodeURI( - `${OAUTH_BASE_URL}/oauth/authorize?client_id=${this.client_id}&redirect_uri=${this.redirect_uri}&response_type=code`, - ); - this.tokenUri = `${OAUTH_BASE_URL}/oauth/token`; + this.defaultCredentials = { + client_id: this.client_id, + client_secret: this.client_secret, + }; + this.regionalCredentials = get(params, 'regional_credentials', {}); + // Entities saved without a region (null or '') are US accounts + this.setRegion((get(params, 'region', null) || 'us') as ClioRegion); this.URLs = { whoAmI: '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/users/who_am_i.json', @@ -115,18 +113,29 @@ export class Api extends OAuth2Requester { } /** - * Sets the region and updates the API base URL. - * Note: OAuth URLs remain fixed to US domain (app.clio.com). + * Points the API, OAuth endpoints and client credentials at a region. + * A token issued in one region is not valid in another. */ setRegion(region: ClioRegion): void { - if (!REGION_BASE_URLS[region]) { + const host = REGION_HOSTS[region]; + if (!host) { throw new Error( `Invalid Clio region: ${region}. Must be one of: us, eu, ca, au`, ); } + const regional = this.regionalCredentials[region]; + this.client_id = + regional?.client_id || this.defaultCredentials.client_id; + this.client_secret = + regional?.client_secret || this.defaultCredentials.client_secret; + this.region = region; - this.baseUrl = REGION_BASE_URLS[region]; + this.baseUrl = `${host}/api/v4`; + this.authorizationUri = encodeURI( + `${host}/oauth/authorize?client_id=${this.client_id}&redirect_uri=${this.redirect_uri}&response_type=code`, + ); + this.tokenUri = `${host}/oauth/token`; } // ==================== User ==================== diff --git a/packages/v1-ready/clio/src/definition.ts b/packages/v1-ready/clio/src/definition.ts index e3713926..5aa6c33a 100644 --- a/packages/v1-ready/clio/src/definition.ts +++ b/packages/v1-ready/clio/src/definition.ts @@ -9,13 +9,22 @@ const Definition: FriggModuleAuthDefinition = { moduleName: config.name, modelName: 'Clio', requiredAuthMethods: { - getToken: async (api: Api, params: { code: string }) => { + getToken: async ( + api: Api, + params: { code: string; region?: ClioRegion }, + ) => { const code = get(params, 'code'); if (!code) { throw new Error('Authorization code is required'); } + // The code must be exchanged in the region that issued it + const region = get(params, 'region', null); + if (region) { + api.setRegion(region); + } + try { return await api.getTokenFromCode(code); } catch (error: any) { @@ -141,6 +150,20 @@ const Definition: FriggModuleAuthDefinition = { client_id: process.env.CLIO_CLIENT_ID, client_secret: process.env.CLIO_CLIENT_SECRET, redirect_uri: `${process.env.REDIRECT_URI}/clio`, + regional_credentials: { + eu: { + client_id: process.env.CLIO_EU_CLIENT_ID, + client_secret: process.env.CLIO_EU_CLIENT_SECRET, + }, + ca: { + client_id: process.env.CLIO_CA_CLIENT_ID, + client_secret: process.env.CLIO_CA_CLIENT_SECRET, + }, + au: { + client_id: process.env.CLIO_AU_CLIENT_ID, + client_secret: process.env.CLIO_AU_CLIENT_SECRET, + }, + }, // Note: Clio scopes are configured directly in the Developer Portal, not via OAuth scope. }, }; diff --git a/packages/v1-ready/clio/src/frigg-core.d.ts b/packages/v1-ready/clio/src/frigg-core.d.ts index 0a76732f..ce5211e8 100644 --- a/packages/v1-ready/clio/src/frigg-core.d.ts +++ b/packages/v1-ready/clio/src/frigg-core.d.ts @@ -53,6 +53,6 @@ declare module '@friggframework/core' { uiSchema?: any; }; refreshEntityOptions?: (api: any, options: any) => Promise; - env: Record; + env: Record; } } diff --git a/packages/v1-ready/clio/src/types.ts b/packages/v1-ready/clio/src/types.ts index 06756fc8..7af8559b 100644 --- a/packages/v1-ready/clio/src/types.ts +++ b/packages/v1-ready/clio/src/types.ts @@ -4,10 +4,20 @@ export type ClioRegion = 'us' | 'eu' | 'ca' | 'au'; +export interface ClioClientCredentials { + client_id?: string; + client_secret?: string; +} + export interface ClioOAuth2Options { access_token?: string; refresh_token?: string; region?: ClioRegion; + client_id?: string; + client_secret?: string; + redirect_uri?: string; + // Per-region developer app credentials; unset regions use client_id/secret + regional_credentials?: Partial>; } export interface ClioTokenResponse { diff --git a/packages/v1-ready/clio/test/regions.test.ts b/packages/v1-ready/clio/test/regions.test.ts new file mode 100644 index 00000000..f43b0b16 --- /dev/null +++ b/packages/v1-ready/clio/test/regions.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, vi } from 'vitest'; +import { Api } from '../src/api'; +import { Definition } from '../src/definition'; + +const credentials = { + client_id: 'us-id', + client_secret: 'us-secret', + redirect_uri: 'https://example.com/redirect/clio', + regional_credentials: { + ca: { client_id: 'ca-id', client_secret: 'ca-secret' }, + }, +}; + +function captureTokenPost(api: Api) { + return vi + .spyOn(api, '_post') + .mockResolvedValue({ access_token: 'a', refresh_token: 'r' }); +} + +describe('Clio regions', () => { + it('defaults to the US host and credentials', () => { + const api = new Api(credentials); + + expect(api.region).toBe('us'); + expect(api.baseUrl).toBe('https://app.clio.com/api/v4'); + expect(api.tokenUri).toBe('https://app.clio.com/oauth/token'); + expect(api.client_id).toBe('us-id'); + }); + + it.each([null, ''])('treats a saved region of %j as US', (region) => { + const api = new Api({ ...credentials, region: region as any }); + + expect(api.region).toBe('us'); + expect(api.tokenUri).toBe('https://app.clio.com/oauth/token'); + }); + + it('uses the regional host and credentials for a persisted region', () => { + const api = new Api({ ...credentials, region: 'ca' }); + + expect(api.baseUrl).toBe('https://ca.app.clio.com/api/v4'); + expect(api.tokenUri).toBe('https://ca.app.clio.com/oauth/token'); + expect(api.authorizationUri).toContain( + 'https://ca.app.clio.com/oauth/authorize?client_id=ca-id', + ); + expect(api.client_id).toBe('ca-id'); + expect(api.client_secret).toBe('ca-secret'); + }); + + it('falls back to the default credentials when a region has none', () => { + const api = new Api({ ...credentials, region: 'eu' }); + + expect(api.tokenUri).toBe('https://eu.app.clio.com/oauth/token'); + expect(api.client_id).toBe('us-id'); + }); + + it('restores the default credentials when switching back to US', () => { + const api = new Api({ ...credentials, region: 'ca' }); + api.setRegion('us'); + + expect(api.tokenUri).toBe('https://app.clio.com/oauth/token'); + expect(api.client_id).toBe('us-id'); + expect(api.client_secret).toBe('us-secret'); + }); + + it('rejects an unknown region', () => { + const api = new Api(credentials); + + expect(() => api.setRegion('xx' as any)).toThrow( + 'Invalid Clio region: xx', + ); + }); + + it('exchanges the code in the region sent with it', async () => { + const api = new Api(credentials); + const post = captureTokenPost(api); + + await Definition.requiredAuthMethods.getToken(api, { + code: 'the-code', + region: 'ca', + }); + + const [options] = post.mock.calls[0] as any[]; + expect(options.url).toBe('https://ca.app.clio.com/oauth/token'); + expect(options.body.get('client_id')).toBe('ca-id'); + expect(options.body.get('client_secret')).toBe('ca-secret'); + expect(options.body.get('code')).toBe('the-code'); + expect(api.region).toBe('ca'); + }); + + it('keeps the US exchange when no region is sent', async () => { + const api = new Api(credentials); + const post = captureTokenPost(api); + + await Definition.requiredAuthMethods.getToken(api, { + code: 'the-code', + }); + + const [options] = post.mock.calls[0] as any[]; + expect(options.url).toBe('https://app.clio.com/oauth/token'); + expect(options.body.get('client_id')).toBe('us-id'); + }); + + it('refreshes against the persisted region', async () => { + const api = new Api({ ...credentials, region: 'ca' }); + const post = captureTokenPost(api); + + await (api as any).refreshAccessToken({ refresh_token: 'r' }); + + const [options] = post.mock.calls[0] as any[]; + expect(options.url).toBe('https://ca.app.clio.com/oauth/token'); + expect(options.body.get('client_id')).toBe('ca-id'); + expect(options.body.get('refresh_token')).toBe('r'); + }); +});