diff --git a/src/platforms/slack/commands/auth.ts b/src/platforms/slack/commands/auth.ts index 3379b4c..2c83631 100644 --- a/src/platforms/slack/commands/auth.ts +++ b/src/platforms/slack/commands/auth.ts @@ -3,6 +3,7 @@ import { handleError } from '@/shared/utils/error-handler' import { formatOutput } from '@/shared/utils/output' import { SlackClient, SlackError } from '../client' import { CredentialManager } from '../credential-manager' +import { refreshCookie } from '../ensure-auth' import { TokenExtractor } from '../token-extractor' async function extractAction(options: { pretty?: boolean; debug?: boolean }): Promise { @@ -162,7 +163,8 @@ async function statusAction(options: { pretty?: boolean }): Promise { authInfo = await client.testAuth() valid = true } catch { - valid = false + authInfo = await refreshCookie(ws.token, credManager) + valid = authInfo !== null } const output = { diff --git a/src/platforms/slack/ensure-auth.test.ts b/src/platforms/slack/ensure-auth.test.ts index 766a7b4..481f05d 100644 --- a/src/platforms/slack/ensure-auth.test.ts +++ b/src/platforms/slack/ensure-auth.test.ts @@ -6,6 +6,7 @@ import { TokenExtractor } from './token-extractor' let getWorkspaceSpy: ReturnType let extractSpy: ReturnType +let extractCookieSpy: ReturnType let testAuthSpy: ReturnType let setWorkspaceSpy: ReturnType let loadSpy: ReturnType @@ -23,6 +24,8 @@ beforeEach(() => { }, ]) + extractCookieSpy = spyOn(TokenExtractor.prototype, 'extractCookie').mockResolvedValue('xoxd-fresh-cookie') + testAuthSpy = spyOn(SlackClient.prototype, 'testAuth').mockResolvedValue({ user_id: 'U123', team_id: 'T123', @@ -43,6 +46,7 @@ beforeEach(() => { afterEach(() => { getWorkspaceSpy?.mockRestore() extractSpy?.mockRestore() + extractCookieSpy?.mockRestore() testAuthSpy?.mockRestore() setWorkspaceSpy?.mockRestore() loadSpy?.mockRestore() @@ -50,7 +54,7 @@ afterEach(() => { }) describe('ensureSlackAuth', () => { - test('skips extraction when credentials already exist', async () => { + test('skips extraction when stored credentials are valid', async () => { // given getWorkspaceSpy.mockResolvedValue({ workspace_id: 'T123', @@ -63,9 +67,58 @@ describe('ensureSlackAuth', () => { await ensureSlackAuth() // then + expect(testAuthSpy).toHaveBeenCalled() expect(extractSpy).not.toHaveBeenCalled() }) + test('refreshes cookie when stored credentials are stale', async () => { + // given + getWorkspaceSpy.mockResolvedValue({ + workspace_id: 'T123', + workspace_name: 'existing', + token: 'xoxc-existing', + cookie: 'xoxd-old-cookie', + }) + let callCount = 0 + testAuthSpy.mockImplementation(() => { + callCount++ + if (callCount === 1) throw new Error('invalid_auth') + return Promise.resolve({ user_id: 'U1', team_id: 'T123', user: 'user', team: 'Team' }) + }) + loadSpy.mockResolvedValue({ + current_workspace: 'T123', + workspaces: { + T123: { workspace_id: 'T123', workspace_name: 'existing', token: 'xoxc-existing', cookie: 'xoxd-old-cookie' }, + }, + }) + + // when + await ensureSlackAuth() + + // then — cookie refreshed, no full extraction + expect(extractCookieSpy).toHaveBeenCalled() + expect(extractSpy).not.toHaveBeenCalled() + expect(setWorkspaceSpy).toHaveBeenCalledWith(expect.objectContaining({ cookie: 'xoxd-fresh-cookie' })) + }) + + test('falls through to full extraction when cookie refresh fails', async () => { + // given + getWorkspaceSpy.mockResolvedValue({ + workspace_id: 'T123', + workspace_name: 'existing', + token: 'xoxc-existing', + cookie: 'xoxd-old-cookie', + }) + testAuthSpy.mockRejectedValue(new Error('invalid_auth')) + extractCookieSpy.mockResolvedValue('') + + // when + await ensureSlackAuth() + + // then — falls through to full extraction + expect(extractSpy).toHaveBeenCalled() + }) + test('extracts and saves credentials when none exist', async () => { // when await ensureSlackAuth() diff --git a/src/platforms/slack/ensure-auth.ts b/src/platforms/slack/ensure-auth.ts index 1bc398d..4d9e22f 100644 --- a/src/platforms/slack/ensure-auth.ts +++ b/src/platforms/slack/ensure-auth.ts @@ -5,7 +5,16 @@ import { TokenExtractor } from './token-extractor' export async function ensureSlackAuth(): Promise { const credManager = new CredentialManager() const workspace = await credManager.getWorkspace() - if (workspace) return + + if (workspace) { + try { + const client = new SlackClient(workspace.token, workspace.cookie) + await client.testAuth() + return + } catch { + if (await refreshCookie(workspace.token, credManager)) return + } + } try { const extractor = new TokenExtractor() @@ -32,6 +41,27 @@ export async function ensureSlackAuth(): Promise { if (code === 'EBUSY' || message.includes('locking the cookie')) { throw error } - // Silently ignore other extraction errors (e.g. Slack not installed) + } +} + +export async function refreshCookie( + token: string, + credManager: CredentialManager, +): Promise<{ user_id: string; team_id: string; user?: string; team?: string } | null> { + try { + const extractor = new TokenExtractor() + const freshCookie = await extractor.extractCookie() + if (!freshCookie) return null + + const client = new SlackClient(token, freshCookie) + const authInfo = await client.testAuth() + + const config = await credManager.load() + for (const ws of Object.values(config.workspaces)) { + await credManager.setWorkspace({ ...ws, cookie: freshCookie }) + } + return authInfo + } catch { + return null } } diff --git a/src/platforms/slack/token-extractor.ts b/src/platforms/slack/token-extractor.ts index d24c437..7a291a5 100644 --- a/src/platforms/slack/token-extractor.ts +++ b/src/platforms/slack/token-extractor.ts @@ -1,6 +1,6 @@ import { execSync } from 'node:child_process' import { createDecipheriv, pbkdf2Sync } from 'node:crypto' -import { copyFileSync, existsSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs' +import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs' import { createRequire } from 'node:module' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' @@ -83,6 +83,26 @@ export class TokenExtractor { } } + async extractCookie(): Promise { + if (!existsSync(this.slackDir)) { + return '' + } + + await this.getDerivedKeyAsync() + + const cookie = await this.extractCookieFromSQLite() + if (!cookie && this.usedCachedKey) { + await this.clearKeyCache() + this.cachedKey = this.getDerivedKeyFromKeychain() + if (this.cachedKey) { + await this.keyCache.set('slack', this.cachedKey) + return await this.extractCookieFromSQLite() + } + } + + return cookie + } + async extract(): Promise { if (!existsSync(this.slackDir)) { throw new Error(`Slack directory not found: ${this.slackDir}`) @@ -194,25 +214,57 @@ export class TokenExtractor { } private async extractFromLevelDB(dbPath: string): Promise { - const tokens: TokenInfo[] = [] + // ClassicLevel on a copy avoids LevelDB prefix-compression artifacts + // that corrupt tokens when reading raw .ldb files + const classicLevelTokens = await this.extractViaClassicLevelCopy(dbPath) + if (classicLevelTokens.length > 0) { + return classicLevelTokens + } - // First try reading LDB files directly (more reliable for sandboxed apps) const directTokens = this.extractTokensFromLDBFiles(dbPath) if (directTokens.length > 0) { return directTokens } - // Fallback to ClassicLevel for standard installations + return this.extractViaClassicLevel(dbPath) + } + + private async extractViaClassicLevelCopy(dbPath: string): Promise { + const tempDir = join(tmpdir(), `slack-leveldb-${Date.now()}-${Math.random().toString(36).slice(2)}`) + + try { + mkdirSync(tempDir, { recursive: true }) + + const files = readdirSync(dbPath) + for (const file of files) { + if (file === 'LOCK') continue // ClassicLevel creates its own + const src = join(dbPath, file) + try { + if (statSync(src).isFile()) { + copyFileSync(src, join(tempDir, file)) + } + } catch {} + } + + return await this.extractViaClassicLevel(tempDir) + } catch { + return [] + } finally { + try { + rmSync(tempDir, { recursive: true, force: true }) + } catch {} + } + } + + private async extractViaClassicLevel(dbPath: string): Promise { + const tokens: TokenInfo[] = [] let db: ClassicLevel | null = null try { db = new ClassicLevel(dbPath, { valueEncoding: 'utf8' }) for await (const [key, value] of db.iterator()) { if (typeof value === 'string' && value.includes('xoxc-')) { - const extracted = this.parseTokenData(key, value) - if (extracted) { - tokens.push(extracted) - } + tokens.push(...this.parseTokenValue(key, value)) } } } catch { @@ -223,7 +275,6 @@ export class TokenExtractor { } catch {} } } - return tokens } @@ -392,33 +443,57 @@ export class TokenExtractor { return { token, teamId, teamName } } - private parseTokenData(_key: string, value: string): TokenInfo | null { - const tokenMatch = value.match(/xoxc-[a-zA-Z0-9-]+/) - if (!tokenMatch) { - return null + private parseTokenValue(_key: string, value: string): TokenInfo[] { + // LevelDB values may have leading control characters (e.g. 0x01). + // Built dynamically to satisfy biome's noControlCharactersInRegex. + const controlChars = new RegExp( + `[${String.fromCharCode(0)}-${String.fromCharCode(8)}${String.fromCharCode(11)}${String.fromCharCode(12)}${String.fromCharCode(14)}-${String.fromCharCode(31)}]`, + 'g', + ) + const cleaned = value.replace(controlChars, '') + try { + const parsed = JSON.parse(cleaned) + if (parsed?.teams && typeof parsed.teams === 'object') { + return this.parseTeamsObject(parsed.teams) + } + } catch {} + + const single = this.parseSingleToken(cleaned) + return single ? [single] : [] + } + + private parseTeamsObject(teams: Record): TokenInfo[] { + const tokens: TokenInfo[] = [] + for (const [teamId, team] of Object.entries(teams)) { + if (!team?.token || typeof team.token !== 'string' || !team.token.startsWith('xoxc-')) continue + tokens.push({ + token: team.token, + teamId: team.id || teamId, + teamName: team.name || 'unknown', + }) } + return tokens + } - const token = tokenMatch[0] + private parseSingleToken(value: string): TokenInfo | null { + const tokenMatch = value.match(/xoxc-[a-zA-Z0-9-]+/) + if (!tokenMatch) return null let teamId = 'unknown' let teamName = 'unknown' const teamIdMatch = value.match(/"team_id"\s*:\s*"(T[A-Z0-9]+)"/) - if (teamIdMatch) { - teamId = teamIdMatch[1] - } + if (teamIdMatch) teamId = teamIdMatch[1] const teamNameMatch = value.match(/"team_name"\s*:\s*"([^"]+)"/) if (teamNameMatch) { teamName = teamNameMatch[1] } else { const domainMatch = value.match(/"domain"\s*:\s*"([^"]+)"/) - if (domainMatch) { - teamName = domainMatch[1] - } + if (domainMatch) teamName = domainMatch[1] } - return { token, teamId, teamName } + return { token: tokenMatch[0], teamId, teamName } } private async extractCookieFromSQLite(): Promise {