Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 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
6 changes: 6 additions & 0 deletions client/packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@
"directory": "client/packages/cli"
},
"exports": {
"./auth": {
"import": {
"types": "./dist/auth.d.ts",
"default": "./dist/auth.js"
}
},
"./ui": {
"import": {
"types": "./dist/ui/index.d.ts",
Expand Down
181 changes: 181 additions & 0 deletions client/packages/cli/src/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { getAuthPaths } from './util/getAuthPaths.ts';

type AuthTokens = Record<string, string>;

const productionApiUrl = 'https://api.instantdb.com';

type AuthConfig =
| { type: 'map'; tokens: AuthTokens }
| { type: 'legacy'; token: string }
| { type: 'invalid' };

type AuthPaths = ReturnType<typeof getAuthPaths>;

function normalizeApiUrl(apiUrl: string): string {
return apiUrl.replace(/\/+$/, '');
}

function parseAuthConfig(contents: string): AuthConfig {
if (!contents) return { type: 'invalid' };

let parsed: unknown;
try {
parsed = JSON.parse(contents);
} catch {
const trimmed = contents.trimStart();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
return { type: 'invalid' };
}
return { type: 'legacy', token: contents };
}
Comment thread
drew-harris marked this conversation as resolved.

if (
parsed === null ||
Array.isArray(parsed) ||
typeof parsed !== 'object' ||
!Object.values(parsed).every((token) => typeof token === 'string')
) {
return { type: 'invalid' };
}

const tokens: AuthTokens = {};
for (const [apiUrl, token] of Object.entries(parsed)) {
tokens[normalizeApiUrl(apiUrl)] = token as string;
}
return { type: 'map', tokens };
}

function serializeAuthTokens(tokens: AuthTokens): string {
return JSON.stringify(tokens, null, 2) + '\n';
}

async function readAuthConfigFile(paths: AuthPaths): Promise<string | null> {
try {
return await readFile(paths.authConfigFilePath, 'utf8');
} catch (error) {
if (isNotFoundError(error)) return null;
throw error;
}
}

async function writeAuthConfigFile(paths: AuthPaths, tokens: AuthTokens) {
await mkdir(paths.appConfigDirPath, { recursive: true });
await writeFile(
paths.authConfigFilePath,
serializeAuthTokens(tokens),
'utf8',
);
}
Comment on lines +62 to +69

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Auth token file/dir written without restrictive permissions.

writeAuthConfigFile persists bearer tokens via mkdir/writeFile with default modes (typically 644/755 after umask), making the credentials file world-readable on shared/multi-user systems.

🔒 Suggested fix
 async function writeAuthConfigFile(paths: AuthPaths, tokens: AuthTokens) {
-  await mkdir(paths.appConfigDirPath, { recursive: true });
+  await mkdir(paths.appConfigDirPath, { recursive: true, mode: 0o700 });
   await writeFile(
     paths.authConfigFilePath,
     serializeAuthTokens(tokens),
-    'utf8',
+    { encoding: 'utf8', mode: 0o600 },
   );
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async function writeAuthConfigFile(paths: AuthPaths, tokens: AuthTokens) {
await mkdir(paths.appConfigDirPath, { recursive: true });
await writeFile(
paths.authConfigFilePath,
serializeAuthTokens(tokens),
'utf8',
);
}
async function writeAuthConfigFile(paths: AuthPaths, tokens: AuthTokens) {
await mkdir(paths.appConfigDirPath, { recursive: true, mode: 0o700 });
await writeFile(
paths.authConfigFilePath,
serializeAuthTokens(tokens),
{ encoding: 'utf8', mode: 0o600 },
);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/packages/cli/src/auth.ts` around lines 62 - 69, Update
writeAuthConfigFile to create the auth directory and token file with restrictive
permissions: use a private directory mode and a file mode that prevents
group/other access when calling mkdir and writeFile. Preserve the existing
paths, serialization, and UTF-8 encoding.


function isNotFoundError(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && 'code' in error && error.code === 'ENOENT';
}

async function tokenBelongsToApiUrl(apiUrl: string, authToken: string) {
try {
const response = await fetch(`${apiUrl}/dash/me`, {
headers: { Authorization: `Bearer ${authToken}` },
signal: AbortSignal.timeout(5_000),
});
return response.ok;
} catch {
return false;
}
}

async function getLegacyTokenApiUrl(apiUrl: string, authToken: string) {
if (await tokenBelongsToApiUrl(apiUrl, authToken)) return apiUrl;
if (
apiUrl !== productionApiUrl &&
(await tokenBelongsToApiUrl(productionApiUrl, authToken))
) {
return productionApiUrl;
}
return null;
}

export async function readConfigAuthToken(
apiUrl: string,
): Promise<string | null> {
const paths = getAuthPaths();
const contents = await readAuthConfigFile(paths);
if (contents === null) return null;

const config = parseAuthConfig(contents);
const key = normalizeApiUrl(apiUrl);
if (config.type === 'map') return config.tokens[key] || null;
if (config.type === 'invalid') return null;

const migrationKey = await getLegacyTokenApiUrl(key, config.token);

if (migrationKey) {
await writeAuthConfigFile(paths, {
[migrationKey]: config.token,
}).catch(() => {});
}

// If production accepted the token while another backend is selected, do
// not send a known production credential to that backend.
return migrationKey && migrationKey !== key ? null : config.token;
}

export async function saveConfigAuthToken(
apiUrl: string,
authToken: string,
): Promise<void> {
const paths = getAuthPaths();
const contents = await readAuthConfigFile(paths);
const config = contents === null ? null : parseAuthConfig(contents);
const key = normalizeApiUrl(apiUrl);
let tokens: AuthTokens = {};
if (config?.type === 'map') {
tokens = config.tokens;
} else if (config?.type === 'legacy') {
const legacyKey = await getLegacyTokenApiUrl(key, config.token);
if (legacyKey && legacyKey !== key) {
tokens[legacyKey] = config.token;
}
}
tokens[key] = authToken;
await writeAuthConfigFile(paths, tokens);
}

export async function removeConfigAuthToken(
apiUrl: string,
): Promise<'removed' | 'not-found'> {
const paths = getAuthPaths();
const contents = await readAuthConfigFile(paths);
if (contents === null) return 'not-found';

const config = parseAuthConfig(contents);
if (config.type === 'legacy') {
const key = normalizeApiUrl(apiUrl);
if (key === productionApiUrl) {
await rm(paths.authConfigFilePath);
return 'removed';
}

const legacyKey = await getLegacyTokenApiUrl(key, config.token);
if (legacyKey === key) {
await rm(paths.authConfigFilePath);
return 'removed';
}
if (legacyKey) {
await writeAuthConfigFile(paths, { [legacyKey]: config.token });
}
return 'not-found';
}
if (config.type === 'invalid') return 'not-found';

const key = normalizeApiUrl(apiUrl);
if (!(key in config.tokens)) return 'not-found';

delete config.tokens[key];
if (Object.keys(config.tokens).length === 0) {
await rm(paths.authConfigFilePath);
} else {
await writeAuthConfigFile(paths, config.tokens);
}
return 'removed';
}
31 changes: 14 additions & 17 deletions client/packages/cli/src/commands/logout.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,20 @@
import { Effect } from 'effect';
import { getAuthPaths } from '../util/getAuthPaths.ts';
import { FileSystem } from '@effect/platform';
import chalk from 'chalk';
import { SystemError } from '@effect/platform/Error';
import { removeConfigAuthToken } from '../auth.ts';
import { getBaseUrl } from '../util/apiUrl.ts';

export const logoutCommand = Effect.fn(function* () {
const { authConfigFilePath } = getAuthPaths();
const fs = yield* FileSystem.FileSystem;
const apiUrl = yield* getBaseUrl;

yield* Effect.matchEffect(fs.remove(authConfigFilePath), {
onFailure: (e) =>
Effect.gen(function* () {
if (e instanceof SystemError && e.reason === 'NotFound') {
yield* Effect.log(chalk.green('You were already logged out!'));
} else {
yield* Effect.logError(chalk.red('Failed to logout: ' + e.message));
}
}),
onSuccess: () =>
Effect.log(chalk.green('Successfully logged out from Instant!')),
});
yield* Effect.matchEffect(
Effect.tryPromise(() => removeConfigAuthToken(apiUrl)),
{
onFailure: (e) =>
Effect.logError(chalk.red('Failed to logout: ' + e.message)),
onSuccess: (result) =>
result === 'removed'
? Effect.log(chalk.green('Successfully logged out from Instant!'))
: Effect.log(chalk.green('You were already logged out!')),
},
);
});
31 changes: 8 additions & 23 deletions client/packages/cli/src/context/authToken.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { FileSystem } from '@effect/platform';
import { Config, Context, Effect, Layer, Option, Ref, Schema } from 'effect';
import envPaths from 'env-paths';
import { join } from 'node:path';
import { readConfigAuthToken } from '../auth.ts';
import { loginCommand } from '../commands/login.ts';
import { program } from '../program.ts';
import { getBaseUrl } from '../util/apiUrl.ts';

type AuthTokenSource = 'admin' | 'env' | 'opt' | 'file';

Expand Down Expand Up @@ -64,17 +63,13 @@ export const authTokenGetEffect = (allowAdminToken: boolean = true) =>
};
}

const authPaths = yield* getAuthPaths;
const fs = yield* FileSystem.FileSystem;
const file = yield* fs
.readFileString(authPaths.authConfigFilePath, 'utf8')
.pipe(
// will usually fail if file not found, return null instead
Effect.orElseSucceed(() => null),
);
if (file) {
const apiUrl = yield* getBaseUrl;
const fileToken = yield* Effect.tryPromise(() =>
readConfigAuthToken(apiUrl),
).pipe(Effect.orElseSucceed(() => null));
if (fileToken) {
return {
authToken: file,
authToken: fileToken,
source: 'file' as 'env' | 'opt' | 'file',
};
}
Expand Down Expand Up @@ -137,13 +132,3 @@ export const AuthTokenLive = ({
),
),
);

const getAuthPaths = Effect.gen(function* () {
const dev = yield* Config.boolean('INSTANT_CLI_DEV').pipe(
Config.withDefault(false),
);
const key = `instantdb-${dev ? 'dev' : 'prod'}`;
const { config: appConfigDirPath } = envPaths(key);
const authConfigFilePath = join(appConfigDirPath, 'a');
return { authConfigFilePath, appConfigDirPath };
});
42 changes: 3 additions & 39 deletions client/packages/cli/src/lib/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import { Config, Context, Data, Effect, Layer, Option, Schema } from 'effect';
import { AuthToken } from '../context/authToken.ts';
import { TimeoutException } from 'effect/Cause';
import { RequestError } from '@effect/platform/HttpClientError';
import { readInstantConfigFile } from '../util/instantConfig.ts';
import { BadArgsError } from '../errors.ts';
import { getBaseUrl } from '../util/apiUrl.ts';

export { getBaseUrl } from '../util/apiUrl.ts';

export class InstantHttp extends Context.Tag(
'instant-cli/new/lib/http/InstantHttp',
Expand Down Expand Up @@ -49,15 +50,6 @@ class InstantTypicalHttpErrorResponse extends Schema.Struct({
),
}) {}

const HttpUrl = Schema.URL.pipe(
Schema.filter(
(url) =>
url.protocol === 'http:' ||
url.protocol === 'https:' ||
'Expected an HTTP(S) URL',
),
);

export const InstantHttpLive = Layer.effect(
InstantHttp,
Effect.gen(function* () {
Expand Down Expand Up @@ -135,34 +127,6 @@ export const InstantHttpAuthedLive = Layer.effect(
}),
);

export const getBaseUrl = Effect.gen(function* () {
const setEnv = yield* Config.string('INSTANT_CLI_API_URI').pipe(
Config.option,
);
const dev = yield* Config.boolean('INSTANT_CLI_DEV').pipe(
Config.withDefault(false),
);

if (Option.isSome(setEnv)) {
return setEnv.value;
}

const instantConfig = yield* Effect.tryPromise(readInstantConfigFile);
if (instantConfig?.apiURI !== undefined) {
yield* Schema.decodeUnknown(HttpUrl)(instantConfig.apiURI).pipe(
Effect.mapError(() =>
BadArgsError.make({
message:
'Invalid apiURI in instant.config.ts. Expected a valid HTTP(S) URL.',
}),
),
);
return instantConfig.apiURI;
}

return dev ? 'http://localhost:8888' : 'https://api.instantdb.com';
});

export const getDashUrl = Effect.gen(function* () {
const setEnv = yield* Config.string('INSTANT_CLI_DASH_URI').pipe(
Config.option,
Expand Down
16 changes: 5 additions & 11 deletions client/packages/cli/src/lib/login.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
import { Effect, Schedule, Schema } from 'effect';
import { InstantHttp, withCommand } from './http.ts';
import {
HttpClientRequest,
HttpClientResponse,
FileSystem,
} from '@effect/platform';
import { getAuthPaths } from '../util/getAuthPaths.ts';
import { HttpClientRequest, HttpClientResponse } from '@effect/platform';
import { saveConfigAuthToken as saveAuthTokenForApi } from '../auth.ts';
import { getBaseUrl } from '../util/apiUrl.ts';

const LoginInfo = Schema.Struct({
secret: Schema.String,
Expand Down Expand Up @@ -46,9 +43,6 @@ export const waitForAuthToken = Effect.fn(function* (secret: string) {
});

export const saveConfigAuthToken = Effect.fn(function* (token: string) {
const authPaths = getAuthPaths();

const fs = yield* FileSystem.FileSystem;
yield* fs.makeDirectory(authPaths.appConfigDirPath, { recursive: true });
yield* fs.writeFileString(authPaths.authConfigFilePath, token);
const apiUrl = yield* getBaseUrl;
yield* Effect.tryPromise(() => saveAuthTokenForApi(apiUrl, token));
});
Loading
Loading