diff --git a/packages/v1-ready/clio/src/api.ts b/packages/v1-ready/clio/src/api.ts index d49843c..d6e8983 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 e371392..5aa6c33 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 0a76732..ce5211e 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 06756fc..7af8559 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 0000000..f43b0b1 --- /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'); + }); +});