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
4 changes: 3 additions & 1 deletion src/platforms/slack/commands/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
Expand Down Expand Up @@ -162,7 +163,8 @@ async function statusAction(options: { pretty?: boolean }): Promise<void> {
authInfo = await client.testAuth()
valid = true
} catch {
valid = false
authInfo = await refreshCookie(ws.token, credManager)
valid = authInfo !== null
}

const output = {
Expand Down
55 changes: 54 additions & 1 deletion src/platforms/slack/ensure-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { TokenExtractor } from './token-extractor'

let getWorkspaceSpy: ReturnType<typeof spyOn>
let extractSpy: ReturnType<typeof spyOn>
let extractCookieSpy: ReturnType<typeof spyOn>
let testAuthSpy: ReturnType<typeof spyOn>
let setWorkspaceSpy: ReturnType<typeof spyOn>
let loadSpy: ReturnType<typeof spyOn>
Expand All @@ -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',
Expand All @@ -43,14 +46,15 @@ beforeEach(() => {
afterEach(() => {
getWorkspaceSpy?.mockRestore()
extractSpy?.mockRestore()
extractCookieSpy?.mockRestore()
testAuthSpy?.mockRestore()
setWorkspaceSpy?.mockRestore()
loadSpy?.mockRestore()
setCurrentWorkspaceSpy?.mockRestore()
})

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',
Expand All @@ -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()
Expand Down
34 changes: 32 additions & 2 deletions src/platforms/slack/ensure-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,16 @@ import { TokenExtractor } from './token-extractor'
export async function ensureSlackAuth(): Promise<void> {
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()
Expand All @@ -32,6 +41,27 @@ export async function ensureSlackAuth(): Promise<void> {
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
}
}
117 changes: 96 additions & 21 deletions src/platforms/slack/token-extractor.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -83,6 +83,26 @@ export class TokenExtractor {
}
}

async extractCookie(): Promise<string> {
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<ExtractedWorkspace[]> {
if (!existsSync(this.slackDir)) {
throw new Error(`Slack directory not found: ${this.slackDir}`)
Expand Down Expand Up @@ -194,25 +214,57 @@ export class TokenExtractor {
}

private async extractFromLevelDB(dbPath: string): Promise<TokenInfo[]> {
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<TokenInfo[]> {
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<TokenInfo[]> {
const tokens: TokenInfo[] = []
let db: ClassicLevel<string, string> | 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 {
Expand All @@ -223,7 +275,6 @@ export class TokenExtractor {
} catch {}
}
}

return tokens
}

Expand Down Expand Up @@ -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<string, any>): 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<string> {
Expand Down