Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion client/packages/cli/__tests__/authClientAddGithub.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { test, expect, describe, vi, beforeEach } from 'vitest';
import { test, expect, describe, vi, beforeEach, afterEach } from 'vitest';
import { Effect, Layer, Logger } from 'effect';
import * as NodeContext from '@effect/platform-node/NodeContext';
import { GlobalOpts } from '../src/context/globalOpts.ts';
Expand Down Expand Up @@ -90,12 +90,17 @@ const withEntry = (flags: Map<string, string>, key: string, value: string) =>
new Map([...flags, [key, value]]);

beforeEach(() => {
vi.stubEnv('INSTANT_CLI_API_URI', undefined);
prompts = [];
addedClients = [];
logs = [];
mockPromptReturn = '';
});

afterEach(() => {
vi.unstubAllEnvs();
});

// -- flag sets --

const webFlags = new Map([
Expand Down Expand Up @@ -186,6 +191,16 @@ describe('interactive prompts for each missing flag', () => {
// -- success cases --

describe('success', () => {
test('uses the configured API URI for the callback URL', async () => {
vi.stubEnv('INSTANT_CLI_API_URI', 'https://api.instant.example');

await run(webFlags, { yes: true });

expect(addedClients[0].redirectTo).toBe(
'https://api.instant.example/runtime/oauth/callback',
);
});

test('all required flags → creates client and prints callback URL', async () => {
await run(webFlags, { yes: true });
expect(addedClients).toHaveLength(1);
Expand Down
12 changes: 11 additions & 1 deletion client/packages/cli/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ vi.mock('../src/util/instantConfig.ts', () => ({
readInstantConfigFile: mocks.readInstantConfigFile,
}));

import { getDashUrl } from '../src/lib/config.ts';
import { getDashUrl, getOAuthCallbackUrl } from '../src/lib/config.ts';

beforeEach(() => {
vi.stubEnv('INSTANT_CLI_API_URI', undefined);
Expand Down Expand Up @@ -45,3 +45,13 @@ describe('dashboard URL configuration', () => {
);
});
});

describe('OAuth callback URL configuration', () => {
it('uses the configured API URL', async () => {
vi.stubEnv('INSTANT_CLI_API_URI', 'https://api.instant.example/');

await expect(Effect.runPromise(getOAuthCallbackUrl)).resolves.toBe(
'https://api.instant.example/runtime/oauth/callback',
);
});
});
13 changes: 13 additions & 0 deletions client/packages/cli/__tests__/redirectUriPrompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,21 @@ import { expect, test } from 'vitest';
import stripAnsi from 'strip-ansi';
import { redirectUriPrompt } from '../src/commands/auth/client/shared.ts';

test('redirectUriPrompt shows the configured callback URL', () => {
const prompt = redirectUriPrompt({
heading: 'Custom redirect URI (optional):',
oauthCallbackURL: 'https://api.example.com/runtime/oauth/callback',
});

const output = stripAnsi(prompt.modifyOutput!('\n', 'idle'));

expect(output).toContain('https://api.example.com/runtime/oauth/callback');
});

test('redirectUriPrompt shows skipped when submitted empty', () => {
const prompt = redirectUriPrompt({
heading: 'Custom redirect URI (optional):',
oauthCallbackURL: 'https://api.example.com/runtime/oauth/callback',
});

const output = stripAnsi(prompt.modifyOutput!('\n', 'submitted'));
Expand All @@ -15,6 +27,7 @@ test('redirectUriPrompt shows skipped when submitted empty', () => {
test('redirectUriPrompt shows submitted custom redirect URI', () => {
const prompt = redirectUriPrompt({
heading: 'Custom redirect URI (optional):',
oauthCallbackURL: 'https://api.example.com/runtime/oauth/callback',
});

const output = stripAnsi(
Expand Down
60 changes: 41 additions & 19 deletions client/packages/cli/src/commands/auth/client/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import {
OAuthClient,
} from '../../../lib/oauth.ts';
import {
DEFAULT_OAUTH_CALLBACK_URL,
GOOGLE_AUTHORIZATION_ENDPOINT,
GOOGLE_DISCOVERY_ENDPOINT,
GOOGLE_TOKEN_ENDPOINT,
Expand All @@ -25,6 +24,7 @@ import {
LINKEDIN_DISCOVERY_ENDPOINT,
LINKEDIN_TOKEN_ENDPOINT,
} from '@instantdb/platform';
import { getOAuthCallbackUrl } from '../../../lib/config.ts';
import { UI } from '../../../ui/index.ts';
import chalk from 'chalk';
import boxen from 'boxen';
Expand Down Expand Up @@ -58,9 +58,11 @@ const googleConsoleUrl =
'https://console.developers.google.com/apis/credentials';
const githubDeveloperUrl = 'https://github.com/settings/developers';
const linkedinDeveloperUrl = 'https://www.linkedin.com/developers/apps';
const optionalRedirectPrompt = redirectUriPrompt({
heading: 'Custom redirect URI (optional):',
});
const optionalRedirectPrompt = (oauthCallbackURL: string) =>
redirectUriPrompt({
heading: 'Custom redirect URI (optional):',
oauthCallbackURL,
});

const selectGoogleAppType = (value: unknown) =>
Effect.gen(function* () {
Expand Down Expand Up @@ -220,19 +222,22 @@ const printGoogleCustomCredentialsClient = Effect.fn(function* ({
clientId,
customRedirectUri,
redirectUri,
oauthCallbackURL,
}: {
appType: typeof GoogleAppTypeSchema.Type;
client: typeof OAuthClient.Type;
clientId: string | undefined;
customRedirectUri: string | undefined;
redirectUri: string | undefined;
oauthCallbackURL: string;
}) {
const redirectMessages: string[] = [];
if (appType === 'web' && redirectUri) {
redirectMessages.push(
...redirectSetupMessages({
prompt: 'Add this redirect URI in Google Console',
redirectUri,
oauthCallbackURL,
showCustomRedirectInstructions: Boolean(customRedirectUri),
}),
);
Expand All @@ -252,7 +257,10 @@ const printGoogleCustomCredentialsClient = Effect.fn(function* ({
);
});

const handleGoogleClient = Effect.fn(function* (opts: Record<string, unknown>) {
const handleGoogleClient = Effect.fn(function* (
opts: Record<string, unknown>,
oauthCallbackURL: string,
) {
// This one requires special logic for getting client name
// because the suggested name includes the app type
const appType = yield* selectGoogleAppType(opts['app-type']);
Expand Down Expand Up @@ -314,13 +322,13 @@ const handleGoogleClient = Effect.fn(function* (opts: Record<string, unknown>) {
? '--custom-redirect-uri is not compatible with --dev-credentials.'
: 'Provided custom redirect URI when not using web app type.',
}),
Args.prompt(optionalRedirectPrompt),
Args.prompt(optionalRedirectPrompt(oauthCallbackURL)),
Args.optional(),
);

const redirectUri = useSharedCredentials
? undefined
: customRedirectUri || DEFAULT_OAUTH_CALLBACK_URL;
: customRedirectUri || oauthCallbackURL;

const response = yield* addOAuthClient({
providerId: provider.id,
Expand Down Expand Up @@ -352,10 +360,14 @@ const handleGoogleClient = Effect.fn(function* (opts: Record<string, unknown>) {
clientId,
customRedirectUri,
redirectUri,
oauthCallbackURL,
});
});

const handleGithubClient = Effect.fn(function* (opts: Record<string, unknown>) {
const handleGithubClient = Effect.fn(function* (
opts: Record<string, unknown>,
oauthCallbackURL: string,
) {
const { clientName, provider } = yield* getClientNameAndProvider(
'github',
opts,
Expand All @@ -372,11 +384,11 @@ const handleGithubClient = Effect.fn(function* (opts: Record<string, unknown>) {
);

const customRedirectUri = yield* Args.text(opts, 'custom-redirect-uri').pipe(
Args.prompt(optionalRedirectPrompt),
Args.prompt(optionalRedirectPrompt(oauthCallbackURL)),
Args.optional(),
);

const redirectUri = customRedirectUri || DEFAULT_OAUTH_CALLBACK_URL;
const redirectUri = customRedirectUri || oauthCallbackURL;

// The backend infers GitHub's authorization/token endpoints from
// meta.providerName === 'github', so we don't pass them here.
Expand All @@ -392,6 +404,7 @@ const handleGithubClient = Effect.fn(function* (opts: Record<string, unknown>) {
const redirectMessages = redirectSetupMessages({
prompt: 'Add this callback URL in your GitHub OAuth App settings',
redirectUri,
oauthCallbackURL,
showCustomRedirectInstructions: Boolean(customRedirectUri),
});

Expand All @@ -410,6 +423,7 @@ const handleGithubClient = Effect.fn(function* (opts: Record<string, unknown>) {

const handleLinkedInClient = Effect.fn(function* (
opts: Record<string, unknown>,
oauthCallbackURL: string,
) {
const { clientName, provider } = yield* getClientNameAndProvider(
'linkedin',
Expand All @@ -427,11 +441,11 @@ const handleLinkedInClient = Effect.fn(function* (
);

const customRedirectUri = yield* Args.text(opts, 'custom-redirect-uri').pipe(
Args.prompt(optionalRedirectPrompt),
Args.prompt(optionalRedirectPrompt(oauthCallbackURL)),
Args.optional(),
);

const redirectUri = customRedirectUri || DEFAULT_OAUTH_CALLBACK_URL;
const redirectUri = customRedirectUri || oauthCallbackURL;

const response = yield* addOAuthClient({
providerId: provider.id,
Expand All @@ -447,6 +461,7 @@ const handleLinkedInClient = Effect.fn(function* (
const redirectMessages = redirectSetupMessages({
prompt: 'Add this redirect URI in your LinkedIn app settings',
redirectUri,
oauthCallbackURL,
showCustomRedirectInstructions: Boolean(customRedirectUri),
});

Expand All @@ -463,7 +478,10 @@ const handleLinkedInClient = Effect.fn(function* (
);
});

const handleAppleClient = Effect.fn(function* (opts: Record<string, unknown>) {
const handleAppleClient = Effect.fn(function* (
opts: Record<string, unknown>,
oauthCallbackURL: string,
) {
const { clientName, provider } = yield* getClientNameAndProvider(
'apple',
opts,
Expand Down Expand Up @@ -529,12 +547,12 @@ const handleAppleClient = Effect.fn(function* (opts: Record<string, unknown>) {
Args.availableWhen(!skipWeb, {
message: `--custom-redirect-uri ${webSkipMessage}`,
}),
Args.prompt(optionalRedirectPrompt),
Args.prompt(optionalRedirectPrompt(oauthCallbackURL)),
Args.optional(),
);

const redirectUri = privateKey
? customRedirectUri || DEFAULT_OAUTH_CALLBACK_URL
? customRedirectUri || oauthCallbackURL
: undefined;

const meta: { teamId?: string; keyId?: string } = {};
Expand Down Expand Up @@ -566,6 +584,7 @@ const handleAppleClient = Effect.fn(function* (opts: Record<string, unknown>) {
...redirectSetupMessages({
prompt: `Add this return URL under your Services ID on ${link('https://developer.apple.com', 'developer.apple.com')}`,
redirectUri,
oauthCallbackURL,
showCustomRedirectInstructions: Boolean(customRedirectUri),
}),
);
Expand Down Expand Up @@ -697,13 +716,16 @@ export const authClientAddCmd = Effect.fn(
}),
),
);
const oauthCallbackURL = yield* getOAuthCallbackUrl;

yield* Match.value(clientType).pipe(
Match.withReturnType<Effect.Effect<void, any, any>>(),
Match.when('google', () => handleGoogleClient(opts)),
Match.when('github', () => handleGithubClient(opts)),
Match.when('apple', () => handleAppleClient(opts)),
Match.when('linkedin', () => handleLinkedInClient(opts)),
Match.when('google', () => handleGoogleClient(opts, oauthCallbackURL)),
Match.when('github', () => handleGithubClient(opts, oauthCallbackURL)),
Match.when('apple', () => handleAppleClient(opts, oauthCallbackURL)),
Match.when('linkedin', () =>
handleLinkedInClient(opts, oauthCallbackURL),
),
Match.when('clerk', () => handleClerkClient(opts)),
Match.when('firebase', () => handleFirebaseClient(opts)),
Match.exhaustive,
Expand Down
15 changes: 11 additions & 4 deletions client/packages/cli/src/commands/auth/client/shared.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { FileSystem } from '@effect/platform';
import { Effect } from 'effect';
import { DEFAULT_OAUTH_CALLBACK_URL } from '@instantdb/platform';
import chalk from 'chalk';
import { BadArgsError } from '../../../errors.ts';
import { link } from '../../../logging.ts';
Expand Down Expand Up @@ -38,7 +37,13 @@ export const clientSecretPrompt = ({
]),
});

export const redirectUriPrompt = ({ heading }: { heading: string }) => ({
export const redirectUriPrompt = ({
heading,
oauthCallbackURL,
}: {
heading: string;
oauthCallbackURL: string;
}) => ({
prompt: '',
placeholder: 'https://yoursite.com/oauth/callback',
modifyOutput: UI.modifiers.piped([
Expand All @@ -47,7 +52,7 @@ export const redirectUriPrompt = ({ heading }: { heading: string }) => ({
return (
`\n${heading}
${chalk.dim('With a custom redirect URI, users will see "Redirecting to yoursite.com..." for a more branded experience.')}
${chalk.dim(`Your URI must forward to ${DEFAULT_OAUTH_CALLBACK_URL} with all query parameters preserved.`)}\n\n` +
${chalk.dim(`Your URI must forward to ${oauthCallbackURL} with all query parameters preserved.`)}\n\n` +
stripFirstBlankLine(output)
);
}
Expand All @@ -64,18 +69,20 @@ ${chalk.dim(`Your URI must forward to ${DEFAULT_OAUTH_CALLBACK_URL} with all que
export const redirectSetupMessages = ({
prompt,
redirectUri,
oauthCallbackURL,
showCustomRedirectInstructions,
}: {
prompt: string;
redirectUri: string;
oauthCallbackURL: string;
showCustomRedirectInstructions?: boolean;
}) => {
const messages = ['', chalk.bold(`${prompt}:`), chalk.bold(redirectUri)];

if (showCustomRedirectInstructions) {
messages.push(
'',
`Your custom redirect must forward to ${chalk.bold(DEFAULT_OAUTH_CALLBACK_URL)} with all query parameters preserved.`,
`Your custom redirect must forward to ${chalk.bold(oauthCallbackURL)} with all query parameters preserved.`,
);
messages.push(
`You can test it by visiting: ${chalk.bold(redirectUri + '?test-redirect=true')}`,
Expand Down
Loading
Loading