@slates/cli 1.0.0-rc.6 → 1.0.0
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 +6 -6
- package/src/cli.ts +3 -1
- package/src/commands/auth.incremental.test.ts +207 -0
- package/src/commands/auth.test.ts +215 -1
- package/src/commands/auth.ts +260 -38
- package/src/commands/config.ts +1 -1
- package/src/commands/profiles.ts +14 -6
- package/src/commands/repl.ts +1 -1
- package/src/commands/test.ts +3 -3
- package/src/commands/tools.ts +1 -1
- package/src/lib/context.ts +3 -3
- package/src/lib/integration.test.ts +10 -2
- package/src/lib/integration.ts +5 -3
- package/src/lib/oauth.test.ts +126 -0
- package/src/lib/oauth.ts +21 -6
- package/src/lib/prompts.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@slates/cli",
|
|
3
|
-
"version": "1.0.0
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -20,14 +20,14 @@
|
|
|
20
20
|
},
|
|
21
21
|
"scripts": {
|
|
22
22
|
"test": "vitest run --passWithNoTests",
|
|
23
|
-
"
|
|
24
|
-
"
|
|
23
|
+
"typecheck": "tsc --noEmit",
|
|
24
|
+
"biome:check": "biome check --write src"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"@inquirer/prompts": "^7.4.0",
|
|
28
|
-
"@slates/client": "1.0.0-rc.
|
|
29
|
-
"@slates/oauth-microsoft": "1.0.0-rc.
|
|
30
|
-
"@slates/profiles": "1.0.0-rc.
|
|
28
|
+
"@slates/client": "1.0.0-rc.17",
|
|
29
|
+
"@slates/oauth-microsoft": "1.0.0-rc.6",
|
|
30
|
+
"@slates/profiles": "1.0.0-rc.15",
|
|
31
31
|
"sade": "^1.8.1"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
package/src/cli.ts
CHANGED
|
@@ -193,6 +193,7 @@ if (isGlobalTestCommand) {
|
|
|
193
193
|
.option('--client-id', 'OAuth client ID')
|
|
194
194
|
.option('--client-secret', 'OAuth client secret')
|
|
195
195
|
.option('--scopes', 'Comma-separated OAuth scopes')
|
|
196
|
+
.option('--incremental', 'Extend an existing OAuth grant with the supplied scope batch')
|
|
196
197
|
.action((authMethodId: string | undefined, opts) =>
|
|
197
198
|
printResult(() =>
|
|
198
199
|
setupAuth({
|
|
@@ -203,7 +204,8 @@ if (isGlobalTestCommand) {
|
|
|
203
204
|
oauthCredential: opts.oauthCredential,
|
|
204
205
|
clientId: opts.clientId,
|
|
205
206
|
clientSecret: opts.clientSecret,
|
|
206
|
-
scopes: opts.scopes
|
|
207
|
+
scopes: opts.scopes,
|
|
208
|
+
incremental: opts.incremental
|
|
207
209
|
})
|
|
208
210
|
)
|
|
209
211
|
);
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { type AuthSetupRuntimeDependencies, runAuthSetupWithDependencies } from './auth';
|
|
3
|
+
|
|
4
|
+
let authMethod = {
|
|
5
|
+
id: 'google_oauth',
|
|
6
|
+
name: 'Google OAuth',
|
|
7
|
+
type: 'auth.oauth',
|
|
8
|
+
inputSchema: {},
|
|
9
|
+
scopes: [
|
|
10
|
+
{ id: 'scope:two', title: 'Scope two' },
|
|
11
|
+
{ id: 'scope:denied', title: 'Denied scope' }
|
|
12
|
+
],
|
|
13
|
+
capabilities: {
|
|
14
|
+
getDefaultInput: { enabled: false },
|
|
15
|
+
handleChangedInput: { enabled: false },
|
|
16
|
+
getProfile: { enabled: true }
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
let existingAuth = {
|
|
21
|
+
id: 'auth-1',
|
|
22
|
+
authMethodId: 'google_oauth',
|
|
23
|
+
authMethodName: 'Google OAuth',
|
|
24
|
+
authType: 'auth.oauth',
|
|
25
|
+
input: { developerToken: 'existing-developer-token' },
|
|
26
|
+
output: {
|
|
27
|
+
token: 'old-access-token',
|
|
28
|
+
refreshToken: 'existing-refresh-token',
|
|
29
|
+
developerToken: 'existing-developer-token'
|
|
30
|
+
},
|
|
31
|
+
scopes: ['openid', 'scope:one'],
|
|
32
|
+
clientId: 'client-id',
|
|
33
|
+
clientSecret: 'client-secret',
|
|
34
|
+
callbackState: null,
|
|
35
|
+
profile: { id: 'google-user-1', email: 'person@example.com' },
|
|
36
|
+
createdAt: '2026-09-02T10:00:00.000Z',
|
|
37
|
+
updatedAt: '2026-09-02T10:00:00.000Z'
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
let createScenario = (profile = existingAuth.profile) => {
|
|
41
|
+
let storedAuth: Record<string, unknown> | null = null;
|
|
42
|
+
let store = {
|
|
43
|
+
getAuth: vi.fn(() => existingAuth),
|
|
44
|
+
getOAuthCredential: vi.fn(() => null),
|
|
45
|
+
upsertAuth: vi.fn((_profileId: string, auth: Record<string, unknown>) => {
|
|
46
|
+
storedAuth = auth;
|
|
47
|
+
return auth;
|
|
48
|
+
}),
|
|
49
|
+
save: vi.fn(async () => undefined)
|
|
50
|
+
};
|
|
51
|
+
let client = {
|
|
52
|
+
clearAuth: vi.fn(),
|
|
53
|
+
getAuthorizationUrl: vi.fn(async () => ({
|
|
54
|
+
authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth'
|
|
55
|
+
})),
|
|
56
|
+
handleAuthorizationCallback: vi.fn(async () => ({
|
|
57
|
+
output: {
|
|
58
|
+
token: 'new-access-token',
|
|
59
|
+
refreshToken: undefined,
|
|
60
|
+
developerToken: 'existing-developer-token'
|
|
61
|
+
},
|
|
62
|
+
input: { developerToken: 'existing-developer-token' },
|
|
63
|
+
scopes: ['scope:two']
|
|
64
|
+
})),
|
|
65
|
+
getAuthProfile: vi.fn(async () => ({ profile }))
|
|
66
|
+
};
|
|
67
|
+
let callback = {
|
|
68
|
+
redirectUri: 'http://127.0.0.1:45873/callback',
|
|
69
|
+
state: 'oauth-state',
|
|
70
|
+
wait: vi.fn(async () => ({
|
|
71
|
+
code: 'authorization-code',
|
|
72
|
+
state: 'oauth-state',
|
|
73
|
+
callbackParams: { code: 'authorization-code', state: 'oauth-state' }
|
|
74
|
+
}))
|
|
75
|
+
};
|
|
76
|
+
let createClientContext = vi.fn(async () => ({
|
|
77
|
+
store,
|
|
78
|
+
profile: { id: 'profile-1' },
|
|
79
|
+
client
|
|
80
|
+
}));
|
|
81
|
+
let chooseAuthMethod = vi.fn(async () => authMethod);
|
|
82
|
+
let chooseScopes = vi.fn(async (_method, scopes: string[]) => scopes);
|
|
83
|
+
let createOAuthCallbackListener = vi.fn(async () => callback);
|
|
84
|
+
let printBrowserUrl = vi.fn();
|
|
85
|
+
let dependencies = {
|
|
86
|
+
createClientContext,
|
|
87
|
+
chooseAuthMethod,
|
|
88
|
+
chooseScopes,
|
|
89
|
+
createOAuthCallbackListener,
|
|
90
|
+
printBrowserUrl
|
|
91
|
+
} as unknown as AuthSetupRuntimeDependencies;
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
store,
|
|
95
|
+
client,
|
|
96
|
+
callback,
|
|
97
|
+
dependencies,
|
|
98
|
+
createOAuthCallbackListener,
|
|
99
|
+
getStoredAuth: () => storedAuth
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
describe('incremental OAuth setup orchestration', () => {
|
|
104
|
+
it('reuses input and credentials, then stores only actually granted scopes', async () => {
|
|
105
|
+
let scenario = createScenario();
|
|
106
|
+
|
|
107
|
+
await runAuthSetupWithDependencies(
|
|
108
|
+
{
|
|
109
|
+
integration: 'super-booble-2',
|
|
110
|
+
authMethodId: 'google_oauth',
|
|
111
|
+
incremental: true,
|
|
112
|
+
scopes: 'scope:two,scope:denied'
|
|
113
|
+
},
|
|
114
|
+
scenario.dependencies
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
expect(scenario.client.getAuthorizationUrl).toHaveBeenCalledWith(
|
|
118
|
+
expect.objectContaining({
|
|
119
|
+
input: existingAuth.input,
|
|
120
|
+
clientId: 'client-id',
|
|
121
|
+
clientSecret: 'client-secret',
|
|
122
|
+
scopes: ['scope:two', 'scope:denied']
|
|
123
|
+
})
|
|
124
|
+
);
|
|
125
|
+
expect(scenario.client.handleAuthorizationCallback).toHaveBeenCalledWith(
|
|
126
|
+
expect.objectContaining({ input: existingAuth.input })
|
|
127
|
+
);
|
|
128
|
+
expect(scenario.getStoredAuth()).toEqual(
|
|
129
|
+
expect.objectContaining({
|
|
130
|
+
input: existingAuth.input,
|
|
131
|
+
output: {
|
|
132
|
+
token: 'new-access-token',
|
|
133
|
+
refreshToken: 'existing-refresh-token',
|
|
134
|
+
developerToken: 'existing-developer-token'
|
|
135
|
+
},
|
|
136
|
+
scopes: ['openid', 'scope:one', 'scope:two'],
|
|
137
|
+
profile: existingAuth.profile
|
|
138
|
+
})
|
|
139
|
+
);
|
|
140
|
+
expect(scenario.store.save).toHaveBeenCalledOnce();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('rejects a different client ID before opening a callback listener or persisting', async () => {
|
|
144
|
+
let scenario = createScenario();
|
|
145
|
+
|
|
146
|
+
await expect(
|
|
147
|
+
runAuthSetupWithDependencies(
|
|
148
|
+
{
|
|
149
|
+
integration: 'super-booble-2',
|
|
150
|
+
authMethodId: 'google_oauth',
|
|
151
|
+
incremental: true,
|
|
152
|
+
scopes: 'scope:two',
|
|
153
|
+
clientId: 'different-client-id',
|
|
154
|
+
clientSecret: 'different-client-secret'
|
|
155
|
+
},
|
|
156
|
+
scenario.dependencies
|
|
157
|
+
)
|
|
158
|
+
).rejects.toThrow('must use the same OAuth client ID');
|
|
159
|
+
|
|
160
|
+
expect(scenario.createOAuthCallbackListener).not.toHaveBeenCalled();
|
|
161
|
+
expect(scenario.store.upsertAuth).not.toHaveBeenCalled();
|
|
162
|
+
expect(scenario.store.save).not.toHaveBeenCalled();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('rejects an auth input override before authorization begins', async () => {
|
|
166
|
+
let scenario = createScenario();
|
|
167
|
+
|
|
168
|
+
await expect(
|
|
169
|
+
runAuthSetupWithDependencies(
|
|
170
|
+
{
|
|
171
|
+
integration: 'super-booble-2',
|
|
172
|
+
authMethodId: 'google_oauth',
|
|
173
|
+
incremental: true,
|
|
174
|
+
scopes: 'scope:two',
|
|
175
|
+
input: JSON.stringify({ developerToken: 'replacement-developer-token' })
|
|
176
|
+
},
|
|
177
|
+
scenario.dependencies
|
|
178
|
+
)
|
|
179
|
+
).rejects.toThrow('reuses the existing authentication input');
|
|
180
|
+
|
|
181
|
+
expect(scenario.createOAuthCallbackListener).not.toHaveBeenCalled();
|
|
182
|
+
expect(scenario.store.upsertAuth).not.toHaveBeenCalled();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('rejects a different Google account without merging or persisting its token', async () => {
|
|
186
|
+
let scenario = createScenario({
|
|
187
|
+
id: 'google-user-2',
|
|
188
|
+
email: 'other@example.com'
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
await expect(
|
|
192
|
+
runAuthSetupWithDependencies(
|
|
193
|
+
{
|
|
194
|
+
integration: 'super-booble-2',
|
|
195
|
+
authMethodId: 'google_oauth',
|
|
196
|
+
incremental: true,
|
|
197
|
+
scopes: 'scope:two'
|
|
198
|
+
},
|
|
199
|
+
scenario.dependencies
|
|
200
|
+
)
|
|
201
|
+
).rejects.toThrow('returned a different Google account');
|
|
202
|
+
|
|
203
|
+
expect(scenario.client.handleAuthorizationCallback).toHaveBeenCalledOnce();
|
|
204
|
+
expect(scenario.store.upsertAuth).not.toHaveBeenCalled();
|
|
205
|
+
expect(scenario.store.save).not.toHaveBeenCalled();
|
|
206
|
+
});
|
|
207
|
+
});
|
|
@@ -1,5 +1,210 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
assertOAuthProfileContinuity,
|
|
4
|
+
completeIncrementalOAuthAuthorization,
|
|
5
|
+
mergeIncrementalOAuthAuthorization,
|
|
6
|
+
normalizeCallbackRedirectUriForIntegration,
|
|
7
|
+
resolveIncrementalOAuthCredentials,
|
|
8
|
+
validateIncrementalOAuthSetup
|
|
9
|
+
} from './auth';
|
|
10
|
+
|
|
11
|
+
let existingOAuthAuth = {
|
|
12
|
+
id: 'auth-1',
|
|
13
|
+
authMethodId: 'google_oauth',
|
|
14
|
+
authMethodName: 'Google OAuth',
|
|
15
|
+
authType: 'auth.oauth' as const,
|
|
16
|
+
input: {},
|
|
17
|
+
output: { token: 'access-token', refreshToken: 'refresh-token' },
|
|
18
|
+
scopes: ['openid'],
|
|
19
|
+
clientId: 'client-id',
|
|
20
|
+
clientSecret: 'client-secret',
|
|
21
|
+
profile: { id: 'google-user-1', email: 'person@example.com' },
|
|
22
|
+
createdAt: '2026-09-02T10:00:00.000Z',
|
|
23
|
+
updatedAt: '2026-09-02T10:00:00.000Z'
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
describe('validateIncrementalOAuthSetup', () => {
|
|
27
|
+
it('requires existing OAuth authentication', () => {
|
|
28
|
+
expect(() =>
|
|
29
|
+
validateIncrementalOAuthSetup({
|
|
30
|
+
enabled: true,
|
|
31
|
+
authMethodName: 'Google OAuth',
|
|
32
|
+
previousAuth: null,
|
|
33
|
+
scopes: ['scope:one']
|
|
34
|
+
})
|
|
35
|
+
).toThrow('requires existing OAuth authentication');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('requires an explicit scope batch', () => {
|
|
39
|
+
expect(() =>
|
|
40
|
+
validateIncrementalOAuthSetup({
|
|
41
|
+
enabled: true,
|
|
42
|
+
authMethodName: 'Google OAuth',
|
|
43
|
+
previousAuth: existingOAuthAuth,
|
|
44
|
+
scopes: []
|
|
45
|
+
})
|
|
46
|
+
).toThrow('requires an explicit comma-separated scope batch');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('returns the existing OAuth authentication for an incremental batch', () => {
|
|
50
|
+
expect(
|
|
51
|
+
validateIncrementalOAuthSetup({
|
|
52
|
+
enabled: true,
|
|
53
|
+
authMethodName: 'Google OAuth',
|
|
54
|
+
previousAuth: existingOAuthAuth,
|
|
55
|
+
scopes: ['scope:one']
|
|
56
|
+
})
|
|
57
|
+
).toBe(existingOAuthAuth);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('rejects input overrides so the existing auth input is preserved', () => {
|
|
61
|
+
expect(() =>
|
|
62
|
+
validateIncrementalOAuthSetup({
|
|
63
|
+
enabled: true,
|
|
64
|
+
authMethodName: 'Google OAuth',
|
|
65
|
+
previousAuth: existingOAuthAuth,
|
|
66
|
+
scopes: ['scope:one'],
|
|
67
|
+
inputProvided: true
|
|
68
|
+
})
|
|
69
|
+
).toThrow('reuses the existing authentication input');
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('mergeIncrementalOAuthAuthorization', () => {
|
|
74
|
+
it('accumulates granted scopes and preserves an omitted refresh token', () => {
|
|
75
|
+
expect(
|
|
76
|
+
mergeIncrementalOAuthAuthorization({
|
|
77
|
+
previousOutput: {
|
|
78
|
+
token: 'old-access-token',
|
|
79
|
+
refreshToken: 'existing-refresh-token',
|
|
80
|
+
developerToken: 'developer-token'
|
|
81
|
+
},
|
|
82
|
+
previousScopes: ['openid', 'scope:one'],
|
|
83
|
+
output: {
|
|
84
|
+
token: 'new-access-token',
|
|
85
|
+
refreshToken: undefined,
|
|
86
|
+
developerToken: 'developer-token',
|
|
87
|
+
expiresAt: '2026-09-02T12:00:00.000Z'
|
|
88
|
+
},
|
|
89
|
+
scopes: ['scope:one', 'scope:two']
|
|
90
|
+
})
|
|
91
|
+
).toEqual({
|
|
92
|
+
output: {
|
|
93
|
+
token: 'new-access-token',
|
|
94
|
+
refreshToken: 'existing-refresh-token',
|
|
95
|
+
developerToken: 'developer-token',
|
|
96
|
+
expiresAt: '2026-09-02T12:00:00.000Z'
|
|
97
|
+
},
|
|
98
|
+
scopes: ['openid', 'scope:one', 'scope:two']
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('uses a replacement refresh token when the provider returns one', () => {
|
|
103
|
+
expect(
|
|
104
|
+
mergeIncrementalOAuthAuthorization({
|
|
105
|
+
previousOutput: {
|
|
106
|
+
token: 'old-access-token',
|
|
107
|
+
refreshToken: 'old-refresh-token'
|
|
108
|
+
},
|
|
109
|
+
previousScopes: ['scope:one'],
|
|
110
|
+
output: {
|
|
111
|
+
token: 'new-access-token',
|
|
112
|
+
refreshToken: 'new-refresh-token'
|
|
113
|
+
},
|
|
114
|
+
scopes: ['scope:two']
|
|
115
|
+
}).output.refreshToken
|
|
116
|
+
).toBe('new-refresh-token');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('resolveIncrementalOAuthCredentials', () => {
|
|
121
|
+
let linkedCredential = {
|
|
122
|
+
id: 'credential-1',
|
|
123
|
+
name: 'Google credentials',
|
|
124
|
+
authMethodId: 'google_oauth',
|
|
125
|
+
clientId: 'client-id',
|
|
126
|
+
clientSecret: 'credential-secret',
|
|
127
|
+
createdAt: '2026-09-02T10:00:00.000Z',
|
|
128
|
+
updatedAt: '2026-09-02T10:00:00.000Z'
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
it('derives the prior client identity from its linked OAuth credential', () => {
|
|
132
|
+
expect(
|
|
133
|
+
resolveIncrementalOAuthCredentials({
|
|
134
|
+
previousAuth: { ...existingOAuthAuth, clientId: undefined, clientSecret: undefined },
|
|
135
|
+
linkedCredential
|
|
136
|
+
})
|
|
137
|
+
).toEqual({
|
|
138
|
+
credential: linkedCredential,
|
|
139
|
+
clientId: 'client-id',
|
|
140
|
+
clientSecret: 'credential-secret'
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('rejects a selected credential for a different client', () => {
|
|
145
|
+
expect(() =>
|
|
146
|
+
resolveIncrementalOAuthCredentials({
|
|
147
|
+
previousAuth: existingOAuthAuth,
|
|
148
|
+
linkedCredential: null,
|
|
149
|
+
selectedCredential: { ...linkedCredential, clientId: 'different-client-id' },
|
|
150
|
+
selectedCredentialRequested: true
|
|
151
|
+
})
|
|
152
|
+
).toThrow('must use the same OAuth client ID');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('rejects setup when the prior client identity is unavailable', () => {
|
|
156
|
+
expect(() =>
|
|
157
|
+
resolveIncrementalOAuthCredentials({
|
|
158
|
+
previousAuth: { ...existingOAuthAuth, clientId: undefined },
|
|
159
|
+
linkedCredential: null
|
|
160
|
+
})
|
|
161
|
+
).toThrow('cannot determine the OAuth client ID');
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe('completeIncrementalOAuthAuthorization', () => {
|
|
166
|
+
it('checks account continuity before merging a partial grant', () => {
|
|
167
|
+
expect(
|
|
168
|
+
completeIncrementalOAuthAuthorization({
|
|
169
|
+
previousAuth: existingOAuthAuth,
|
|
170
|
+
output: {
|
|
171
|
+
token: 'new-access-token',
|
|
172
|
+
refreshToken: undefined,
|
|
173
|
+
developerToken: 'developer-token'
|
|
174
|
+
},
|
|
175
|
+
grantedScopes: ['scope:two'],
|
|
176
|
+
profile: { id: 'google-user-1', email: 'renamed@example.com' }
|
|
177
|
+
})
|
|
178
|
+
).toEqual({
|
|
179
|
+
output: {
|
|
180
|
+
token: 'new-access-token',
|
|
181
|
+
refreshToken: 'refresh-token',
|
|
182
|
+
developerToken: 'developer-token'
|
|
183
|
+
},
|
|
184
|
+
scopes: ['openid', 'scope:two']
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('rejects a different account', () => {
|
|
189
|
+
expect(() =>
|
|
190
|
+
completeIncrementalOAuthAuthorization({
|
|
191
|
+
previousAuth: existingOAuthAuth,
|
|
192
|
+
output: { token: 'new-access-token' },
|
|
193
|
+
grantedScopes: ['scope:two'],
|
|
194
|
+
profile: { id: 'google-user-2', email: 'other@example.com' }
|
|
195
|
+
})
|
|
196
|
+
).toThrow('returned a different Google account');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('compares normalized email when the previous profile has no stable ID', () => {
|
|
200
|
+
expect(() =>
|
|
201
|
+
assertOAuthProfileContinuity(
|
|
202
|
+
{ email: 'Person@Example.com' },
|
|
203
|
+
{ email: 'person@example.com' }
|
|
204
|
+
)
|
|
205
|
+
).not.toThrow();
|
|
206
|
+
});
|
|
207
|
+
});
|
|
3
208
|
|
|
4
209
|
describe('normalizeCallbackRedirectUriForIntegration', () => {
|
|
5
210
|
it('normalizes Notion loopback redirects to localhost', () => {
|
|
@@ -14,6 +219,15 @@ describe('normalizeCallbackRedirectUriForIntegration', () => {
|
|
|
14
219
|
).toBe('http://localhost:45873/callback');
|
|
15
220
|
});
|
|
16
221
|
|
|
222
|
+
it('normalizes QuickBooks loopback redirects to localhost', () => {
|
|
223
|
+
expect(
|
|
224
|
+
normalizeCallbackRedirectUriForIntegration(
|
|
225
|
+
'quickbooks',
|
|
226
|
+
'http://127.0.0.1:45873/callback'
|
|
227
|
+
)
|
|
228
|
+
).toBe('http://localhost:45873/callback');
|
|
229
|
+
});
|
|
230
|
+
|
|
17
231
|
it('normalizes Typeform loopback redirects to localhost', () => {
|
|
18
232
|
expect(
|
|
19
233
|
normalizeCallbackRedirectUriForIntegration('typeform', 'http://127.0.0.1:45873/callback')
|
package/src/commands/auth.ts
CHANGED
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
normalizeMicrosoftRedirectUri,
|
|
4
4
|
normalizeMicrosoftRedirectUriForIntegration
|
|
5
5
|
} from '@slates/oauth-microsoft';
|
|
6
|
-
import { SlatesOAuthCredentialRecord, SlatesStoredAuth } from '@slates/profiles';
|
|
6
|
+
import type { SlatesOAuthCredentialRecord, SlatesStoredAuth } from '@slates/profiles';
|
|
7
7
|
import {
|
|
8
8
|
chooseAuthMethod,
|
|
9
9
|
createClientContext,
|
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
promptForObjectSchema,
|
|
18
18
|
promptForString
|
|
19
19
|
} from '../lib/prompts';
|
|
20
|
-
import { JsonInput, WithProfile } from '../lib/types';
|
|
20
|
+
import type { JsonInput, WithProfile } from '../lib/types';
|
|
21
21
|
|
|
22
22
|
type JsonObject = Record<string, any>;
|
|
23
23
|
let NOTION_INTEGRATION_KEY = 'notion';
|
|
@@ -26,11 +26,13 @@ let INTERCOM_INTEGRATION_KEY = 'intercom';
|
|
|
26
26
|
let TYPEFORM_INTEGRATION_KEY = 'typeform';
|
|
27
27
|
let XERO_INTEGRATION_KEY = 'xero';
|
|
28
28
|
let ZENDESK_INTEGRATION_KEY = 'zendesk';
|
|
29
|
+
let QUICKBOOKS_INTEGRATION_KEY = 'quickbooks';
|
|
29
30
|
let HUBSPOT_INTEGRATION_KEY = 'hubspot';
|
|
30
31
|
let HUBSPOT_DEVELOPER_PLATFORM_OAUTH_METHOD_ID = 'developer_platform_oauth';
|
|
31
32
|
let LOOPBACK_REDIRECT_NORMALIZED_INTEGRATIONS = new Set([
|
|
32
33
|
INTERCOM_INTEGRATION_KEY,
|
|
33
34
|
NOTION_INTEGRATION_KEY,
|
|
35
|
+
QUICKBOOKS_INTEGRATION_KEY,
|
|
34
36
|
SALESFORCE_INTEGRATION_KEY,
|
|
35
37
|
TYPEFORM_INTEGRATION_KEY,
|
|
36
38
|
XERO_INTEGRATION_KEY,
|
|
@@ -44,8 +46,181 @@ type AuthSetupOptions = WithProfile &
|
|
|
44
46
|
clientSecret?: string;
|
|
45
47
|
oauthCredential?: string;
|
|
46
48
|
scopes?: string;
|
|
49
|
+
incremental?: boolean;
|
|
47
50
|
};
|
|
48
51
|
|
|
52
|
+
export type AuthSetupRuntimeDependencies = {
|
|
53
|
+
createClientContext: typeof createClientContext;
|
|
54
|
+
chooseAuthMethod: typeof chooseAuthMethod;
|
|
55
|
+
chooseScopes: typeof chooseScopes;
|
|
56
|
+
createOAuthCallbackListener: typeof createOAuthCallbackListener;
|
|
57
|
+
printBrowserUrl: typeof printBrowserUrl;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
let authSetupRuntimeDependencies: AuthSetupRuntimeDependencies = {
|
|
61
|
+
createClientContext,
|
|
62
|
+
chooseAuthMethod,
|
|
63
|
+
chooseScopes,
|
|
64
|
+
createOAuthCallbackListener,
|
|
65
|
+
printBrowserUrl
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export let mergeIncrementalOAuthAuthorization = (d: {
|
|
69
|
+
previousOutput: JsonObject;
|
|
70
|
+
previousScopes: string[];
|
|
71
|
+
output: JsonObject;
|
|
72
|
+
scopes: string[];
|
|
73
|
+
}) => {
|
|
74
|
+
let output = { ...d.output };
|
|
75
|
+
if (
|
|
76
|
+
(d.output.refreshToken === undefined || d.output.refreshToken === null) &&
|
|
77
|
+
typeof d.previousOutput.refreshToken === 'string'
|
|
78
|
+
) {
|
|
79
|
+
output.refreshToken = d.previousOutput.refreshToken;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
output,
|
|
84
|
+
scopes: [...new Set([...d.previousScopes, ...d.scopes])]
|
|
85
|
+
};
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export let validateIncrementalOAuthSetup = (d: {
|
|
89
|
+
enabled?: boolean;
|
|
90
|
+
authMethodName: string;
|
|
91
|
+
previousAuth: SlatesStoredAuth | null;
|
|
92
|
+
scopes: string[];
|
|
93
|
+
inputProvided?: boolean;
|
|
94
|
+
}) => {
|
|
95
|
+
if (!d.enabled) return null;
|
|
96
|
+
|
|
97
|
+
if (!d.previousAuth || d.previousAuth.authType !== 'auth.oauth') {
|
|
98
|
+
throw new Error(
|
|
99
|
+
`Incremental OAuth setup requires existing OAuth authentication for ${d.authMethodName}. Run auth setup once without --incremental first.`
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
if (d.scopes.length === 0) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
'Incremental OAuth setup requires an explicit comma-separated scope batch via --scopes.'
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
if (d.inputProvided) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
'Incremental OAuth setup reuses the existing authentication input. Remove --input and retry.'
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return d.previousAuth;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
export let resolveIncrementalOAuthCredentials = (d: {
|
|
117
|
+
previousAuth: SlatesStoredAuth;
|
|
118
|
+
linkedCredential: SlatesOAuthCredentialRecord | null;
|
|
119
|
+
selectedCredential?: SlatesOAuthCredentialRecord | null;
|
|
120
|
+
selectedCredentialRequested?: boolean;
|
|
121
|
+
clientId?: string;
|
|
122
|
+
clientSecret?: string;
|
|
123
|
+
}) => {
|
|
124
|
+
let storedClientId = d.previousAuth.clientId;
|
|
125
|
+
let linkedClientId = d.linkedCredential?.clientId;
|
|
126
|
+
if (storedClientId && linkedClientId && storedClientId !== linkedClientId) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
'The existing authentication and its saved OAuth credential use different client IDs. Reconnect before using incremental OAuth setup.'
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let previousClientId = storedClientId ?? linkedClientId;
|
|
133
|
+
if (!previousClientId) {
|
|
134
|
+
throw new Error(
|
|
135
|
+
'Incremental OAuth setup cannot determine the OAuth client ID used by the existing authentication. Reconnect before using --incremental.'
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
if (d.selectedCredentialRequested && !d.selectedCredential) {
|
|
139
|
+
throw new Error(
|
|
140
|
+
'The selected OAuth credential was not found for this authentication method.'
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (
|
|
145
|
+
(d.clientId && d.clientId !== previousClientId) ||
|
|
146
|
+
(d.selectedCredential && d.selectedCredential.clientId !== previousClientId)
|
|
147
|
+
) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
'Incremental OAuth setup must use the same OAuth client ID as the existing authentication.'
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
let clientSecret =
|
|
154
|
+
d.clientSecret ??
|
|
155
|
+
d.selectedCredential?.clientSecret ??
|
|
156
|
+
d.linkedCredential?.clientSecret ??
|
|
157
|
+
d.previousAuth.clientSecret;
|
|
158
|
+
if (!clientSecret) {
|
|
159
|
+
throw new Error(
|
|
160
|
+
'The existing authentication does not have a reusable OAuth client secret. Supply the secret for the same client with --client-secret.'
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
credential:
|
|
166
|
+
d.selectedCredential ?? (d.clientSecret === undefined ? d.linkedCredential : null),
|
|
167
|
+
clientId: previousClientId,
|
|
168
|
+
clientSecret
|
|
169
|
+
};
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
let getOAuthProfileId = (profile: JsonObject | null | undefined) => {
|
|
173
|
+
let id = profile?.id;
|
|
174
|
+
return typeof id === 'string' && id.trim() ? id : null;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
let getOAuthProfileEmail = (profile: JsonObject | null | undefined) => {
|
|
178
|
+
let email = profile?.email;
|
|
179
|
+
return typeof email === 'string' && email.trim() ? email.trim().toLowerCase() : null;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
export let assertOAuthProfileContinuity = (
|
|
183
|
+
previousProfile: JsonObject | null | undefined,
|
|
184
|
+
currentProfile: JsonObject | null | undefined
|
|
185
|
+
) => {
|
|
186
|
+
let previousId = getOAuthProfileId(previousProfile);
|
|
187
|
+
if (previousId) {
|
|
188
|
+
if (getOAuthProfileId(currentProfile) !== previousId) {
|
|
189
|
+
throw new Error(
|
|
190
|
+
'Incremental OAuth setup returned a different Google account. The existing authentication was left unchanged.'
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
let previousEmail = getOAuthProfileEmail(previousProfile);
|
|
197
|
+
if (!previousEmail) {
|
|
198
|
+
throw new Error(
|
|
199
|
+
'Incremental OAuth setup cannot verify the Google account used by the existing authentication. Reconnect before using --incremental.'
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
if (getOAuthProfileEmail(currentProfile) !== previousEmail) {
|
|
203
|
+
throw new Error(
|
|
204
|
+
'Incremental OAuth setup returned a different Google account. The existing authentication was left unchanged.'
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
export let completeIncrementalOAuthAuthorization = (d: {
|
|
210
|
+
previousAuth: SlatesStoredAuth;
|
|
211
|
+
output: JsonObject;
|
|
212
|
+
grantedScopes: string[];
|
|
213
|
+
profile: JsonObject | null | undefined;
|
|
214
|
+
}) => {
|
|
215
|
+
assertOAuthProfileContinuity(d.previousAuth.profile, d.profile);
|
|
216
|
+
return mergeIncrementalOAuthAuthorization({
|
|
217
|
+
previousOutput: d.previousAuth.output,
|
|
218
|
+
previousScopes: d.previousAuth.scopes,
|
|
219
|
+
output: d.output,
|
|
220
|
+
scopes: d.grantedScopes
|
|
221
|
+
});
|
|
222
|
+
};
|
|
223
|
+
|
|
49
224
|
export let normalizeCallbackRedirectUriForIntegration = (
|
|
50
225
|
integration: string,
|
|
51
226
|
redirectUri: string,
|
|
@@ -266,26 +441,46 @@ let chooseOAuthCredentialsForSetup = async (opts: {
|
|
|
266
441
|
};
|
|
267
442
|
};
|
|
268
443
|
|
|
269
|
-
let
|
|
270
|
-
|
|
444
|
+
export let runAuthSetupWithDependencies = async (
|
|
445
|
+
opts: AuthSetupOptions,
|
|
446
|
+
dependencies: AuthSetupRuntimeDependencies
|
|
447
|
+
): Promise<SlatesStoredAuth> => {
|
|
448
|
+
let { store, profile, client } = await dependencies.createClientContext({
|
|
271
449
|
...opts,
|
|
272
450
|
autoRefresh: false
|
|
273
451
|
});
|
|
274
452
|
client.clearAuth();
|
|
275
|
-
let authMethod = await chooseAuthMethod({
|
|
453
|
+
let authMethod = await dependencies.chooseAuthMethod({
|
|
276
454
|
client,
|
|
277
455
|
authMethodId: opts.authMethodId,
|
|
278
456
|
forcePrompt: !opts.authMethodId
|
|
279
457
|
});
|
|
458
|
+
let scopes = parseList(opts.scopes);
|
|
459
|
+
let previousAuth = validateIncrementalOAuthSetup({
|
|
460
|
+
enabled: opts.incremental,
|
|
461
|
+
authMethodName: authMethod.name,
|
|
462
|
+
previousAuth: opts.incremental ? store.getAuth(profile.id, authMethod.id) : null,
|
|
463
|
+
scopes,
|
|
464
|
+
inputProvided: opts.input !== undefined
|
|
465
|
+
});
|
|
466
|
+
if (opts.incremental && !authMethod.capabilities.getProfile?.enabled) {
|
|
467
|
+
throw new Error(
|
|
468
|
+
'Incremental OAuth setup requires this authentication method to expose a profile for account verification.'
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
if (previousAuth) {
|
|
472
|
+
assertOAuthProfileContinuity(previousAuth.profile, previousAuth.profile);
|
|
473
|
+
}
|
|
280
474
|
|
|
281
475
|
let defaultInput = authMethod.capabilities.getDefaultInput?.enabled
|
|
282
476
|
? ((await client.getDefaultAuthInput(authMethod.id)).input ?? {})
|
|
283
477
|
: {};
|
|
284
478
|
let authInput =
|
|
479
|
+
previousAuth?.input ??
|
|
285
480
|
parseJsonObject(opts.input, 'auth input') ??
|
|
286
481
|
(await promptForObjectSchema(authMethod.inputSchema, defaultInput));
|
|
287
482
|
|
|
288
|
-
if (authMethod.capabilities.handleChangedInput?.enabled) {
|
|
483
|
+
if (!previousAuth && authMethod.capabilities.handleChangedInput?.enabled) {
|
|
289
484
|
authInput =
|
|
290
485
|
(
|
|
291
486
|
await client.updateAuthInput({
|
|
@@ -299,31 +494,46 @@ let runAuthSetup = async (opts: AuthSetupOptions): Promise<SlatesStoredAuth> =>
|
|
|
299
494
|
let output: JsonObject;
|
|
300
495
|
let finalInput = authInput;
|
|
301
496
|
let callbackState: JsonObject | null = null;
|
|
302
|
-
let scopes = parseList(opts.scopes);
|
|
303
497
|
|
|
304
498
|
if (authMethod.type === 'auth.oauth') {
|
|
305
|
-
let
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
authMethod.id
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
499
|
+
let previousOAuthCredential = previousAuth?.oauthCredentialId
|
|
500
|
+
? store.getOAuthCredential(previousAuth.oauthCredentialId, authMethod.id)
|
|
501
|
+
: null;
|
|
502
|
+
let selectedOAuthCredential = opts.oauthCredential
|
|
503
|
+
? store.getOAuthCredential(opts.oauthCredential, authMethod.id)
|
|
504
|
+
: null;
|
|
505
|
+
let resolvedOAuthCredentials =
|
|
506
|
+
opts.incremental && previousAuth
|
|
507
|
+
? resolveIncrementalOAuthCredentials({
|
|
508
|
+
previousAuth,
|
|
509
|
+
linkedCredential: previousOAuthCredential,
|
|
510
|
+
selectedCredential: selectedOAuthCredential,
|
|
511
|
+
selectedCredentialRequested: opts.oauthCredential !== undefined,
|
|
512
|
+
clientId: opts.clientId,
|
|
513
|
+
clientSecret: opts.clientSecret
|
|
514
|
+
})
|
|
515
|
+
: await chooseOAuthCredentialsForSetup({
|
|
516
|
+
store,
|
|
517
|
+
authMethod,
|
|
518
|
+
clientId: opts.clientId,
|
|
519
|
+
clientSecret: opts.clientSecret,
|
|
520
|
+
oauthCredential: opts.oauthCredential
|
|
521
|
+
});
|
|
320
522
|
if (!resolvedOAuthCredentials) {
|
|
321
523
|
throw new Error(`Authentication method ${authMethod.id} is not OAuth.`);
|
|
322
524
|
}
|
|
323
525
|
|
|
324
526
|
let clientId = resolvedOAuthCredentials.clientId;
|
|
325
527
|
let clientSecret = resolvedOAuthCredentials.clientSecret;
|
|
326
|
-
scopes = await chooseScopes(authMethod, scopes);
|
|
528
|
+
scopes = await dependencies.chooseScopes(authMethod, scopes);
|
|
529
|
+
|
|
530
|
+
let callback = await dependencies.createOAuthCallbackListener();
|
|
531
|
+
let redirectUri = normalizeCallbackRedirectUriForIntegration(
|
|
532
|
+
opts.integration,
|
|
533
|
+
callback.redirectUri,
|
|
534
|
+
authMethod.id
|
|
535
|
+
);
|
|
536
|
+
console.log(`OAuth redirect URL: ${redirectUri}`);
|
|
327
537
|
|
|
328
538
|
let authorizationUrl = await client.getAuthorizationUrl({
|
|
329
539
|
authenticationMethodId: authMethod.id,
|
|
@@ -336,9 +546,9 @@ let runAuthSetup = async (opts: AuthSetupOptions): Promise<SlatesStoredAuth> =>
|
|
|
336
546
|
});
|
|
337
547
|
|
|
338
548
|
callbackState = authorizationUrl.callbackState ?? null;
|
|
339
|
-
finalInput = authorizationUrl.input ?? authInput;
|
|
549
|
+
finalInput = previousAuth?.input ?? authorizationUrl.input ?? authInput;
|
|
340
550
|
|
|
341
|
-
printBrowserUrl(authorizationUrl.authorizationUrl);
|
|
551
|
+
dependencies.printBrowserUrl(authorizationUrl.authorizationUrl);
|
|
342
552
|
let callbackResult = await callback.wait();
|
|
343
553
|
if (callbackResult.state !== callback.state) {
|
|
344
554
|
throw new Error('OAuth state mismatch.');
|
|
@@ -357,18 +567,27 @@ let runAuthSetup = async (opts: AuthSetupOptions): Promise<SlatesStoredAuth> =>
|
|
|
357
567
|
callbackState: callbackState ?? undefined
|
|
358
568
|
});
|
|
359
569
|
|
|
360
|
-
|
|
361
|
-
finalInput = authOutput.input ?? finalInput;
|
|
362
|
-
scopes = authOutput.scopes ?? scopes;
|
|
363
|
-
|
|
570
|
+
let grantedScopes = authOutput.scopes ?? scopes;
|
|
364
571
|
let profileInfo = authMethod.capabilities.getProfile?.enabled
|
|
365
572
|
? await client.getAuthProfile({
|
|
366
573
|
authenticationMethodId: authMethod.id,
|
|
367
|
-
output,
|
|
368
|
-
input: finalInput,
|
|
369
|
-
scopes
|
|
574
|
+
output: authOutput.output,
|
|
575
|
+
input: previousAuth?.input ?? authOutput.input ?? finalInput,
|
|
576
|
+
scopes: grantedScopes
|
|
370
577
|
})
|
|
371
578
|
: null;
|
|
579
|
+
let accumulatedAuthorization =
|
|
580
|
+
opts.incremental && previousAuth
|
|
581
|
+
? completeIncrementalOAuthAuthorization({
|
|
582
|
+
previousAuth,
|
|
583
|
+
output: authOutput.output,
|
|
584
|
+
grantedScopes,
|
|
585
|
+
profile: profileInfo?.profile
|
|
586
|
+
})
|
|
587
|
+
: { output: authOutput.output, scopes: grantedScopes };
|
|
588
|
+
output = accumulatedAuthorization.output;
|
|
589
|
+
finalInput = previousAuth?.input ?? authOutput.input ?? finalInput;
|
|
590
|
+
scopes = accumulatedAuthorization.scopes;
|
|
372
591
|
|
|
373
592
|
let stored = store.upsertAuth(profile.id, {
|
|
374
593
|
authMethodId: authMethod.id,
|
|
@@ -388,12 +607,12 @@ let runAuthSetup = async (opts: AuthSetupOptions): Promise<SlatesStoredAuth> =>
|
|
|
388
607
|
return stored;
|
|
389
608
|
}
|
|
390
609
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
610
|
+
let authOutput = await client.getAuthOutput({
|
|
611
|
+
authenticationMethodId: authMethod.id,
|
|
612
|
+
input: authInput
|
|
613
|
+
});
|
|
614
|
+
output = authOutput.output;
|
|
615
|
+
scopes = authOutput.scopes ?? scopes;
|
|
397
616
|
|
|
398
617
|
let profileInfo = authMethod.capabilities.getProfile?.enabled
|
|
399
618
|
? await client.getAuthProfile({
|
|
@@ -418,6 +637,9 @@ let runAuthSetup = async (opts: AuthSetupOptions): Promise<SlatesStoredAuth> =>
|
|
|
418
637
|
return stored;
|
|
419
638
|
};
|
|
420
639
|
|
|
640
|
+
export let runAuthSetup = async (opts: AuthSetupOptions): Promise<SlatesStoredAuth> =>
|
|
641
|
+
runAuthSetupWithDependencies(opts, authSetupRuntimeDependencies);
|
|
642
|
+
|
|
421
643
|
export let setupAuth = async (opts: AuthSetupOptions) => runAuthSetup(opts);
|
|
422
644
|
|
|
423
645
|
export let refreshAuth = async (opts: WithProfile & { authMethodId?: string }) => {
|
package/src/commands/config.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createClientContext } from '../lib/context';
|
|
2
2
|
import { parseJsonObject, promptForObjectSchema } from '../lib/prompts';
|
|
3
|
-
import { JsonInput, WithProfile } from '../lib/types';
|
|
3
|
+
import type { JsonInput, WithProfile } from '../lib/types';
|
|
4
4
|
|
|
5
5
|
export let getConfig = async (opts: WithProfile) => {
|
|
6
6
|
let { profile } = await createClientContext(opts);
|
package/src/commands/profiles.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { input } from '@inquirer/prompts';
|
|
2
|
-
import { createSlatesClientFromProfile, openSlatesCliStore } from '@slates/profiles';
|
|
2
|
+
import { createSlatesClientFromProfile, type openSlatesCliStore } from '@slates/profiles';
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import { chooseProfile, openIntegrationStore, syncProfileMetadata } from '../lib/context';
|
|
5
|
-
import { WithProfile } from '../lib/types';
|
|
5
|
+
import type { WithProfile } from '../lib/types';
|
|
6
6
|
|
|
7
7
|
let normalizeEntry = (rootDir: string, entry: string) => {
|
|
8
8
|
let absolute = path.isAbsolute(entry) ? entry : path.resolve(process.cwd(), entry);
|
|
@@ -12,7 +12,9 @@ let normalizeEntry = (rootDir: string, entry: string) => {
|
|
|
12
12
|
: absolute;
|
|
13
13
|
};
|
|
14
14
|
|
|
15
|
-
let getNextSetupProfileName = async (
|
|
15
|
+
let getNextSetupProfileName = async (
|
|
16
|
+
store: Awaited<ReturnType<typeof openSlatesCliStore>>
|
|
17
|
+
) => {
|
|
16
18
|
let names = new Set(store.listProfiles().map(profile => profile.name));
|
|
17
19
|
if (!names.has('default')) {
|
|
18
20
|
return 'default';
|
|
@@ -41,10 +43,14 @@ let createProfile = async (
|
|
|
41
43
|
|
|
42
44
|
let defaultName =
|
|
43
45
|
opts.name ??
|
|
44
|
-
(opts.initializeConfig
|
|
46
|
+
(opts.initializeConfig
|
|
47
|
+
? await getNextSetupProfileName(store)
|
|
48
|
+
: `profile-${store.listProfiles().length + 1}`);
|
|
45
49
|
let name =
|
|
46
50
|
opts.name ??
|
|
47
|
-
(interactive
|
|
51
|
+
(interactive
|
|
52
|
+
? await input({ message: 'Profile name', default: defaultName })
|
|
53
|
+
: defaultName);
|
|
48
54
|
let defaultEntry = opts.entry ?? integration.entry;
|
|
49
55
|
let entry =
|
|
50
56
|
opts.entry ??
|
|
@@ -56,7 +62,9 @@ let createProfile = async (
|
|
|
56
62
|
: defaultEntry);
|
|
57
63
|
let exportName =
|
|
58
64
|
opts.exportName ??
|
|
59
|
-
(interactive
|
|
65
|
+
(interactive
|
|
66
|
+
? await input({ message: 'Export name (optional)', default: 'provider' })
|
|
67
|
+
: 'provider');
|
|
60
68
|
|
|
61
69
|
let profile = store.upsertProfile({
|
|
62
70
|
name,
|
package/src/commands/repl.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { input } from '@inquirer/prompts';
|
|
2
2
|
import { print } from '../lib/prompts';
|
|
3
|
-
import { WithProfile } from '../lib/types';
|
|
3
|
+
import type { WithProfile } from '../lib/types';
|
|
4
4
|
import { listAuth, setupAuth } from './auth';
|
|
5
5
|
import { getConfig, setConfig } from './config';
|
|
6
6
|
import { getProfile } from './profiles';
|
package/src/commands/test.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { writeFile } from 'fs/promises';
|
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import { chooseProfile } from '../lib/context';
|
|
5
5
|
import { listWorkspaceIntegrations } from '../lib/integration';
|
|
6
|
-
import { WithProfile } from '../lib/types';
|
|
6
|
+
import type { WithProfile } from '../lib/types';
|
|
7
7
|
|
|
8
8
|
let runVitest = async (opts: {
|
|
9
9
|
cwd: string;
|
|
@@ -43,7 +43,7 @@ export let runVitestWithProfile = async (opts: WithProfile & { vitestArgs: strin
|
|
|
43
43
|
|
|
44
44
|
await writeFile(
|
|
45
45
|
contextPath,
|
|
46
|
-
JSON.stringify(
|
|
46
|
+
`${JSON.stringify(
|
|
47
47
|
{
|
|
48
48
|
integration: integration.relativeDir,
|
|
49
49
|
profileId: profile.id,
|
|
@@ -53,7 +53,7 @@ export let runVitestWithProfile = async (opts: WithProfile & { vitestArgs: strin
|
|
|
53
53
|
},
|
|
54
54
|
null,
|
|
55
55
|
2
|
|
56
|
-
)
|
|
56
|
+
)}\n`,
|
|
57
57
|
'utf-8'
|
|
58
58
|
);
|
|
59
59
|
|
package/src/commands/tools.ts
CHANGED
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
syncProfileMetadata
|
|
6
6
|
} from '../lib/context';
|
|
7
7
|
import { parseJsonObject, promptForObjectSchema } from '../lib/prompts';
|
|
8
|
-
import { JsonInput, WithProfile } from '../lib/types';
|
|
8
|
+
import type { JsonInput, WithProfile } from '../lib/types';
|
|
9
9
|
|
|
10
10
|
export let listTools = async (opts: WithProfile) => {
|
|
11
11
|
let { store, profile, client } = await createClientContext(opts);
|
package/src/lib/context.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { select } from '@inquirer/prompts';
|
|
2
|
-
import { SlatesProtocolClient } from '@slates/client';
|
|
2
|
+
import type { SlatesProtocolClient } from '@slates/client';
|
|
3
3
|
import {
|
|
4
4
|
createSlatesClientFromProfile,
|
|
5
5
|
openSlatesCliStore,
|
|
6
|
-
|
|
7
|
-
type
|
|
6
|
+
type SlatesCliStore,
|
|
7
|
+
type SlatesProfileRecord
|
|
8
8
|
} from '@slates/profiles';
|
|
9
9
|
import { resolveIntegration } from './integration';
|
|
10
10
|
import { promptForObjectSchema } from './prompts';
|
|
@@ -26,7 +26,11 @@ describe('resolveIntegration', () => {
|
|
|
26
26
|
JSON.stringify({ main: 'src/index.ts' }, null, 2),
|
|
27
27
|
'utf-8'
|
|
28
28
|
);
|
|
29
|
-
await writeFile(
|
|
29
|
+
await writeFile(
|
|
30
|
+
path.join(integrationDir, 'src', 'index.ts'),
|
|
31
|
+
'export let provider = {};\n',
|
|
32
|
+
'utf-8'
|
|
33
|
+
);
|
|
30
34
|
|
|
31
35
|
let resolved = await resolveIntegration('demo', { cwd });
|
|
32
36
|
|
|
@@ -44,7 +48,11 @@ describe('resolveIntegration', () => {
|
|
|
44
48
|
JSON.stringify({ source: 'src/index.ts' }, null, 2),
|
|
45
49
|
'utf-8'
|
|
46
50
|
);
|
|
47
|
-
await writeFile(
|
|
51
|
+
await writeFile(
|
|
52
|
+
path.join(integrationDir, 'src', 'index.ts'),
|
|
53
|
+
'export let provider = {};\n',
|
|
54
|
+
'utf-8'
|
|
55
|
+
);
|
|
48
56
|
|
|
49
57
|
let resolved = await resolveIntegration('./custom/demo', { cwd });
|
|
50
58
|
|
package/src/lib/integration.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { access, readFile, readdir } from 'fs/promises';
|
|
2
|
-
import path from 'path';
|
|
3
1
|
import { resolveSlatesCliRoot } from '@slates/profiles';
|
|
2
|
+
import { access, readdir, readFile } from 'fs/promises';
|
|
3
|
+
import path from 'path';
|
|
4
4
|
|
|
5
5
|
export interface ResolvedIntegration {
|
|
6
6
|
input: string;
|
|
@@ -142,7 +142,9 @@ export let listWorkspaceIntegrations = async (opts: { cwd?: string } = {}) => {
|
|
|
142
142
|
);
|
|
143
143
|
|
|
144
144
|
integrations.push(
|
|
145
|
-
...chunk.filter(
|
|
145
|
+
...chunk.filter(
|
|
146
|
+
(integration): integration is WorkspaceIntegrationSummary => integration !== null
|
|
147
|
+
)
|
|
146
148
|
);
|
|
147
149
|
}
|
|
148
150
|
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { createOAuthCallbackListener, getOAuthCallbackCode } from './oauth';
|
|
3
|
+
|
|
4
|
+
let originalOAuthPort = process.env.SLATES_OAUTH_PORT;
|
|
5
|
+
let originalOAuthCallbackOverride = process.env.OAUTH_CALLBACK_OVERRIDE;
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
process.env.SLATES_OAUTH_PORT = '0';
|
|
9
|
+
delete process.env.OAUTH_CALLBACK_OVERRIDE;
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
if (originalOAuthPort === undefined) {
|
|
14
|
+
delete process.env.SLATES_OAUTH_PORT;
|
|
15
|
+
} else {
|
|
16
|
+
process.env.SLATES_OAUTH_PORT = originalOAuthPort;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (originalOAuthCallbackOverride === undefined) {
|
|
20
|
+
delete process.env.OAUTH_CALLBACK_OVERRIDE;
|
|
21
|
+
} else {
|
|
22
|
+
process.env.OAUTH_CALLBACK_OVERRIDE = originalOAuthCallbackOverride;
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('getOAuthCallbackCode', () => {
|
|
27
|
+
it('returns an OAuth 2 code', () => {
|
|
28
|
+
expect(getOAuthCallbackCode(new URLSearchParams({ code: 'oauth2-code' }))).toBe(
|
|
29
|
+
'oauth2-code'
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('returns an OAuth 1.0a verifier', () => {
|
|
34
|
+
expect(
|
|
35
|
+
getOAuthCallbackCode(new URLSearchParams({ oauth_verifier: 'oauth1-verifier' }))
|
|
36
|
+
).toBe('oauth1-verifier');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('prefers an OAuth 2 code when both callback values are present', () => {
|
|
40
|
+
expect(
|
|
41
|
+
getOAuthCallbackCode(
|
|
42
|
+
new URLSearchParams({ code: 'oauth2-code', oauth_verifier: 'oauth1-verifier' })
|
|
43
|
+
)
|
|
44
|
+
).toBe('oauth2-code');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('returns null when neither callback value is present', () => {
|
|
48
|
+
expect(getOAuthCallbackCode(new URLSearchParams({ state: 'callback-state' }))).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('createOAuthCallbackListener', () => {
|
|
53
|
+
it('resolves OAuth 1.0a verifier callbacks with all callback parameters', async () => {
|
|
54
|
+
let listener = await createOAuthCallbackListener();
|
|
55
|
+
let callbackUrl = new URL(listener.redirectUri);
|
|
56
|
+
callbackUrl.search = new URLSearchParams({
|
|
57
|
+
state: listener.state,
|
|
58
|
+
oauth_verifier: 'oauth1-verifier',
|
|
59
|
+
oauth_token: 'request-token'
|
|
60
|
+
}).toString();
|
|
61
|
+
|
|
62
|
+
let [response, result] = await Promise.all([fetch(callbackUrl), listener.wait()]);
|
|
63
|
+
|
|
64
|
+
expect(response.status).toBe(200);
|
|
65
|
+
expect(result).toEqual({
|
|
66
|
+
code: 'oauth1-verifier',
|
|
67
|
+
state: listener.state,
|
|
68
|
+
callbackParams: {
|
|
69
|
+
state: listener.state,
|
|
70
|
+
oauth_verifier: 'oauth1-verifier',
|
|
71
|
+
oauth_token: 'request-token'
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('continues to resolve OAuth 2 code callbacks', async () => {
|
|
77
|
+
let listener = await createOAuthCallbackListener();
|
|
78
|
+
let callbackUrl = new URL(listener.redirectUri);
|
|
79
|
+
callbackUrl.search = new URLSearchParams({
|
|
80
|
+
state: listener.state,
|
|
81
|
+
code: 'oauth2-code'
|
|
82
|
+
}).toString();
|
|
83
|
+
|
|
84
|
+
let [response, result] = await Promise.all([fetch(callbackUrl), listener.wait()]);
|
|
85
|
+
|
|
86
|
+
expect(result).toMatchObject({
|
|
87
|
+
code: 'oauth2-code',
|
|
88
|
+
state: listener.state,
|
|
89
|
+
callbackParams: { state: listener.state, code: 'oauth2-code' }
|
|
90
|
+
});
|
|
91
|
+
expect(response.status).toBe(200);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('rejects callbacks without state', async () => {
|
|
95
|
+
let listener = await createOAuthCallbackListener();
|
|
96
|
+
let callbackUrl = new URL(listener.redirectUri);
|
|
97
|
+
callbackUrl.search = new URLSearchParams({ code: 'oauth2-code' }).toString();
|
|
98
|
+
|
|
99
|
+
let result = expect(listener.wait()).rejects.toThrow(
|
|
100
|
+
'OAuth callback did not include the required query parameters.'
|
|
101
|
+
);
|
|
102
|
+
let response = await fetch(callbackUrl);
|
|
103
|
+
|
|
104
|
+
expect(response.status).toBe(400);
|
|
105
|
+
await result;
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('rejects OAuth error callbacks', async () => {
|
|
109
|
+
let listener = await createOAuthCallbackListener();
|
|
110
|
+
let callbackUrl = new URL(listener.redirectUri);
|
|
111
|
+
callbackUrl.search = new URLSearchParams({
|
|
112
|
+
state: listener.state,
|
|
113
|
+
error: 'access_denied',
|
|
114
|
+
error_description: 'The user denied access.',
|
|
115
|
+
error_uri: 'https://example.com/oauth-error'
|
|
116
|
+
}).toString();
|
|
117
|
+
|
|
118
|
+
let result = expect(listener.wait()).rejects.toThrow(
|
|
119
|
+
'OAuth callback returned "access_denied": The user denied access. (https://example.com/oauth-error)'
|
|
120
|
+
);
|
|
121
|
+
let response = await fetch(callbackUrl);
|
|
122
|
+
|
|
123
|
+
expect(response.status).toBe(400);
|
|
124
|
+
await result;
|
|
125
|
+
});
|
|
126
|
+
});
|
package/src/lib/oauth.ts
CHANGED
|
@@ -4,6 +4,21 @@ import { createServer } from 'http';
|
|
|
4
4
|
|
|
5
5
|
let DEFAULT_OAUTH_CALLBACK_PORT = 45873;
|
|
6
6
|
|
|
7
|
+
let resolveAdvertisedRedirectUri = (localPort: number) => {
|
|
8
|
+
let override = process.env.OAUTH_CALLBACK_OVERRIDE;
|
|
9
|
+
if (!override) return `http://127.0.0.1:${localPort}/callback`;
|
|
10
|
+
|
|
11
|
+
let url: URL;
|
|
12
|
+
try {
|
|
13
|
+
url = new URL(override);
|
|
14
|
+
} catch {
|
|
15
|
+
throw new Error(`OAUTH_CALLBACK_OVERRIDE must be an absolute URL, received: ${override}`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (url.pathname === '' || url.pathname === '/') url.pathname = '/callback';
|
|
19
|
+
return url.toString();
|
|
20
|
+
};
|
|
21
|
+
|
|
7
22
|
export let chooseScopes = async (
|
|
8
23
|
authMethod: any,
|
|
9
24
|
initialScopes: string[]
|
|
@@ -18,10 +33,7 @@ export let chooseScopes = async (
|
|
|
18
33
|
choices: authMethod.scopes.map((scope: any) => ({
|
|
19
34
|
name: `${scope.title} (${scope.id})`,
|
|
20
35
|
value: scope.id,
|
|
21
|
-
checked:
|
|
22
|
-
initialScopes.length > 0
|
|
23
|
-
? initialScopes.includes(scope.id)
|
|
24
|
-
: (scope.defaultChecked ?? true)
|
|
36
|
+
checked: initialScopes.length > 0 ? initialScopes.includes(scope.id) : true
|
|
25
37
|
}))
|
|
26
38
|
})) as string[];
|
|
27
39
|
};
|
|
@@ -30,6 +42,9 @@ export let printBrowserUrl = (url: string) => {
|
|
|
30
42
|
console.log(`Open this URL in your browser:\n${url}`);
|
|
31
43
|
};
|
|
32
44
|
|
|
45
|
+
export let getOAuthCallbackCode = (params: URLSearchParams) =>
|
|
46
|
+
params.get('code') ?? params.get('oauth_verifier');
|
|
47
|
+
|
|
33
48
|
export let createOAuthCallbackListener = async () => {
|
|
34
49
|
return new Promise<{
|
|
35
50
|
redirectUri: string;
|
|
@@ -47,7 +62,7 @@ export let createOAuthCallbackListener = async () => {
|
|
|
47
62
|
let server = createServer((req, res) => {
|
|
48
63
|
try {
|
|
49
64
|
let url = new URL(req.url ?? '/', 'http://127.0.0.1');
|
|
50
|
-
let code = url.searchParams
|
|
65
|
+
let code = getOAuthCallbackCode(url.searchParams);
|
|
51
66
|
let state = url.searchParams.get('state');
|
|
52
67
|
let oauthError = url.searchParams.get('error');
|
|
53
68
|
let oauthErrorDescription = url.searchParams.get('error_description');
|
|
@@ -153,7 +168,7 @@ export let createOAuthCallbackListener = async () => {
|
|
|
153
168
|
}
|
|
154
169
|
|
|
155
170
|
resolve({
|
|
156
|
-
redirectUri:
|
|
171
|
+
redirectUri: resolveAdvertisedRedirectUri(address.port),
|
|
157
172
|
state: expectedState,
|
|
158
173
|
wait: () => waiter.promise
|
|
159
174
|
});
|
package/src/lib/prompts.ts
CHANGED