@baukit/integrations-client 0.7.2 → 0.7.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/src/connection-health.test.ts +118 -0
- package/src/connection-health.ts +171 -0
- package/src/index.ts +3 -0
- package/src/oauth-session.test.ts +302 -0
- package/src/oauth-session.ts +314 -0
- package/src/provider-registry.test.ts +133 -0
- package/src/provider-registry.ts +130 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@baukit/integrations-client",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.4",
|
|
4
4
|
"description": "Provider-neutral connection state, OAuth session coordination, and provider registration for web and native clients.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Patrick Koss",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
},
|
|
31
31
|
"files": [
|
|
32
32
|
"dist",
|
|
33
|
+
"src",
|
|
33
34
|
"README.md",
|
|
34
35
|
"LICENSE"
|
|
35
36
|
],
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
connectionStateFromServer,
|
|
5
|
+
reduceConnectionState,
|
|
6
|
+
type ConnectionState,
|
|
7
|
+
} from './connection-health.js';
|
|
8
|
+
|
|
9
|
+
describe('connection health', () => {
|
|
10
|
+
it.each([
|
|
11
|
+
['healthy', 'connected', ['disconnect']],
|
|
12
|
+
['degraded', 'error', ['retry']],
|
|
13
|
+
['needs_reconnect', 'needs_reconnect', ['reconnect']],
|
|
14
|
+
['failed', 'error', ['retry']],
|
|
15
|
+
['revoked', 'needs_reconnect', ['reconnect']],
|
|
16
|
+
['pending_revocation', 'revocation_pending', ['retry']],
|
|
17
|
+
['disconnected', 'disconnected', ['connect']],
|
|
18
|
+
] as const)('maps server state %s to %s', (serverState, status, actions) => {
|
|
19
|
+
const state = connectionStateFromServer({ state: serverState });
|
|
20
|
+
expect(state.status).toBe(status);
|
|
21
|
+
expect(state.availableActions).toEqual(actions);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('never copies provider diagnostics into the user-facing state', () => {
|
|
25
|
+
const rawProviderError = 'invalid_grant: token=provider-secret';
|
|
26
|
+
const state = connectionStateFromServer({
|
|
27
|
+
state: 'degraded',
|
|
28
|
+
diagnosticCode: 'provider_unavailable',
|
|
29
|
+
providerDiagnostic: rawProviderError,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
expect(state.diagnostic).toEqual({ code: 'provider_unavailable', source: 'server' });
|
|
33
|
+
expect(JSON.stringify(state)).not.toContain(rawProviderError);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it.each([
|
|
37
|
+
'provider failed with account 42',
|
|
38
|
+
'provider-failed',
|
|
39
|
+
'UPSTREAM_FAILURE',
|
|
40
|
+
`a${'b'.repeat(128)}`,
|
|
41
|
+
])('drops unsafe diagnostic code %s', (diagnosticCode) => {
|
|
42
|
+
const state = connectionStateFromServer({
|
|
43
|
+
state: 'failed',
|
|
44
|
+
diagnosticCode,
|
|
45
|
+
providerDiagnostic: diagnosticCode,
|
|
46
|
+
});
|
|
47
|
+
expect(state.diagnostic).toBeUndefined();
|
|
48
|
+
expect(JSON.stringify(state)).not.toContain(diagnosticCode);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('keeps reconnect intent when authorization is cancelled', () => {
|
|
52
|
+
const reconnectable = connectionStateFromServer({ state: 'needs_reconnect' });
|
|
53
|
+
const connecting = reduceConnectionState(reconnectable, { type: 'connect_requested' });
|
|
54
|
+
expect(connecting).toMatchObject({ status: 'connecting', operation: 'reconnect' });
|
|
55
|
+
|
|
56
|
+
const cancelled = reduceConnectionState(connecting, { type: 'auth_cancelled' });
|
|
57
|
+
expect(cancelled).toEqual({
|
|
58
|
+
status: 'needs_reconnect',
|
|
59
|
+
availableActions: ['reconnect'],
|
|
60
|
+
operation: 'reconnect',
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('moves a completed authorization to connected', () => {
|
|
65
|
+
const connecting = reduceConnectionState(disconnected(), { type: 'connect_requested' });
|
|
66
|
+
expect(reduceConnectionState(connecting, { type: 'auth_started' })).toBe(connecting);
|
|
67
|
+
expect(reduceConnectionState(connecting, { type: 'auth_returned' })).toEqual({
|
|
68
|
+
status: 'connected',
|
|
69
|
+
availableActions: ['disconnect'],
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('turns an authorization timeout into a retryable safe error', () => {
|
|
74
|
+
const connecting = reduceConnectionState(disconnected(), { type: 'connect_requested' });
|
|
75
|
+
const timedOut = reduceConnectionState(connecting, {
|
|
76
|
+
type: 'auth_timed_out',
|
|
77
|
+
diagnosticCode: 'oauth_start_timed_out',
|
|
78
|
+
});
|
|
79
|
+
expect(timedOut).toEqual({
|
|
80
|
+
status: 'error',
|
|
81
|
+
availableActions: ['retry'],
|
|
82
|
+
diagnostic: { code: 'oauth_start_timed_out', source: 'client' },
|
|
83
|
+
operation: 'connect',
|
|
84
|
+
});
|
|
85
|
+
expect(reduceConnectionState(timedOut, { type: 'retry' })).toEqual({
|
|
86
|
+
status: 'connecting',
|
|
87
|
+
availableActions: [],
|
|
88
|
+
operation: 'connect',
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('tracks disconnect and pending revocation retries', () => {
|
|
93
|
+
const connected = connectionStateFromServer({ state: 'active' });
|
|
94
|
+
const disconnecting = reduceConnectionState(connected, { type: 'disconnect_requested' });
|
|
95
|
+
expect(disconnecting).toMatchObject({ status: 'disconnecting', operation: 'disconnect' });
|
|
96
|
+
|
|
97
|
+
const pending = reduceConnectionState(disconnecting, { type: 'revocation_pending' });
|
|
98
|
+
expect(pending).toMatchObject({
|
|
99
|
+
status: 'revocation_pending',
|
|
100
|
+
availableActions: ['retry'],
|
|
101
|
+
operation: 'disconnect',
|
|
102
|
+
});
|
|
103
|
+
expect(reduceConnectionState(pending, { type: 'retry' })).toMatchObject({
|
|
104
|
+
status: 'disconnecting',
|
|
105
|
+
operation: 'disconnect',
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('ignores actions that are not available', () => {
|
|
110
|
+
const state = disconnected();
|
|
111
|
+
expect(reduceConnectionState(state, { type: 'disconnect_requested' })).toBe(state);
|
|
112
|
+
expect(reduceConnectionState(state, { type: 'retry' })).toBe(state);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
function disconnected(): ConnectionState {
|
|
117
|
+
return connectionStateFromServer({ state: 'disconnected' });
|
|
118
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
export type ConnectionStatus =
|
|
2
|
+
| 'disconnected'
|
|
3
|
+
| 'connecting'
|
|
4
|
+
| 'connected'
|
|
5
|
+
| 'needs_reconnect'
|
|
6
|
+
| 'disconnecting'
|
|
7
|
+
| 'revocation_pending'
|
|
8
|
+
| 'error';
|
|
9
|
+
|
|
10
|
+
export type ConnectionAction = 'connect' | 'reconnect' | 'disconnect' | 'retry';
|
|
11
|
+
|
|
12
|
+
export type ConnectionOperation = 'connect' | 'reconnect' | 'disconnect';
|
|
13
|
+
|
|
14
|
+
export type ServerConnectionState =
|
|
15
|
+
| 'disconnected'
|
|
16
|
+
| 'active'
|
|
17
|
+
| 'connected'
|
|
18
|
+
| 'healthy'
|
|
19
|
+
| 'degraded'
|
|
20
|
+
| 'needs_reconnect'
|
|
21
|
+
| 'failed'
|
|
22
|
+
| 'revoked'
|
|
23
|
+
| 'pending_revocation';
|
|
24
|
+
|
|
25
|
+
export interface ConnectionDiagnostic {
|
|
26
|
+
readonly code: string;
|
|
27
|
+
readonly source: 'client' | 'server';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ConnectionState {
|
|
31
|
+
readonly status: ConnectionStatus;
|
|
32
|
+
readonly availableActions: readonly ConnectionAction[];
|
|
33
|
+
readonly diagnostic?: ConnectionDiagnostic;
|
|
34
|
+
readonly operation?: ConnectionOperation;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ServerConnectionSnapshot {
|
|
38
|
+
readonly state: ServerConnectionState;
|
|
39
|
+
readonly diagnosticCode?: string | null;
|
|
40
|
+
readonly providerDiagnostic?: unknown;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type ConnectionEvent =
|
|
44
|
+
| { readonly type: 'server_state'; readonly snapshot: ServerConnectionSnapshot }
|
|
45
|
+
| { readonly type: 'connect_requested' }
|
|
46
|
+
| { readonly type: 'auth_started' }
|
|
47
|
+
| { readonly type: 'auth_returned' }
|
|
48
|
+
| { readonly type: 'auth_cancelled' }
|
|
49
|
+
| { readonly type: 'auth_timed_out'; readonly diagnosticCode?: string }
|
|
50
|
+
| { readonly type: 'disconnect_requested' }
|
|
51
|
+
| { readonly type: 'revocation_pending'; readonly diagnosticCode?: string }
|
|
52
|
+
| { readonly type: 'retry' };
|
|
53
|
+
|
|
54
|
+
const MAX_DIAGNOSTIC_CODE_LENGTH = 128;
|
|
55
|
+
const DIAGNOSTIC_CODE_PATTERN = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/;
|
|
56
|
+
|
|
57
|
+
const ACTIONS = {
|
|
58
|
+
disconnected: ['connect'],
|
|
59
|
+
connecting: [],
|
|
60
|
+
connected: ['disconnect'],
|
|
61
|
+
needs_reconnect: ['reconnect'],
|
|
62
|
+
disconnecting: [],
|
|
63
|
+
revocation_pending: ['retry'],
|
|
64
|
+
error: ['retry'],
|
|
65
|
+
} as const satisfies Record<ConnectionStatus, readonly ConnectionAction[]>;
|
|
66
|
+
|
|
67
|
+
export function connectionStateFromServer(snapshot: ServerConnectionSnapshot): ConnectionState {
|
|
68
|
+
const diagnostic = serverDiagnostic(snapshot.diagnosticCode);
|
|
69
|
+
switch (snapshot.state) {
|
|
70
|
+
case 'active':
|
|
71
|
+
case 'connected':
|
|
72
|
+
case 'healthy':
|
|
73
|
+
return makeConnectionState('connected');
|
|
74
|
+
case 'needs_reconnect':
|
|
75
|
+
case 'revoked':
|
|
76
|
+
return makeConnectionState('needs_reconnect', { diagnostic, operation: 'reconnect' });
|
|
77
|
+
case 'degraded':
|
|
78
|
+
case 'failed':
|
|
79
|
+
return makeConnectionState('error', { diagnostic, operation: 'reconnect' });
|
|
80
|
+
case 'pending_revocation':
|
|
81
|
+
return makeConnectionState('revocation_pending', { diagnostic, operation: 'disconnect' });
|
|
82
|
+
case 'disconnected':
|
|
83
|
+
return makeConnectionState('disconnected');
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function reduceConnectionState(
|
|
88
|
+
state: ConnectionState,
|
|
89
|
+
event: ConnectionEvent,
|
|
90
|
+
): ConnectionState {
|
|
91
|
+
switch (event.type) {
|
|
92
|
+
case 'server_state':
|
|
93
|
+
return connectionStateFromServer(event.snapshot);
|
|
94
|
+
case 'connect_requested': {
|
|
95
|
+
const action = state.status === 'needs_reconnect' ? 'reconnect' : 'connect';
|
|
96
|
+
if (!state.availableActions.includes(action)) return state;
|
|
97
|
+
return makeConnectionState('connecting', { operation: action });
|
|
98
|
+
}
|
|
99
|
+
case 'auth_started':
|
|
100
|
+
return state.status === 'connecting'
|
|
101
|
+
? state
|
|
102
|
+
: makeConnectionState('connecting', {
|
|
103
|
+
operation: state.status === 'needs_reconnect' ? 'reconnect' : 'connect',
|
|
104
|
+
});
|
|
105
|
+
case 'auth_returned':
|
|
106
|
+
return makeConnectionState('connected');
|
|
107
|
+
case 'auth_cancelled':
|
|
108
|
+
return state.operation === 'reconnect'
|
|
109
|
+
? makeConnectionState('needs_reconnect', { operation: 'reconnect' })
|
|
110
|
+
: makeConnectionState('disconnected');
|
|
111
|
+
case 'auth_timed_out':
|
|
112
|
+
return makeConnectionState('error', {
|
|
113
|
+
diagnostic: clientDiagnostic(event.diagnosticCode ?? 'oauth_timed_out'),
|
|
114
|
+
operation: state.operation === 'reconnect' ? 'reconnect' : 'connect',
|
|
115
|
+
});
|
|
116
|
+
case 'disconnect_requested':
|
|
117
|
+
return state.availableActions.includes('disconnect')
|
|
118
|
+
? makeConnectionState('disconnecting', { operation: 'disconnect' })
|
|
119
|
+
: state;
|
|
120
|
+
case 'revocation_pending':
|
|
121
|
+
return makeConnectionState('revocation_pending', {
|
|
122
|
+
diagnostic: clientDiagnostic(event.diagnosticCode ?? 'revocation_pending'),
|
|
123
|
+
operation: 'disconnect',
|
|
124
|
+
});
|
|
125
|
+
case 'retry':
|
|
126
|
+
if (!state.availableActions.includes('retry')) return state;
|
|
127
|
+
return state.operation === 'disconnect'
|
|
128
|
+
? makeConnectionState('disconnecting', { operation: 'disconnect' })
|
|
129
|
+
: makeConnectionState('connecting', {
|
|
130
|
+
operation: state.operation === 'reconnect' ? 'reconnect' : 'connect',
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function makeConnectionState(
|
|
136
|
+
status: ConnectionStatus,
|
|
137
|
+
options: {
|
|
138
|
+
readonly diagnostic?: ConnectionDiagnostic | undefined;
|
|
139
|
+
readonly operation?: ConnectionOperation;
|
|
140
|
+
} = {},
|
|
141
|
+
): ConnectionState {
|
|
142
|
+
return {
|
|
143
|
+
status,
|
|
144
|
+
availableActions: ACTIONS[status],
|
|
145
|
+
...(options.diagnostic === undefined ? {} : { diagnostic: options.diagnostic }),
|
|
146
|
+
...(options.operation === undefined ? {} : { operation: options.operation }),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function serverDiagnostic(code: string | null | undefined): ConnectionDiagnostic | undefined {
|
|
151
|
+
return diagnostic(code, 'server');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function clientDiagnostic(code: string | undefined): ConnectionDiagnostic | undefined {
|
|
155
|
+
return diagnostic(code, 'client');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function diagnostic(
|
|
159
|
+
code: string | null | undefined,
|
|
160
|
+
source: ConnectionDiagnostic['source'],
|
|
161
|
+
): ConnectionDiagnostic | undefined {
|
|
162
|
+
if (
|
|
163
|
+
code === null ||
|
|
164
|
+
code === undefined ||
|
|
165
|
+
code.length > MAX_DIAGNOSTIC_CODE_LENGTH ||
|
|
166
|
+
!DIAGNOSTIC_CODE_PATTERN.test(code)
|
|
167
|
+
) {
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
return { code, source };
|
|
171
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
OAuthSessionCoordinator,
|
|
5
|
+
createReturnUrlValidator,
|
|
6
|
+
type BrowserPopupSession,
|
|
7
|
+
type OAuthClock,
|
|
8
|
+
type OAuthInFlightSession,
|
|
9
|
+
type OAuthSessionResult,
|
|
10
|
+
type OAuthSessionStorage,
|
|
11
|
+
} from './oauth-session.js';
|
|
12
|
+
|
|
13
|
+
const RETURN_URL = 'https://app.test/oauth/callback';
|
|
14
|
+
const CALLBACK_URL = `${RETURN_URL}?code=authorization-code&state=nonce-1`;
|
|
15
|
+
|
|
16
|
+
describe('OAuth session coordinator', () => {
|
|
17
|
+
it('reserves a browser popup before waiting for the authorization URL', async () => {
|
|
18
|
+
const authorization = deferred<string>();
|
|
19
|
+
const result = deferred<OAuthSessionResult>();
|
|
20
|
+
const navigate = vi.fn();
|
|
21
|
+
const popup = popupSession(result.promise, { navigate });
|
|
22
|
+
const browser = { openPlaceholder: vi.fn(() => popup) };
|
|
23
|
+
const fixture = coordinator({ browser });
|
|
24
|
+
|
|
25
|
+
const outcome = fixture.value.authorize({
|
|
26
|
+
platform: 'web',
|
|
27
|
+
returnUrl: RETURN_URL,
|
|
28
|
+
createAuthorizationUrl: vi.fn(() => authorization.promise),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
expect(browser.openPlaceholder).toHaveBeenCalledWith({
|
|
32
|
+
placeholderUrl: 'about:blank',
|
|
33
|
+
returnUrl: RETURN_URL,
|
|
34
|
+
windowName: 'baukit-integration-oauth',
|
|
35
|
+
});
|
|
36
|
+
expect(navigate).not.toHaveBeenCalled();
|
|
37
|
+
|
|
38
|
+
authorization.resolve('https://accounts.test/authorize');
|
|
39
|
+
await settle();
|
|
40
|
+
expect(navigate).toHaveBeenCalledWith('https://accounts.test/authorize');
|
|
41
|
+
result.resolve({ type: 'success', returnUrl: CALLBACK_URL });
|
|
42
|
+
|
|
43
|
+
await expect(outcome).resolves.toEqual({
|
|
44
|
+
type: 'success',
|
|
45
|
+
callbackParams: { code: 'authorization-code', state: 'nonce-1' },
|
|
46
|
+
});
|
|
47
|
+
expect(fixture.storage.value).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('runs a native auth session after the authorization URL is ready', async () => {
|
|
51
|
+
const run = vi.fn(() => Promise.resolve({ type: 'success', returnUrl: CALLBACK_URL } as const));
|
|
52
|
+
const native = {
|
|
53
|
+
run,
|
|
54
|
+
cancel: vi.fn(),
|
|
55
|
+
};
|
|
56
|
+
const fixture = coordinator({ native });
|
|
57
|
+
|
|
58
|
+
await expect(
|
|
59
|
+
fixture.value.authorize({
|
|
60
|
+
platform: 'native',
|
|
61
|
+
returnUrl: RETURN_URL,
|
|
62
|
+
createAuthorizationUrl: () => Promise.resolve('https://accounts.test/authorize'),
|
|
63
|
+
}),
|
|
64
|
+
).resolves.toMatchObject({ type: 'success' });
|
|
65
|
+
expect(run).toHaveBeenCalledWith({
|
|
66
|
+
authorizationUrl: 'https://accounts.test/authorize',
|
|
67
|
+
returnUrl: RETURN_URL,
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('returns cancelled and clears its in-flight state', async () => {
|
|
72
|
+
const redirectAuthorize = vi.fn(() => Promise.resolve({ type: 'cancelled' } as const));
|
|
73
|
+
const redirect = {
|
|
74
|
+
authorize: redirectAuthorize,
|
|
75
|
+
};
|
|
76
|
+
const fixture = coordinator({
|
|
77
|
+
browser: { openPlaceholder: () => null },
|
|
78
|
+
redirect,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
await expect(
|
|
82
|
+
fixture.value.authorize({
|
|
83
|
+
platform: 'web',
|
|
84
|
+
returnUrl: RETURN_URL,
|
|
85
|
+
createAuthorizationUrl: () => Promise.resolve('https://accounts.test/authorize'),
|
|
86
|
+
}),
|
|
87
|
+
).resolves.toEqual({ type: 'cancelled' });
|
|
88
|
+
expect(redirectAuthorize).toHaveBeenCalledWith({
|
|
89
|
+
authorizationUrl: 'https://accounts.test/authorize',
|
|
90
|
+
returnUrl: RETURN_URL,
|
|
91
|
+
});
|
|
92
|
+
expect(fixture.storage.value).toBeNull();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('times out a stalled authorization start and closes its reserved popup', async () => {
|
|
96
|
+
const close = vi.fn();
|
|
97
|
+
const navigate = vi.fn();
|
|
98
|
+
const popup = popupSession(Promise.resolve({ type: 'cancelled' }), { close, navigate });
|
|
99
|
+
const fixture = coordinator({ browser: { openPlaceholder: () => popup } });
|
|
100
|
+
const outcome = fixture.value.authorize({
|
|
101
|
+
platform: 'web',
|
|
102
|
+
returnUrl: RETURN_URL,
|
|
103
|
+
createAuthorizationUrl: () => new Promise<string>(() => undefined),
|
|
104
|
+
});
|
|
105
|
+
await settle();
|
|
106
|
+
|
|
107
|
+
fixture.clock.advance(1_000);
|
|
108
|
+
|
|
109
|
+
await expect(outcome).resolves.toEqual({ type: 'timed_out' });
|
|
110
|
+
expect(close).toHaveBeenCalledOnce();
|
|
111
|
+
expect(navigate).not.toHaveBeenCalled();
|
|
112
|
+
expect(fixture.storage.value).toBeNull();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('rejects a return from another origin or path', async () => {
|
|
116
|
+
const fixture = coordinator();
|
|
117
|
+
await expect(
|
|
118
|
+
fixture.value.handleRedirect('https://app.test/oauth/other?state=nonce-1'),
|
|
119
|
+
).resolves.toEqual({ type: 'invalid_return' });
|
|
120
|
+
await expect(
|
|
121
|
+
fixture.value.handleRedirect('https://app.test.evil/oauth/callback?state=nonce-1'),
|
|
122
|
+
).resolves.toEqual({ type: 'invalid_return' });
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('returns storage mismatch when the callback state differs', async () => {
|
|
126
|
+
const storage = new MemoryStorage({
|
|
127
|
+
stateNonce: 'stored-nonce',
|
|
128
|
+
returnUrl: RETURN_URL,
|
|
129
|
+
createdAt: 0,
|
|
130
|
+
});
|
|
131
|
+
const fixture = coordinator({ storage });
|
|
132
|
+
|
|
133
|
+
await expect(fixture.value.handleRedirect(CALLBACK_URL)).resolves.toEqual({
|
|
134
|
+
type: 'storage_mismatch',
|
|
135
|
+
});
|
|
136
|
+
expect(storage.value?.stateNonce).toBe('stored-nonce');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('handles a valid redirect after a same-tab navigation', async () => {
|
|
140
|
+
const storage = new MemoryStorage({
|
|
141
|
+
stateNonce: 'nonce-1',
|
|
142
|
+
returnUrl: RETURN_URL,
|
|
143
|
+
createdAt: 0,
|
|
144
|
+
});
|
|
145
|
+
const fixture = coordinator({ storage });
|
|
146
|
+
|
|
147
|
+
await expect(fixture.value.handleRedirect(CALLBACK_URL)).resolves.toEqual({
|
|
148
|
+
type: 'success',
|
|
149
|
+
callbackParams: { code: 'authorization-code', state: 'nonce-1' },
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('rejects duplicate callback parameters', async () => {
|
|
154
|
+
const storage = new MemoryStorage({
|
|
155
|
+
stateNonce: 'nonce-1',
|
|
156
|
+
returnUrl: RETURN_URL,
|
|
157
|
+
createdAt: 0,
|
|
158
|
+
});
|
|
159
|
+
const fixture = coordinator({ storage });
|
|
160
|
+
|
|
161
|
+
await expect(fixture.value.handleRedirect(`${CALLBACK_URL}&state=nonce-1`)).resolves.toEqual({
|
|
162
|
+
type: 'invalid_return',
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('discards a stale in-flight session before saving a new one', async () => {
|
|
167
|
+
const storage = new MemoryStorage({
|
|
168
|
+
stateNonce: 'old-nonce',
|
|
169
|
+
returnUrl: RETURN_URL,
|
|
170
|
+
createdAt: 0,
|
|
171
|
+
});
|
|
172
|
+
const clock = new ControlledClock(2_000);
|
|
173
|
+
const redirect = { authorize: () => Promise.resolve({ type: 'cancelled' } as const) };
|
|
174
|
+
const fixture = coordinator({ storage, clock, redirect });
|
|
175
|
+
|
|
176
|
+
await fixture.value.authorize({
|
|
177
|
+
platform: 'web',
|
|
178
|
+
returnUrl: RETURN_URL,
|
|
179
|
+
createAuthorizationUrl: () => Promise.resolve('https://accounts.test/authorize'),
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
expect(storage.events.slice(0, 3)).toEqual(['load', 'clear', 'save:nonce-1']);
|
|
183
|
+
expect(storage.value).toBeNull();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('validates an exact custom-scheme authority and allowlisted path', () => {
|
|
187
|
+
const validate = createReturnUrlValidator({
|
|
188
|
+
origin: 'sample-app://oauth',
|
|
189
|
+
paths: ['/callback'],
|
|
190
|
+
});
|
|
191
|
+
expect(validate('sample-app://oauth/callback?state=one')).toBe(true);
|
|
192
|
+
expect(validate('sample-app://other/callback?state=one')).toBe(false);
|
|
193
|
+
expect(validate('sample-app://oauth/callback/extra?state=one')).toBe(false);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
class MemoryStorage implements OAuthSessionStorage {
|
|
198
|
+
public readonly events: string[] = [];
|
|
199
|
+
|
|
200
|
+
public constructor(public value: OAuthInFlightSession | null = null) {}
|
|
201
|
+
|
|
202
|
+
public load(): Promise<OAuthInFlightSession | null> {
|
|
203
|
+
this.events.push('load');
|
|
204
|
+
return Promise.resolve(this.value);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
public save(session: OAuthInFlightSession): Promise<void> {
|
|
208
|
+
this.events.push(`save:${session.stateNonce}`);
|
|
209
|
+
this.value = session;
|
|
210
|
+
return Promise.resolve();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
public clear(): Promise<void> {
|
|
214
|
+
this.events.push('clear');
|
|
215
|
+
this.value = null;
|
|
216
|
+
return Promise.resolve();
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
class ControlledClock implements OAuthClock {
|
|
221
|
+
readonly #timers = new Map<object, { callback: () => void; dueAt: number }>();
|
|
222
|
+
|
|
223
|
+
public constructor(private currentTime = 0) {}
|
|
224
|
+
|
|
225
|
+
public now(): number {
|
|
226
|
+
return this.currentTime;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
public setTimeout(callback: () => void, timeoutMs: number): object {
|
|
230
|
+
const handle = {};
|
|
231
|
+
this.#timers.set(handle, { callback, dueAt: this.currentTime + timeoutMs });
|
|
232
|
+
return handle;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
public clearTimeout(handle: unknown): void {
|
|
236
|
+
if (typeof handle === 'object' && handle !== null) this.#timers.delete(handle);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
public advance(milliseconds: number): void {
|
|
240
|
+
this.currentTime += milliseconds;
|
|
241
|
+
for (const [handle, timer] of this.#timers) {
|
|
242
|
+
if (timer.dueAt > this.currentTime) continue;
|
|
243
|
+
this.#timers.delete(handle);
|
|
244
|
+
timer.callback();
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
type CoordinatorOptions = ConstructorParameters<typeof OAuthSessionCoordinator>[0];
|
|
250
|
+
|
|
251
|
+
function coordinator(
|
|
252
|
+
options: Partial<Omit<CoordinatorOptions, 'storage' | 'clock'>> & {
|
|
253
|
+
readonly storage?: MemoryStorage;
|
|
254
|
+
readonly clock?: ControlledClock;
|
|
255
|
+
} = {},
|
|
256
|
+
) {
|
|
257
|
+
const storage = options.storage ?? new MemoryStorage();
|
|
258
|
+
const clock = options.clock ?? new ControlledClock();
|
|
259
|
+
return {
|
|
260
|
+
storage,
|
|
261
|
+
clock,
|
|
262
|
+
value: new OAuthSessionCoordinator({
|
|
263
|
+
timeoutMs: 1_000,
|
|
264
|
+
createStateNonce: () => 'nonce-1',
|
|
265
|
+
validateReturnUrl: createReturnUrlValidator({
|
|
266
|
+
origin: 'https://app.test',
|
|
267
|
+
paths: ['/oauth/callback'],
|
|
268
|
+
}),
|
|
269
|
+
...options,
|
|
270
|
+
storage,
|
|
271
|
+
clock,
|
|
272
|
+
}),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function popupSession(
|
|
277
|
+
result: Promise<OAuthSessionResult>,
|
|
278
|
+
callbacks: {
|
|
279
|
+
readonly navigate?: (authorizationUrl: string) => void;
|
|
280
|
+
readonly close?: () => void;
|
|
281
|
+
} = {},
|
|
282
|
+
): BrowserPopupSession {
|
|
283
|
+
return {
|
|
284
|
+
result,
|
|
285
|
+
navigate: callbacks.navigate ?? vi.fn(),
|
|
286
|
+
close: callbacks.close ?? vi.fn(),
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function deferred<T>() {
|
|
291
|
+
let resolveValue: (value: T) => void = () => undefined;
|
|
292
|
+
const promise = new Promise<T>((resolve) => {
|
|
293
|
+
resolveValue = resolve;
|
|
294
|
+
});
|
|
295
|
+
return { promise, resolve: resolveValue };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function settle(): Promise<void> {
|
|
299
|
+
await Promise.resolve();
|
|
300
|
+
await Promise.resolve();
|
|
301
|
+
await Promise.resolve();
|
|
302
|
+
}
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
export interface OAuthInFlightSession {
|
|
2
|
+
readonly stateNonce: string;
|
|
3
|
+
readonly returnUrl: string;
|
|
4
|
+
readonly createdAt: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface OAuthSessionStorage {
|
|
8
|
+
load(): Promise<OAuthInFlightSession | null>;
|
|
9
|
+
save(session: OAuthInFlightSession): Promise<void>;
|
|
10
|
+
clear(): Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface OAuthClock {
|
|
14
|
+
now(): number;
|
|
15
|
+
setTimeout(callback: () => void, timeoutMs: number): unknown;
|
|
16
|
+
clearTimeout(handle: unknown): void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type OAuthSessionResult =
|
|
20
|
+
{ readonly type: 'success'; readonly returnUrl: string } | { readonly type: 'cancelled' };
|
|
21
|
+
|
|
22
|
+
export interface BrowserPopupSession {
|
|
23
|
+
readonly result: Promise<OAuthSessionResult>;
|
|
24
|
+
navigate(authorizationUrl: string): void;
|
|
25
|
+
close(): void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface BrowserPopupOpener {
|
|
29
|
+
openPlaceholder(input: {
|
|
30
|
+
readonly placeholderUrl: string;
|
|
31
|
+
readonly returnUrl: string;
|
|
32
|
+
readonly windowName: string;
|
|
33
|
+
}): BrowserPopupSession | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface NativeAuthSessionRunner {
|
|
37
|
+
run(input: {
|
|
38
|
+
readonly authorizationUrl: string;
|
|
39
|
+
readonly returnUrl: string;
|
|
40
|
+
}): Promise<OAuthSessionResult>;
|
|
41
|
+
cancel(): void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface OAuthRedirectHandler {
|
|
45
|
+
authorize(input: {
|
|
46
|
+
readonly authorizationUrl: string;
|
|
47
|
+
readonly returnUrl: string;
|
|
48
|
+
}): Promise<OAuthSessionResult>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface OAuthReturnUrlPolicy {
|
|
52
|
+
readonly origin: string;
|
|
53
|
+
readonly paths: readonly string[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type OAuthReturnUrlValidator = (returnUrl: string) => boolean;
|
|
57
|
+
|
|
58
|
+
export type OAuthSessionOutcome =
|
|
59
|
+
| {
|
|
60
|
+
readonly type: 'success';
|
|
61
|
+
readonly callbackParams: Readonly<Record<string, string>>;
|
|
62
|
+
}
|
|
63
|
+
| { readonly type: 'cancelled' }
|
|
64
|
+
| { readonly type: 'timed_out' }
|
|
65
|
+
| { readonly type: 'invalid_return' }
|
|
66
|
+
| { readonly type: 'storage_mismatch' };
|
|
67
|
+
|
|
68
|
+
export interface OAuthSessionCoordinatorOptions {
|
|
69
|
+
readonly storage: OAuthSessionStorage;
|
|
70
|
+
readonly clock: OAuthClock;
|
|
71
|
+
readonly timeoutMs: number;
|
|
72
|
+
readonly createStateNonce: () => string;
|
|
73
|
+
readonly validateReturnUrl: OAuthReturnUrlValidator;
|
|
74
|
+
readonly browser?: BrowserPopupOpener;
|
|
75
|
+
readonly native?: NativeAuthSessionRunner;
|
|
76
|
+
readonly redirect?: OAuthRedirectHandler;
|
|
77
|
+
readonly placeholderUrl?: string;
|
|
78
|
+
readonly windowName?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface OAuthAuthorizationRequest {
|
|
82
|
+
readonly platform: 'web' | 'native';
|
|
83
|
+
readonly returnUrl: string;
|
|
84
|
+
readonly createAuthorizationUrl: (input: {
|
|
85
|
+
readonly stateNonce: string;
|
|
86
|
+
readonly returnUrl: string;
|
|
87
|
+
}) => Promise<string>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const DEFAULT_PLACEHOLDER_URL = 'about:blank';
|
|
91
|
+
const DEFAULT_WINDOW_NAME = 'baukit-integration-oauth';
|
|
92
|
+
const TIMED_OUT = Symbol('timed_out');
|
|
93
|
+
|
|
94
|
+
export class OAuthSessionCoordinator {
|
|
95
|
+
readonly #options: OAuthSessionCoordinatorOptions;
|
|
96
|
+
|
|
97
|
+
public constructor(options: OAuthSessionCoordinatorOptions) {
|
|
98
|
+
if (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0) {
|
|
99
|
+
throw new TypeError('OAuth timeout must be a positive finite number.');
|
|
100
|
+
}
|
|
101
|
+
this.#options = options;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
public async authorize(request: OAuthAuthorizationRequest): Promise<OAuthSessionOutcome> {
|
|
105
|
+
if (!this.#options.validateReturnUrl(request.returnUrl)) return { type: 'invalid_return' };
|
|
106
|
+
|
|
107
|
+
const stateNonce = this.#options.createStateNonce();
|
|
108
|
+
if (stateNonce.length === 0) throw new TypeError('OAuth state nonce must not be empty.');
|
|
109
|
+
|
|
110
|
+
const popup = request.platform === 'web' ? this.openBrowserPopup(request.returnUrl) : null;
|
|
111
|
+
const operation = this.performAuthorization(request, stateNonce, popup);
|
|
112
|
+
const result = await this.withTimeout(operation, () => {
|
|
113
|
+
popup?.close();
|
|
114
|
+
if (request.platform === 'native') this.#options.native?.cancel();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
if (result === TIMED_OUT) {
|
|
118
|
+
await this.clearIfCurrent(stateNonce);
|
|
119
|
+
return { type: 'timed_out' };
|
|
120
|
+
}
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
public async handleRedirect(returnUrl: string): Promise<OAuthSessionOutcome> {
|
|
125
|
+
return this.completeReturn(returnUrl);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
public async discardStaleSession(): Promise<boolean> {
|
|
129
|
+
const stored = await this.#options.storage.load();
|
|
130
|
+
if (stored === null || !this.isStale(stored)) return false;
|
|
131
|
+
await this.#options.storage.clear();
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private openBrowserPopup(returnUrl: string): BrowserPopupSession | null {
|
|
136
|
+
const browser = this.#options.browser;
|
|
137
|
+
if (browser === undefined) return null;
|
|
138
|
+
return browser.openPlaceholder({
|
|
139
|
+
placeholderUrl: this.#options.placeholderUrl ?? DEFAULT_PLACEHOLDER_URL,
|
|
140
|
+
returnUrl,
|
|
141
|
+
windowName: this.#options.windowName ?? DEFAULT_WINDOW_NAME,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private async performAuthorization(
|
|
146
|
+
request: OAuthAuthorizationRequest,
|
|
147
|
+
stateNonce: string,
|
|
148
|
+
popup: BrowserPopupSession | null,
|
|
149
|
+
): Promise<OAuthSessionOutcome> {
|
|
150
|
+
await this.discardStaleSession();
|
|
151
|
+
await this.#options.storage.save({
|
|
152
|
+
stateNonce,
|
|
153
|
+
returnUrl: request.returnUrl,
|
|
154
|
+
createdAt: this.#options.clock.now(),
|
|
155
|
+
});
|
|
156
|
+
const authorizationUrl = await request.createAuthorizationUrl({
|
|
157
|
+
stateNonce,
|
|
158
|
+
returnUrl: request.returnUrl,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
let sessionResult: OAuthSessionResult;
|
|
162
|
+
if (request.platform === 'native') {
|
|
163
|
+
const native = this.#options.native;
|
|
164
|
+
if (native === undefined) throw new TypeError('Native OAuth runner is required.');
|
|
165
|
+
sessionResult = await native.run({ authorizationUrl, returnUrl: request.returnUrl });
|
|
166
|
+
} else if (popup !== null) {
|
|
167
|
+
popup.navigate(authorizationUrl);
|
|
168
|
+
sessionResult = await popup.result;
|
|
169
|
+
} else {
|
|
170
|
+
const redirect = this.#options.redirect;
|
|
171
|
+
if (redirect === undefined) throw new TypeError('Browser OAuth handler is required.');
|
|
172
|
+
sessionResult = await redirect.authorize({
|
|
173
|
+
authorizationUrl,
|
|
174
|
+
returnUrl: request.returnUrl,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (sessionResult.type === 'cancelled') {
|
|
179
|
+
await this.clearIfCurrent(stateNonce);
|
|
180
|
+
return { type: 'cancelled' };
|
|
181
|
+
}
|
|
182
|
+
return this.completeReturn(sessionResult.returnUrl, stateNonce);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private async completeReturn(
|
|
186
|
+
returnUrl: string,
|
|
187
|
+
expectedStateNonce?: string,
|
|
188
|
+
): Promise<OAuthSessionOutcome> {
|
|
189
|
+
if (!this.#options.validateReturnUrl(returnUrl)) return { type: 'invalid_return' };
|
|
190
|
+
const callbackParams = parseCallbackParams(returnUrl);
|
|
191
|
+
if (callbackParams === null) return { type: 'invalid_return' };
|
|
192
|
+
|
|
193
|
+
const stored = await this.#options.storage.load();
|
|
194
|
+
if (stored === null) return { type: 'storage_mismatch' };
|
|
195
|
+
if (this.isStale(stored)) {
|
|
196
|
+
await this.#options.storage.clear();
|
|
197
|
+
return { type: 'timed_out' };
|
|
198
|
+
}
|
|
199
|
+
if (
|
|
200
|
+
(expectedStateNonce !== undefined && stored.stateNonce !== expectedStateNonce) ||
|
|
201
|
+
callbackParams['state'] !== stored.stateNonce
|
|
202
|
+
) {
|
|
203
|
+
return { type: 'storage_mismatch' };
|
|
204
|
+
}
|
|
205
|
+
if (!sameReturnEndpoint(stored.returnUrl, returnUrl)) return { type: 'invalid_return' };
|
|
206
|
+
|
|
207
|
+
await this.#options.storage.clear();
|
|
208
|
+
return { type: 'success', callbackParams };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private isStale(session: OAuthInFlightSession): boolean {
|
|
212
|
+
const elapsed = this.#options.clock.now() - session.createdAt;
|
|
213
|
+
return elapsed < 0 || elapsed >= this.#options.timeoutMs;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private async clearIfCurrent(stateNonce: string): Promise<void> {
|
|
217
|
+
const stored = await this.#options.storage.load();
|
|
218
|
+
if (stored?.stateNonce === stateNonce) await this.#options.storage.clear();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private async withTimeout<T>(
|
|
222
|
+
operation: Promise<T>,
|
|
223
|
+
onTimeout: () => void,
|
|
224
|
+
): Promise<T | typeof TIMED_OUT> {
|
|
225
|
+
let timer: unknown;
|
|
226
|
+
const timeout = new Promise<typeof TIMED_OUT>((resolve) => {
|
|
227
|
+
timer = this.#options.clock.setTimeout(() => {
|
|
228
|
+
try {
|
|
229
|
+
onTimeout();
|
|
230
|
+
} finally {
|
|
231
|
+
resolve(TIMED_OUT);
|
|
232
|
+
}
|
|
233
|
+
}, this.#options.timeoutMs);
|
|
234
|
+
});
|
|
235
|
+
try {
|
|
236
|
+
return await Promise.race([operation, timeout]);
|
|
237
|
+
} finally {
|
|
238
|
+
this.#options.clock.clearTimeout(timer);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function createReturnUrlValidator(policy: OAuthReturnUrlPolicy): OAuthReturnUrlValidator {
|
|
244
|
+
const origin = parseOrigin(policy.origin);
|
|
245
|
+
const paths = new Set(policy.paths.map(normalizeAllowedPath));
|
|
246
|
+
if (paths.size === 0) throw new TypeError('At least one OAuth return path is required.');
|
|
247
|
+
return (returnUrl) => {
|
|
248
|
+
const parsed = parseUrl(returnUrl);
|
|
249
|
+
return (
|
|
250
|
+
parsed !== null &&
|
|
251
|
+
parsed.username === '' &&
|
|
252
|
+
parsed.password === '' &&
|
|
253
|
+
exactOrigin(parsed) === origin &&
|
|
254
|
+
paths.has(parsed.pathname)
|
|
255
|
+
);
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function parseOrigin(origin: string): string {
|
|
260
|
+
const parsed = parseUrl(origin);
|
|
261
|
+
if (
|
|
262
|
+
parsed?.username !== '' ||
|
|
263
|
+
parsed.password !== '' ||
|
|
264
|
+
(parsed.pathname !== '' && parsed.pathname !== '/') ||
|
|
265
|
+
parsed.search !== '' ||
|
|
266
|
+
parsed.hash !== ''
|
|
267
|
+
) {
|
|
268
|
+
throw new TypeError('OAuth return origin must contain only a scheme and authority.');
|
|
269
|
+
}
|
|
270
|
+
return exactOrigin(parsed);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function normalizeAllowedPath(path: string): string {
|
|
274
|
+
if (!path.startsWith('/') || path.includes('?') || path.includes('#')) {
|
|
275
|
+
throw new TypeError('OAuth return paths must be absolute paths without a query or fragment.');
|
|
276
|
+
}
|
|
277
|
+
const parsed = new URL(path, 'https://baukit.invalid');
|
|
278
|
+
if (parsed.pathname !== path) throw new TypeError('OAuth return paths must be normalized.');
|
|
279
|
+
return path;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function parseCallbackParams(returnUrl: string): Readonly<Record<string, string>> | null {
|
|
283
|
+
const parsed = parseUrl(returnUrl);
|
|
284
|
+
if (parsed === null) return null;
|
|
285
|
+
const values: Record<string, string> = {};
|
|
286
|
+
for (const [key, value] of parsed.searchParams) {
|
|
287
|
+
if (Object.hasOwn(values, key)) return null;
|
|
288
|
+
values[key] = value;
|
|
289
|
+
}
|
|
290
|
+
return Object.freeze(values);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function sameReturnEndpoint(expected: string, actual: string): boolean {
|
|
294
|
+
const expectedUrl = parseUrl(expected);
|
|
295
|
+
const actualUrl = parseUrl(actual);
|
|
296
|
+
return (
|
|
297
|
+
expectedUrl !== null &&
|
|
298
|
+
actualUrl !== null &&
|
|
299
|
+
exactOrigin(expectedUrl) === exactOrigin(actualUrl) &&
|
|
300
|
+
expectedUrl.pathname === actualUrl.pathname
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function exactOrigin(url: URL): string {
|
|
305
|
+
return url.origin === 'null' ? `${url.protocol}//${url.host}` : url.origin;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function parseUrl(value: string): URL | null {
|
|
309
|
+
try {
|
|
310
|
+
return new URL(value);
|
|
311
|
+
} catch {
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { describe, expect, expectTypeOf, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { connectionStateFromServer } from './connection-health.js';
|
|
4
|
+
import { createProviderRegistry } from './provider-registry.js';
|
|
5
|
+
|
|
6
|
+
describe('provider registry', () => {
|
|
7
|
+
it('keeps product data and connectors in registration order', () => {
|
|
8
|
+
const startCalendarOAuth = () => Promise.resolve('calendar');
|
|
9
|
+
const startStorageOAuth = () => Promise.resolve('storage');
|
|
10
|
+
const registry = createProviderRegistry([
|
|
11
|
+
{
|
|
12
|
+
id: 'calendar',
|
|
13
|
+
labelKey: 'integrations.calendar',
|
|
14
|
+
capabilities: ['read_events', 'write_events'],
|
|
15
|
+
connector: { startOAuth: startCalendarOAuth, icon: 'calendar-icon' },
|
|
16
|
+
connection: connectionStateFromServer({ state: 'healthy' }),
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
id: 'storage',
|
|
20
|
+
labelKey: 'integrations.storage',
|
|
21
|
+
capabilities: ['read_files'],
|
|
22
|
+
connector: { startOAuth: startStorageOAuth, icon: 'storage-icon' },
|
|
23
|
+
connection: connectionStateFromServer({ state: 'revoked' }),
|
|
24
|
+
},
|
|
25
|
+
] as const);
|
|
26
|
+
|
|
27
|
+
expect(registry.list().map(({ id }) => id)).toEqual(['calendar', 'storage']);
|
|
28
|
+
expect(registry.get('calendar')).toMatchObject({
|
|
29
|
+
currentState: 'connected',
|
|
30
|
+
availableActions: ['disconnect'],
|
|
31
|
+
});
|
|
32
|
+
expect(registry.get('calendar')?.connector?.startOAuth).toBe(startCalendarOAuth);
|
|
33
|
+
expect(registry.get('storage')).toMatchObject({
|
|
34
|
+
currentState: 'needs_reconnect',
|
|
35
|
+
availableActions: ['reconnect'],
|
|
36
|
+
});
|
|
37
|
+
expect(registry.get('storage')?.capabilities).toEqual(['read_files']);
|
|
38
|
+
expectTypeOf(registry.get('calendar')?.connector?.startOAuth).toEqualTypeOf<
|
|
39
|
+
(() => Promise<string>) | undefined
|
|
40
|
+
>();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('copies caller-owned arrays', () => {
|
|
44
|
+
const capabilities: string[] = ['read'];
|
|
45
|
+
const registry = createProviderRegistry([
|
|
46
|
+
{
|
|
47
|
+
id: 'documents',
|
|
48
|
+
labelKey: 'integrations.documents',
|
|
49
|
+
capabilities,
|
|
50
|
+
connection: connectionStateFromServer({ state: 'healthy' }),
|
|
51
|
+
},
|
|
52
|
+
]);
|
|
53
|
+
capabilities.push('write');
|
|
54
|
+
expect(registry.get('documents')?.capabilities).toEqual(['read']);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('overlays connection states without changing the original registry', () => {
|
|
58
|
+
const registry = createProviderRegistry([
|
|
59
|
+
{
|
|
60
|
+
id: 'calendar',
|
|
61
|
+
labelKey: 'integrations.calendar',
|
|
62
|
+
capabilities: ['read_events'],
|
|
63
|
+
connector: { startOAuth: () => Promise.resolve() },
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: 'storage',
|
|
67
|
+
labelKey: 'integrations.storage',
|
|
68
|
+
capabilities: ['read_files'],
|
|
69
|
+
connector: { startOAuth: () => Promise.resolve() },
|
|
70
|
+
},
|
|
71
|
+
] as const);
|
|
72
|
+
|
|
73
|
+
const connected = registry.withConnectionStates(
|
|
74
|
+
new Map([['calendar', connectionStateFromServer({ state: 'healthy' })]]),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
expect(registry.get('calendar')).toMatchObject({
|
|
78
|
+
currentState: 'disconnected',
|
|
79
|
+
availableActions: [],
|
|
80
|
+
});
|
|
81
|
+
expect(connected.get('calendar')).toMatchObject({
|
|
82
|
+
currentState: 'connected',
|
|
83
|
+
availableActions: ['disconnect'],
|
|
84
|
+
});
|
|
85
|
+
expect(connected.get('storage')).toMatchObject({
|
|
86
|
+
currentState: 'disconnected',
|
|
87
|
+
availableActions: [],
|
|
88
|
+
});
|
|
89
|
+
expect(connected.get('calendar')?.connector).toBe(registry.get('calendar')?.connector);
|
|
90
|
+
expect(connected.list().map(({ id }) => id)).toEqual(['calendar', 'storage']);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('accepts record state overlays and ignores unknown state IDs', () => {
|
|
94
|
+
const registry = createProviderRegistry([
|
|
95
|
+
{
|
|
96
|
+
id: 'calendar',
|
|
97
|
+
labelKey: 'integrations.calendar',
|
|
98
|
+
capabilities: ['read_events'],
|
|
99
|
+
},
|
|
100
|
+
] as const);
|
|
101
|
+
const states = {
|
|
102
|
+
calendar: connectionStateFromServer({ state: 'revoked' }),
|
|
103
|
+
unknown: connectionStateFromServer({ state: 'healthy' }),
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const connected = registry.withConnectionStates(states);
|
|
107
|
+
|
|
108
|
+
expect(connected.get('calendar')).toMatchObject({
|
|
109
|
+
currentState: 'needs_reconnect',
|
|
110
|
+
availableActions: ['reconnect'],
|
|
111
|
+
});
|
|
112
|
+
expect(connected.get('unknown')).toBeUndefined();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('rejects duplicate product IDs', () => {
|
|
116
|
+
expect(() =>
|
|
117
|
+
createProviderRegistry([
|
|
118
|
+
{
|
|
119
|
+
id: 'duplicate',
|
|
120
|
+
labelKey: 'first',
|
|
121
|
+
capabilities: [],
|
|
122
|
+
connection: connectionStateFromServer({ state: 'healthy' }),
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
id: 'duplicate',
|
|
126
|
+
labelKey: 'second',
|
|
127
|
+
capabilities: [],
|
|
128
|
+
connection: connectionStateFromServer({ state: 'disconnected' }),
|
|
129
|
+
},
|
|
130
|
+
]),
|
|
131
|
+
).toThrow('Provider IDs must be unique.');
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { ConnectionAction, ConnectionState, ConnectionStatus } from './connection-health.js';
|
|
2
|
+
|
|
3
|
+
export interface ProviderRegistration<
|
|
4
|
+
ProviderId extends string,
|
|
5
|
+
LabelKey extends string,
|
|
6
|
+
Capability extends string,
|
|
7
|
+
Connector = undefined,
|
|
8
|
+
> {
|
|
9
|
+
readonly id: ProviderId;
|
|
10
|
+
readonly labelKey: LabelKey;
|
|
11
|
+
readonly capabilities: readonly Capability[];
|
|
12
|
+
readonly connector?: Connector;
|
|
13
|
+
readonly connection?: ConnectionState;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface RegisteredProvider<
|
|
17
|
+
ProviderId extends string,
|
|
18
|
+
LabelKey extends string,
|
|
19
|
+
Capability extends string,
|
|
20
|
+
Connector = undefined,
|
|
21
|
+
> {
|
|
22
|
+
readonly id: ProviderId;
|
|
23
|
+
readonly labelKey: LabelKey;
|
|
24
|
+
readonly capabilities: readonly Capability[];
|
|
25
|
+
readonly connector?: Connector;
|
|
26
|
+
readonly currentState: ConnectionStatus;
|
|
27
|
+
readonly availableActions: readonly ConnectionAction[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type ProviderConnectionStates<ProviderId extends string> =
|
|
31
|
+
ReadonlyMap<ProviderId, ConnectionState> | Readonly<Partial<Record<ProviderId, ConnectionState>>>;
|
|
32
|
+
|
|
33
|
+
type ProviderDefinition<
|
|
34
|
+
ProviderId extends string,
|
|
35
|
+
LabelKey extends string,
|
|
36
|
+
Capability extends string,
|
|
37
|
+
Connector,
|
|
38
|
+
> = Omit<ProviderRegistration<ProviderId, LabelKey, Capability, Connector>, 'connection'>;
|
|
39
|
+
|
|
40
|
+
export class ProviderRegistry<
|
|
41
|
+
ProviderId extends string,
|
|
42
|
+
LabelKey extends string,
|
|
43
|
+
Capability extends string,
|
|
44
|
+
Connector = undefined,
|
|
45
|
+
> {
|
|
46
|
+
readonly #definitions: readonly ProviderDefinition<ProviderId, LabelKey, Capability, Connector>[];
|
|
47
|
+
readonly #providers: readonly RegisteredProvider<ProviderId, LabelKey, Capability, Connector>[];
|
|
48
|
+
readonly #byId: ReadonlyMap<
|
|
49
|
+
string,
|
|
50
|
+
RegisteredProvider<ProviderId, LabelKey, Capability, Connector>
|
|
51
|
+
>;
|
|
52
|
+
|
|
53
|
+
public constructor(
|
|
54
|
+
registrations: readonly ProviderRegistration<ProviderId, LabelKey, Capability, Connector>[],
|
|
55
|
+
) {
|
|
56
|
+
const definitions = registrations.map((registration) =>
|
|
57
|
+
Object.freeze({
|
|
58
|
+
id: registration.id,
|
|
59
|
+
labelKey: registration.labelKey,
|
|
60
|
+
capabilities: Object.freeze([...registration.capabilities]),
|
|
61
|
+
...('connector' in registration ? { connector: registration.connector } : {}),
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
const ids = new Set(definitions.map((definition) => definition.id));
|
|
65
|
+
if (ids.size !== definitions.length) throw new TypeError('Provider IDs must be unique.');
|
|
66
|
+
|
|
67
|
+
const providers = definitions.map((definition, index) => {
|
|
68
|
+
const connection = registrations[index]?.connection;
|
|
69
|
+
return Object.freeze({
|
|
70
|
+
...definition,
|
|
71
|
+
currentState: connection?.status ?? 'disconnected',
|
|
72
|
+
availableActions: Object.freeze(
|
|
73
|
+
connection === undefined ? [] : [...connection.availableActions],
|
|
74
|
+
),
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
this.#definitions = Object.freeze(definitions);
|
|
78
|
+
this.#providers = Object.freeze(providers);
|
|
79
|
+
this.#byId = new Map(providers.map((provider) => [provider.id, provider]));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
public list(): readonly RegisteredProvider<ProviderId, LabelKey, Capability, Connector>[] {
|
|
83
|
+
return this.#providers;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
public get(
|
|
87
|
+
providerId: string,
|
|
88
|
+
): RegisteredProvider<ProviderId, LabelKey, Capability, Connector> | undefined {
|
|
89
|
+
return this.#byId.get(providerId);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
public withConnectionStates(
|
|
93
|
+
states: ProviderConnectionStates<ProviderId>,
|
|
94
|
+
): ProviderRegistry<ProviderId, LabelKey, Capability, Connector> {
|
|
95
|
+
const registrations = this.#definitions.map((definition) => {
|
|
96
|
+
const connection = getConnectionState(states, definition.id);
|
|
97
|
+
return {
|
|
98
|
+
...definition,
|
|
99
|
+
...(connection === undefined ? {} : { connection }),
|
|
100
|
+
};
|
|
101
|
+
});
|
|
102
|
+
return new ProviderRegistry<ProviderId, LabelKey, Capability, Connector>(registrations);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function createProviderRegistry<
|
|
107
|
+
ProviderId extends string,
|
|
108
|
+
LabelKey extends string,
|
|
109
|
+
Capability extends string,
|
|
110
|
+
Connector = undefined,
|
|
111
|
+
>(
|
|
112
|
+
registrations: readonly ProviderRegistration<ProviderId, LabelKey, Capability, Connector>[],
|
|
113
|
+
): ProviderRegistry<ProviderId, LabelKey, Capability, Connector> {
|
|
114
|
+
return new ProviderRegistry(registrations);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function getConnectionState<ProviderId extends string>(
|
|
118
|
+
states: ProviderConnectionStates<ProviderId>,
|
|
119
|
+
providerId: ProviderId,
|
|
120
|
+
): ConnectionState | undefined {
|
|
121
|
+
if (isReadonlyMap(states)) return states.get(providerId);
|
|
122
|
+
if (!Object.hasOwn(states, providerId)) return undefined;
|
|
123
|
+
return states[providerId];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function isReadonlyMap<ProviderId extends string>(
|
|
127
|
+
states: ProviderConnectionStates<ProviderId>,
|
|
128
|
+
): states is ReadonlyMap<ProviderId, ConnectionState> {
|
|
129
|
+
return typeof Reflect.get(states, 'get') === 'function';
|
|
130
|
+
}
|