Skip to content

Commit 11e0ff0

Browse files
feat(adapter-provider): startAuth + completeAuth for oauth2 adapters (#63)
Adds OAuth2 authorization-code flow to createConnectorAdapterProvider. Hosts pass resolveOAuthClient (env-based by default). 6 new tests. Required by platform-api substrate for end-to-end OAuth. 0.32.0 release. Co-authored-by: Drew Stone <hello@tangle.tools>
1 parent 6a44c9b commit 11e0ff0

4 files changed

Lines changed: 443 additions & 2 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@tangle-network/agent-integrations",
3-
"version": "0.31.0",
3+
"version": "0.32.0",
44
"description": "Vendor-neutral integration contracts and runtime helpers for sandbox and agent apps.",
55
"homepage": "https://github.com/tangle-network/agent-integrations#readme",
66
"repository": {
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
import { describe, expect, it, vi } from 'vitest'
2+
import { createConnectorAdapterProvider } from '../adapter-provider.js'
3+
import { IntegrationError } from '../index.js'
4+
import type { ConnectorAdapter } from '../connectors/types.js'
5+
6+
const OWNER = { type: 'user' as const, id: 'user_42' }
7+
const REDIRECT = 'https://app.example/oauth/callback'
8+
9+
function oauthAdapter(): ConnectorAdapter {
10+
return {
11+
manifest: {
12+
kind: 'demo-oauth',
13+
displayName: 'Demo OAuth',
14+
description: 'Adapter used by adapter-provider OAuth tests.',
15+
auth: {
16+
kind: 'oauth2',
17+
authorizationUrl: 'https://idp.example/authorize',
18+
tokenUrl: 'https://idp.example/token',
19+
scopes: ['read:demo', 'write:demo'],
20+
clientIdEnv: 'DEMO_CLIENT_ID',
21+
clientSecretEnv: 'DEMO_CLIENT_SECRET',
22+
extraAuthParams: { access_type: 'offline' },
23+
},
24+
capabilities: [],
25+
defaultConsistencyModel: 'authoritative',
26+
category: 'other',
27+
},
28+
async test() {
29+
return { ok: true }
30+
},
31+
}
32+
}
33+
34+
function apiKeyAdapter(): ConnectorAdapter {
35+
return {
36+
manifest: {
37+
kind: 'demo-api-key',
38+
displayName: 'Demo API Key',
39+
description: 'Adapter used to verify auth_not_supported branch.',
40+
auth: { kind: 'api-key', hint: 'paste your key' },
41+
capabilities: [],
42+
defaultConsistencyModel: 'authoritative',
43+
category: 'other',
44+
},
45+
async test() {
46+
return { ok: true }
47+
},
48+
}
49+
}
50+
51+
function tokenResponse(body: Record<string, unknown>, status = 200): Response {
52+
return new Response(JSON.stringify(body), {
53+
status,
54+
headers: { 'content-type': 'application/json' },
55+
})
56+
}
57+
58+
describe('createConnectorAdapterProvider OAuth flow', () => {
59+
it('startAuth builds an authorization URL with every required param', async () => {
60+
const provider = createConnectorAdapterProvider({
61+
adapters: [oauthAdapter()],
62+
resolveDataSource: () => ({ kind: 'demo-oauth', id: 'ds_demo' }) as never,
63+
resolveOAuthClient: () => ({ clientId: 'cid_live', clientSecret: 'sec_live' }),
64+
})
65+
66+
const result = await provider.startAuth!({
67+
connectorId: 'demo-oauth',
68+
owner: OWNER,
69+
requestedScopes: [],
70+
redirectUri: REDIRECT,
71+
state: 'state_fixed_for_test',
72+
})
73+
74+
const url = new URL(result.authUrl)
75+
expect(url.origin + url.pathname).toBe('https://idp.example/authorize')
76+
expect(url.searchParams.get('response_type')).toBe('code')
77+
expect(url.searchParams.get('client_id')).toBe('cid_live')
78+
expect(url.searchParams.get('redirect_uri')).toBe(REDIRECT)
79+
expect(url.searchParams.get('scope')).toBe('read:demo write:demo')
80+
expect(url.searchParams.get('state')).toBe('state_fixed_for_test')
81+
expect(url.searchParams.get('access_type')).toBe('offline')
82+
expect(result.providerId).toBe('first-party')
83+
expect(result.connectorId).toBe('demo-oauth')
84+
expect(result.state).toBe('state_fixed_for_test')
85+
})
86+
87+
it('startAuth refuses non-oauth2 (api-key) adapters with auth_not_supported', async () => {
88+
const provider = createConnectorAdapterProvider({
89+
adapters: [apiKeyAdapter()],
90+
resolveDataSource: () => ({ kind: 'demo-api-key', id: 'ds_x' }) as never,
91+
resolveOAuthClient: () => ({ clientId: 'cid', clientSecret: 'sec' }),
92+
})
93+
94+
await expect(
95+
provider.startAuth!({
96+
connectorId: 'demo-api-key',
97+
owner: OWNER,
98+
requestedScopes: [],
99+
redirectUri: REDIRECT,
100+
}),
101+
).rejects.toMatchObject({ code: 'auth_not_supported' })
102+
})
103+
104+
it('startAuth fails with config_missing when resolveOAuthClient returns null', async () => {
105+
const provider = createConnectorAdapterProvider({
106+
adapters: [oauthAdapter()],
107+
resolveDataSource: () => ({ kind: 'demo-oauth', id: 'ds_demo' }) as never,
108+
resolveOAuthClient: () => null,
109+
})
110+
111+
await expect(
112+
provider.startAuth!({
113+
connectorId: 'demo-oauth',
114+
owner: OWNER,
115+
requestedScopes: [],
116+
redirectUri: REDIRECT,
117+
}),
118+
).rejects.toMatchObject({ code: 'config_missing' })
119+
})
120+
121+
it('completeAuth POSTs form-encoded body and returns an active connection', async () => {
122+
const fetchImpl = vi.fn(async (url, init) => {
123+
expect(url).toBe('https://idp.example/token')
124+
expect(init?.method).toBe('POST')
125+
expect((init?.headers as Record<string, string>)['content-type']).toBe(
126+
'application/x-www-form-urlencoded',
127+
)
128+
const body = init?.body as URLSearchParams
129+
expect(body.get('grant_type')).toBe('authorization_code')
130+
expect(body.get('code')).toBe('the_code')
131+
expect(body.get('client_id')).toBe('cid_live')
132+
expect(body.get('client_secret')).toBe('sec_live')
133+
expect(body.get('redirect_uri')).toBe(REDIRECT)
134+
return tokenResponse({
135+
access_token: 'acc_xyz',
136+
refresh_token: 'ref_xyz',
137+
expires_in: 3600,
138+
scope: 'read:demo write:demo',
139+
token_type: 'Bearer',
140+
})
141+
}) as unknown as typeof fetch
142+
143+
const fixedNow = new Date('2026-06-01T12:00:00.000Z')
144+
const provider = createConnectorAdapterProvider({
145+
adapters: [oauthAdapter()],
146+
resolveDataSource: () => ({ kind: 'demo-oauth', id: 'ds_demo' }) as never,
147+
resolveOAuthClient: () => ({ clientId: 'cid_live', clientSecret: 'sec_live' }),
148+
fetchImpl,
149+
now: () => fixedNow,
150+
})
151+
152+
const conn = await provider.completeAuth!({
153+
connectorId: 'demo-oauth',
154+
owner: OWNER,
155+
code: 'the_code',
156+
state: 'state_xyz',
157+
redirectUri: REDIRECT,
158+
})
159+
160+
expect(fetchImpl).toHaveBeenCalledOnce()
161+
expect(conn.owner).toEqual(OWNER)
162+
expect(conn.providerId).toBe('first-party')
163+
expect(conn.connectorId).toBe('demo-oauth')
164+
expect(conn.status).toBe('active')
165+
expect(conn.grantedScopes).toEqual(['read:demo', 'write:demo'])
166+
expect(conn.createdAt).toBe(fixedNow.toISOString())
167+
expect(conn.updatedAt).toBe(fixedNow.toISOString())
168+
expect(conn.expiresAt).toBe(new Date(fixedNow.getTime() + 3600 * 1000).toISOString())
169+
expect(conn.id).toMatch(/^conn_/)
170+
})
171+
172+
it('completeAuth surfaces a provider_failure when the IdP responds non-2xx', async () => {
173+
const fetchImpl = vi.fn(async () =>
174+
new Response('{"error":"invalid_grant"}', {
175+
status: 400,
176+
statusText: 'Bad Request',
177+
headers: { 'content-type': 'application/json' },
178+
}),
179+
) as unknown as typeof fetch
180+
181+
const provider = createConnectorAdapterProvider({
182+
adapters: [oauthAdapter()],
183+
resolveDataSource: () => ({ kind: 'demo-oauth', id: 'ds_demo' }) as never,
184+
resolveOAuthClient: () => ({ clientId: 'cid_live', clientSecret: 'sec_live' }),
185+
fetchImpl,
186+
})
187+
188+
let caught: unknown
189+
try {
190+
await provider.completeAuth!({
191+
connectorId: 'demo-oauth',
192+
owner: OWNER,
193+
code: 'bad_code',
194+
state: 'state_xyz',
195+
redirectUri: REDIRECT,
196+
})
197+
} catch (e) {
198+
caught = e
199+
}
200+
expect(caught).toBeInstanceOf(IntegrationError)
201+
expect((caught as IntegrationError).code).toBe('provider_failure')
202+
// The thrown message MUST NOT leak the client secret.
203+
expect((caught as Error).message).not.toContain('sec_live')
204+
})
205+
206+
it('completeAuth rejects when the token response is missing access_token', async () => {
207+
const fetchImpl = vi.fn(async () => tokenResponse({ token_type: 'Bearer' })) as unknown as typeof fetch
208+
209+
const provider = createConnectorAdapterProvider({
210+
adapters: [oauthAdapter()],
211+
resolveDataSource: () => ({ kind: 'demo-oauth', id: 'ds_demo' }) as never,
212+
resolveOAuthClient: () => ({ clientId: 'cid_live', clientSecret: 'sec_live' }),
213+
fetchImpl,
214+
})
215+
216+
await expect(
217+
provider.completeAuth!({
218+
connectorId: 'demo-oauth',
219+
owner: OWNER,
220+
code: 'the_code',
221+
state: 'state_xyz',
222+
redirectUri: REDIRECT,
223+
}),
224+
).rejects.toMatchObject({ code: 'provider_failure' })
225+
})
226+
})

0 commit comments

Comments
 (0)