Skip to content
Open
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
15 changes: 14 additions & 1 deletion src/auth/schema.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,17 @@
import { z } from "zod";
import {
normalizeSlackWorkspaceUrl,
SLACK_WORKSPACE_ORIGIN_ERROR,
} from "../slack/workspace-url.ts";

const SlackWorkspaceUrlSchema = z.string().transform((value, ctx) => {
try {
return normalizeSlackWorkspaceUrl(value);
} catch {
ctx.addIssue({ code: "custom", message: SLACK_WORKSPACE_ORIGIN_ERROR });
return z.NEVER;
}
});

export const WorkspaceAuthSchema = z.union([
z.object({
Expand All @@ -15,7 +28,7 @@ export const WorkspaceAuthSchema = z.union([
export type WorkspaceAuth = z.infer<typeof WorkspaceAuthSchema>;

export const WorkspaceSchema = z.object({
workspace_url: z.string().url(),
workspace_url: SlackWorkspaceUrlSchema,
workspace_name: z.string().optional(),
team_id: z.string().optional(),
team_domain: z.string().optional(),
Expand Down
118 changes: 60 additions & 58 deletions src/auth/store.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,57 @@
import { CREDENTIALS_FILE, KEYCHAIN_SERVICE } from "./paths.ts";
import { readJsonFile, writeJsonFile } from "../lib/fs.ts";
import { writeJsonFile } from "../lib/fs.ts";
import { CredentialsSchema, type Credentials, type Workspace } from "./schema.ts";
import { keychainGet, keychainSet } from "./keychain.ts";
import { platform } from "node:os";
import { readFile } from "node:fs/promises";
import { isRecord } from "../lib/object-type-guards.ts";
import { normalizeSlackWorkspaceUrl } from "../slack/workspace-url.ts";

const KEYCHAIN_PLACEHOLDER = "__KEYCHAIN__";
const IS_MACOS = platform() === "darwin";
const INVALID_STORED_CREDENTIALS_ERROR =
"Stored credentials are invalid; refusing to use or overwrite them.";

function normalizeWorkspaceUrl(workspaceUrl: string): string {
const u = new URL(workspaceUrl);
return `${u.protocol}//${u.host}`;
function browserCookieAccount(workspaceUrl: string): string {
return `xoxd:${normalizeSlackWorkspaceUrl(workspaceUrl)}`;
}

function isPlaceholderSecret(value: string | undefined): boolean {
return !value || value === KEYCHAIN_PLACEHOLDER;
}

export async function loadCredentials(): Promise<Credentials> {
const fromFile = await readJsonFile<unknown>(CREDENTIALS_FILE);
const parsed = CredentialsSchema.safeParse(fromFile ?? { version: 1, workspaces: [] });
if (!parsed.success) {
return { version: 1, workspaces: [] };
export async function readStoredCredentials(
credentialsFile: string = CREDENTIALS_FILE,
): Promise<Credentials> {
let raw: string;
try {
raw = await readFile(credentialsFile, "utf8");
} catch (error) {
if (isRecord(error) && error.code === "ENOENT") {
return { version: 1, workspaces: [] };
}
throw error;
}

try {
return CredentialsSchema.parse(JSON.parse(raw));
} catch {
throw new Error(INVALID_STORED_CREDENTIALS_ERROR);
}
}

export async function loadCredentials(options?: {
credentialsFile?: string;
keychainRead?: typeof keychainGet;
}): Promise<Credentials> {
// Optional: hydrate browser cookie/token from keychain for security.
const creds = parsed.data;
const creds = await readStoredCredentials(options?.credentialsFile);
const readKeychain = options?.keychainRead ?? keychainGet;
const hydrated = creds.workspaces.map((w) => {
if (w.auth.auth_type === "browser") {
const account = `xoxc:${normalizeWorkspaceUrl(w.workspace_url)}`;
const xoxc = keychainGet(account, KEYCHAIN_SERVICE);
const xoxd = keychainGet("xoxd", KEYCHAIN_SERVICE);
const account = `xoxc:${normalizeSlackWorkspaceUrl(w.workspace_url)}`;
const xoxc = readKeychain(account, KEYCHAIN_SERVICE);
const xoxd = readKeychain(browserCookieAccount(w.workspace_url), KEYCHAIN_SERVICE);
return {
...w,
auth: {
Expand All @@ -40,8 +62,8 @@ export async function loadCredentials(): Promise<Credentials> {
};
}
if (w.auth.auth_type === "standard") {
const account = `token:${normalizeWorkspaceUrl(w.workspace_url)}`;
const token = keychainGet(account, KEYCHAIN_SERVICE);
const account = `token:${normalizeSlackWorkspaceUrl(w.workspace_url)}`;
const token = readKeychain(account, KEYCHAIN_SERVICE);
return {
...w,
auth: {
Expand All @@ -57,55 +79,43 @@ export async function loadCredentials(): Promise<Credentials> {
}

export async function saveCredentials(credentials: Credentials): Promise<void> {
const payload: Credentials = {
const payload = CredentialsSchema.parse({
...credentials,
updated_at: new Date().toISOString(),
workspaces: credentials.workspaces.map((w) => ({
...w,
workspace_url: normalizeWorkspaceUrl(w.workspace_url),
})),
};
});

// Store secrets in keychain when possible and avoid writing plaintext tokens to disk.
// If keychain writes fail (or non-macOS), fall back to storing secrets in the file.
const filePayload: Credentials = structuredClone(payload);

if (IS_MACOS) {
// Browser auth: xoxd is shared across workspaces, xoxc is per-workspace.
const firstBrowser = payload.workspaces.find((w) => w.auth.auth_type === "browser");
let xoxdStored = false;
if (
firstBrowser?.auth.auth_type === "browser" &&
!isPlaceholderSecret(firstBrowser.auth.xoxd_cookie)
) {
const existing = keychainGet("xoxd", KEYCHAIN_SERVICE);
xoxdStored =
existing === firstBrowser.auth.xoxd_cookie ||
keychainSet({
account: "xoxd",
value: firstBrowser.auth.xoxd_cookie,
service: KEYCHAIN_SERVICE,
});
}

for (const w of filePayload.workspaces) {
if (w.auth.auth_type === "browser") {
const account = `xoxc:${normalizeWorkspaceUrl(w.workspace_url)}`;
const account = `xoxc:${normalizeSlackWorkspaceUrl(w.workspace_url)}`;
const tokenStored =
isPlaceholderSecret(w.auth.xoxc_token) ||
keychainGet(account, KEYCHAIN_SERVICE) === w.auth.xoxc_token ||
keychainSet({ account, value: w.auth.xoxc_token, service: KEYCHAIN_SERVICE });
const cookieAccount = browserCookieAccount(w.workspace_url);
const cookieStored =
isPlaceholderSecret(w.auth.xoxd_cookie) ||
keychainGet(cookieAccount, KEYCHAIN_SERVICE) === w.auth.xoxd_cookie ||
keychainSet({
account: cookieAccount,
value: w.auth.xoxd_cookie,
service: KEYCHAIN_SERVICE,
});

if (tokenStored) {
w.auth.xoxc_token = KEYCHAIN_PLACEHOLDER;
}
if (xoxdStored) {
if (cookieStored) {
w.auth.xoxd_cookie = KEYCHAIN_PLACEHOLDER;
}
}

if (w.auth.auth_type === "standard") {
const account = `token:${normalizeWorkspaceUrl(w.workspace_url)}`;
const account = `token:${normalizeSlackWorkspaceUrl(w.workspace_url)}`;
const tokenStored =
isPlaceholderSecret(w.auth.token) ||
keychainGet(account, KEYCHAIN_SERVICE) === w.auth.token ||
Expand All @@ -122,12 +132,10 @@ export async function saveCredentials(credentials: Credentials): Promise<void> {

export async function upsertWorkspace(workspace: Workspace): Promise<Workspace> {
const creds = await loadCredentials();
const normalizedUrl = normalizeWorkspaceUrl(workspace.workspace_url);
const normalizedUrl = normalizeSlackWorkspaceUrl(workspace.workspace_url);
const next: Workspace = { ...workspace, workspace_url: normalizedUrl };

const idx = creds.workspaces.findIndex(
(w) => normalizeWorkspaceUrl(w.workspace_url) === normalizedUrl,
);
const idx = creds.workspaces.findIndex((w) => w.workspace_url === normalizedUrl);
if (idx === -1) {
creds.workspaces.push(next);
} else {
Expand All @@ -152,12 +160,10 @@ export async function upsertWorkspaces(workspaces: Workspace[]): Promise<void> {
const creds = await loadCredentials();

for (const workspace of workspaces) {
const normalizedUrl = normalizeWorkspaceUrl(workspace.workspace_url);
const normalizedUrl = normalizeSlackWorkspaceUrl(workspace.workspace_url);
const next: Workspace = { ...workspace, workspace_url: normalizedUrl };

const idx = creds.workspaces.findIndex(
(w) => normalizeWorkspaceUrl(w.workspace_url) === normalizedUrl,
);
const idx = creds.workspaces.findIndex((w) => w.workspace_url === normalizedUrl);
if (idx === -1) {
creds.workspaces.push(next);
} else {
Expand All @@ -178,16 +184,14 @@ export async function upsertWorkspaces(workspaces: Workspace[]): Promise<void> {

export async function setDefaultWorkspace(workspaceUrl: string): Promise<void> {
const creds = await loadCredentials();
creds.default_workspace_url = normalizeWorkspaceUrl(workspaceUrl);
creds.default_workspace_url = normalizeSlackWorkspaceUrl(workspaceUrl);
await saveCredentials(creds);
}

export async function removeWorkspace(workspaceUrl: string): Promise<void> {
const creds = await loadCredentials();
const normalized = normalizeWorkspaceUrl(workspaceUrl);
creds.workspaces = creds.workspaces.filter(
(w) => normalizeWorkspaceUrl(w.workspace_url) !== normalized,
);
const normalized = normalizeSlackWorkspaceUrl(workspaceUrl);
creds.workspaces = creds.workspaces.filter((w) => w.workspace_url !== normalized);
if (creds.default_workspace_url === normalized) {
creds.default_workspace_url = creds.workspaces[0]?.workspace_url;
}
Expand All @@ -196,10 +200,8 @@ export async function removeWorkspace(workspaceUrl: string): Promise<void> {

export async function resolveWorkspaceForUrl(workspaceUrl: string): Promise<Workspace | null> {
const creds = await loadCredentials();
const normalized = normalizeWorkspaceUrl(workspaceUrl);
return (
creds.workspaces.find((w) => normalizeWorkspaceUrl(w.workspace_url) === normalized) ?? null
);
const normalized = normalizeSlackWorkspaceUrl(workspaceUrl);
return creds.workspaces.find((w) => w.workspace_url === normalized) ?? null;
}

export async function resolveDefaultWorkspace(): Promise<Workspace | null> {
Expand Down
24 changes: 12 additions & 12 deletions src/cli/auth-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,17 +85,17 @@ export function registerAuthCommand(input: { program: Command; ctx: CliContext }
);
}

for (const team of extracted.teams) {
await upsertWorkspace({
await upsertWorkspaces(
extracted.teams.map((team) => ({
workspace_url: input.ctx.normalizeUrl(team.url),
workspace_name: team.name,
auth: {
auth_type: "browser",
xoxc_token: team.token,
xoxd_cookie: extracted.cookie_d,
},
});
}
})),
);
console.log(`Imported ${extracted.teams.length} workspace token(s) from Chrome.`);
} catch (err: unknown) {
console.error(input.ctx.errorMessage(err));
Expand All @@ -118,17 +118,17 @@ export function registerAuthCommand(input: { program: Command; ctx: CliContext }
);
}

for (const team of extracted.teams) {
await upsertWorkspace({
await upsertWorkspaces(
extracted.teams.map((team) => ({
workspace_url: input.ctx.normalizeUrl(team.url),
workspace_name: team.name,
auth: {
auth_type: "browser",
xoxc_token: team.token,
xoxd_cookie: extracted.cookie_d,
},
});
}
})),
);
console.log(`Imported ${extracted.teams.length} workspace token(s) from Brave.`);
} catch (err: unknown) {
console.error(input.ctx.errorMessage(err));
Expand All @@ -148,17 +148,17 @@ export function registerAuthCommand(input: { program: Command; ctx: CliContext }
);
}

for (const team of extracted.teams) {
await upsertWorkspace({
await upsertWorkspaces(
extracted.teams.map((team) => ({
workspace_url: input.ctx.normalizeUrl(team.url),
workspace_name: team.name,
auth: {
auth_type: "browser",
xoxc_token: team.token,
xoxd_cookie: extracted.cookie_d,
},
});
}
})),
);
console.log(`Imported ${extracted.teams.length} workspace token(s) from Firefox.`);
} catch (err: unknown) {
console.error(input.ctx.errorMessage(err));
Expand Down
16 changes: 9 additions & 7 deletions src/cli/context-client-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,19 @@ import {
} from "../auth/store.ts";
import { resolveWorkspaceSelector } from "./workspace-selector.ts";
import { SlackApiClient, type SlackAuth } from "../slack/client.ts";
import { normalizeSlackWorkspaceUrl } from "../slack/workspace-url.ts";

export function normalizeUrl(u: string): string {
const url = new URL(u);
return `${url.protocol}//${url.host}`;
return normalizeSlackWorkspaceUrl(u);
}

function tryNormalizeUrl(u: string): string | undefined {
try {
return normalizeUrl(u);
new URL(u);
} catch {
return undefined;
}
return normalizeUrl(u);
}

function pickAuthFromEnv(): SlackAuth | null {
Expand Down Expand Up @@ -67,12 +68,13 @@ export async function getClientForWorkspace(workspaceUrl?: string): Promise<{

const env = pickAuthFromEnv();
if (env) {
const envWorkspaceUrl = process.env.SLACK_WORKSPACE_URL?.trim();
const urlForBrowser = resolvedWorkspaceUrl || envWorkspaceUrl;
const rawEnvWorkspaceUrl = process.env.SLACK_WORKSPACE_URL?.trim();
const urlForClient =
resolvedWorkspaceUrl ?? (rawEnvWorkspaceUrl ? normalizeUrl(rawEnvWorkspaceUrl) : undefined);
return {
client: new SlackApiClient(env, { workspaceUrl: urlForBrowser }),
client: new SlackApiClient(env, { workspaceUrl: urlForClient }),
auth: env,
workspace_url: urlForBrowser,
workspace_url: urlForClient,
};
}

Expand Down
Loading
Loading