Skip to content
Closed
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
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.trim();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
return { type: 'invalid' };
}
return { type: 'legacy', token: trimmed };
}
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