@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@slates/cli",
3
- "version": "1.0.0-rc.6",
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
- "lint": "prettier src/**/*.ts --check",
24
- "typecheck": "tsc --noEmit"
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.8",
29
- "@slates/oauth-microsoft": "1.0.0-rc.3",
30
- "@slates/profiles": "1.0.0-rc.6",
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 { normalizeCallbackRedirectUriForIntegration } from './auth';
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')
@@ -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 runAuthSetup = async (opts: AuthSetupOptions): Promise<SlatesStoredAuth> => {
270
- let { store, profile, client } = await createClientContext({
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 callback = await createOAuthCallbackListener();
306
- let redirectUri = normalizeCallbackRedirectUriForIntegration(
307
- opts.integration,
308
- callback.redirectUri,
309
- authMethod.id
310
- );
311
- console.log(`OAuth redirect URL: ${redirectUri}`);
312
-
313
- let resolvedOAuthCredentials = await chooseOAuthCredentialsForSetup({
314
- store,
315
- authMethod,
316
- clientId: opts.clientId,
317
- clientSecret: opts.clientSecret,
318
- oauthCredential: opts.oauthCredential
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
- output = authOutput.output;
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
- output = (
392
- await client.getAuthOutput({
393
- authenticationMethodId: authMethod.id,
394
- input: authInput
395
- })
396
- ).output;
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 }) => {
@@ -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);
@@ -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 (store: Awaited<ReturnType<typeof openSlatesCliStore>>) => {
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 ? await getNextSetupProfileName(store) : `profile-${store.listProfiles().length + 1}`);
46
+ (opts.initializeConfig
47
+ ? await getNextSetupProfileName(store)
48
+ : `profile-${store.listProfiles().length + 1}`);
45
49
  let name =
46
50
  opts.name ??
47
- (interactive ? await input({ message: 'Profile name', default: defaultName }) : defaultName);
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 ? await input({ message: 'Export name (optional)', default: 'provider' }) : 'provider');
65
+ (interactive
66
+ ? await input({ message: 'Export name (optional)', default: 'provider' })
67
+ : 'provider');
60
68
 
61
69
  let profile = store.upsertProfile({
62
70
  name,
@@ -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';
@@ -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
- ) + '\n',
56
+ )}\n`,
57
57
  'utf-8'
58
58
  );
59
59
 
@@ -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);
@@ -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
- SlatesProfileRecord,
7
- type SlatesCliStore
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(path.join(integrationDir, 'src', 'index.ts'), 'export let provider = {};\n', 'utf-8');
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(path.join(integrationDir, 'src', 'index.ts'), 'export let provider = {};\n', 'utf-8');
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
 
@@ -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((integration): integration is WorkspaceIntegrationSummary => integration !== null)
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.get('code');
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: `http://127.0.0.1:${address.port}/callback`,
171
+ redirectUri: resolveAdvertisedRedirectUri(address.port),
157
172
  state: expectedState,
158
173
  wait: () => waiter.promise
159
174
  });
@@ -1,5 +1,5 @@
1
1
  import { checkbox, confirm, input, password, select } from '@inquirer/prompts';
2
- import { JsonObject } from './types';
2
+ import type { JsonObject } from './types';
3
3
 
4
4
  export let prettyJson = (value: unknown) => JSON.stringify(value, null, 2);
5
5