Skip to content

Commit fe9e078

Browse files
pitzcarraldoclaude
andcommitted
feat: migrate session and credentials storage to project-local .clix/
Consolidate all project-specific data into .clix/ directory: - Move sessions from XDG_STATE_HOME/clix/sessions/ to .clix/sessions/ - Move auth credentials from XDG_STATE_HOME/clix/ to .clix/credentials.json - Move Firebase tokens into unified credentials.json structure - Keep global config in XDG_CONFIG_HOME/clix/config.json Unified credentials structure: version: 1 clix?: { Auth0 tokens } firebase?: { Firebase OAuth tokens } This enables per-project credential management while maintaining global user preferences, improving multi-workspace workflows. Co-Authored-By: Claude (global.anthropic.claude-haiku-4-5-20251001-v1:0) <noreply@anthropic.com>
1 parent acc35b0 commit fe9e078

9 files changed

Lines changed: 238 additions & 99 deletions

File tree

AGENTS.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -259,5 +259,7 @@ When adding new OAuth flows, use port 9005, path `/auth/callback`, the shared `O
259259

260260
## Security
261261

262-
Do not commit API keys or user data. Local config lives in `$XDG_CONFIG_HOME/clix/config.json` (default: `~/.config/clix/config.json`).
263-
Sessions are stored in `$XDG_STATE_HOME/clix/sessions/` (default: `~/.local/state/clix/sessions/`).
262+
Do not commit API keys or user data. Global config lives in `$XDG_CONFIG_HOME/clix/config.json` (default: `~/.config/clix/config.json`).
263+
Project-local data is stored in `project/.clix/` directory:
264+
- Sessions: `project/.clix/sessions/`
265+
- Credentials: `project/.clix/credentials.json` (unified: Clix Auth + Firebase tokens)

src/lib/auth/credentials.ts

Lines changed: 158 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
import { chmod, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
22
import { join } from 'node:path';
3-
import { xdg } from '../utils/xdg';
43
import { AUTH_ENV_VARS, getAuth0Config } from './config';
54
import { AuthError } from './errors';
6-
import { type Credentials, createCredentials, validateCredentials } from './schema';
5+
import {
6+
type ClixCredentials,
7+
CREDENTIALS_VERSION,
8+
type Credentials,
9+
createClixCredentials,
10+
type FirebaseTokens,
11+
validateCredentials,
12+
} from './schema';
713
import type { TokenResponse } from './types';
814

915
/**
@@ -15,9 +21,13 @@ const EXPIRY_BUFFER_MS = 5 * 60 * 1000;
1521
/**
1622
* CredentialsManager handles storing, loading, and refreshing auth credentials.
1723
*
18-
* Storage location: $XDG_STATE_HOME/clix/credentials.json
24+
* Storage location: project/.clix/credentials.json
1925
* File permissions: 0600 (owner read/write only)
2026
*
27+
* Unified credentials file structure:
28+
* - clix: Auth0/Clix authentication tokens
29+
* - firebase: Firebase OAuth tokens
30+
*
2131
* @example
2232
* ```typescript
2333
* const manager = getCredentialsManager();
@@ -33,7 +43,7 @@ export class CredentialsManager {
3343
private credentialsFilePath: string;
3444

3545
constructor(customStateDir?: string) {
36-
this.stateDirPath = customStateDir ?? xdg.state();
46+
this.stateDirPath = customStateDir ?? join(process.cwd(), '.clix');
3747
this.credentialsFilePath = join(this.stateDirPath, 'credentials.json');
3848
}
3949

@@ -130,14 +140,53 @@ export class CredentialsManager {
130140
}
131141
}
132142

143+
// ============================================
144+
// Clix (Auth0) Credentials Methods
145+
// ============================================
146+
147+
/**
148+
* Get Clix credentials from unified store.
149+
*/
150+
async getClixCredentials(): Promise<ClixCredentials | null> {
151+
const credentials = await this.load();
152+
return credentials?.clix ?? null;
153+
}
154+
133155
/**
134-
* Check if access token is expired.
156+
* Save Clix credentials to unified store.
157+
*/
158+
async saveClixCredentials(clixCredentials: ClixCredentials): Promise<void> {
159+
const current = (await this.load()) ?? { version: CREDENTIALS_VERSION };
160+
await this.save({
161+
...current,
162+
version: CREDENTIALS_VERSION,
163+
clix: clixCredentials,
164+
});
165+
}
166+
167+
/**
168+
* Clear only Clix credentials (keep Firebase tokens).
169+
*/
170+
async clearClixCredentials(): Promise<void> {
171+
const current = await this.load();
172+
if (current) {
173+
const { clix: _, ...rest } = current;
174+
if (rest.firebase) {
175+
await this.save({ ...rest, version: CREDENTIALS_VERSION });
176+
} else {
177+
await this.delete();
178+
}
179+
}
180+
}
181+
182+
/**
183+
* Check if Clix access token is expired.
135184
*
136-
* @param credentials - Credentials to check
185+
* @param clixCredentials - Clix credentials to check
137186
* @returns true if expired or about to expire
138187
*/
139-
isExpired(credentials: Credentials): boolean {
140-
const expiresAtMs = Date.parse(credentials.expiresAt);
188+
isClixExpired(clixCredentials: ClixCredentials): boolean {
189+
const expiresAtMs = Date.parse(clixCredentials.expiresAt);
141190
// Treat invalid dates as expired (secure default)
142191
if (!Number.isFinite(expiresAtMs)) {
143192
return true;
@@ -146,15 +195,23 @@ export class CredentialsManager {
146195
return expiresAtMs - EXPIRY_BUFFER_MS <= Date.now();
147196
}
148197

198+
/**
199+
* @deprecated Use isClixExpired instead.
200+
*/
201+
isExpired(credentials: Credentials): boolean {
202+
if (!credentials.clix) return true;
203+
return this.isClixExpired(credentials.clix);
204+
}
205+
149206
/**
150207
* Refresh access token using refresh token.
151208
*
152-
* @param credentials - Current credentials with refresh token
153-
* @returns New credentials with fresh access token
209+
* @param clixCredentials - Current Clix credentials with refresh token
210+
* @returns New Clix credentials with fresh access token
154211
* @throws AuthError if refresh fails
155212
*/
156-
async refreshAccessToken(credentials: Credentials): Promise<Credentials> {
157-
if (!credentials.refreshToken) {
213+
async refreshAccessToken(clixCredentials: ClixCredentials): Promise<ClixCredentials> {
214+
if (!clixCredentials.refreshToken) {
158215
throw AuthError.refreshFailed('No refresh token available');
159216
}
160217

@@ -170,7 +227,7 @@ export class CredentialsManager {
170227
body: new URLSearchParams({
171228
grant_type: 'refresh_token',
172229
client_id: config.clientId,
173-
refresh_token: credentials.refreshToken,
230+
refresh_token: clixCredentials.refreshToken,
174231
}),
175232
signal: AbortSignal.timeout(30_000),
176233
});
@@ -188,20 +245,20 @@ export class CredentialsManager {
188245
// Preserve existing refresh token if response omits it (RFC 6749 compliant)
189246
const mergedTokenResponse: TokenResponse = {
190247
...tokenResponse,
191-
refresh_token: tokenResponse.refresh_token ?? credentials.refreshToken,
248+
refresh_token: tokenResponse.refresh_token ?? clixCredentials.refreshToken,
192249
};
193250

194251
// Create new credentials with refreshed tokens
195-
const newCredentials = createCredentials(
252+
const newClixCredentials = createClixCredentials(
196253
mergedTokenResponse,
197-
credentials.issuer,
198-
credentials.audience,
254+
clixCredentials.issuer,
255+
clixCredentials.audience,
199256
);
200257

201258
// Save updated credentials
202-
await this.save(newCredentials);
259+
await this.saveClixCredentials(newClixCredentials);
203260

204-
return newCredentials;
261+
return newClixCredentials;
205262
} catch (error) {
206263
if (error instanceof AuthError) {
207264
throw error;
@@ -233,21 +290,21 @@ export class CredentialsManager {
233290
return envToken;
234291
}
235292

236-
// 2. Load stored credentials
237-
const credentials = await this.load();
238-
if (!credentials) {
293+
// 2. Load stored Clix credentials
294+
const clixCredentials = await this.getClixCredentials();
295+
if (!clixCredentials) {
239296
return null;
240297
}
241298

242299
// 3. Check if access token is expired
243-
if (!this.isExpired(credentials)) {
244-
return credentials.accessToken;
300+
if (!this.isClixExpired(clixCredentials)) {
301+
return clixCredentials.accessToken;
245302
}
246303

247304
// 4. Try to refresh if we have a refresh token
248-
if (credentials.refreshToken) {
305+
if (clixCredentials.refreshToken) {
249306
try {
250-
const refreshed = await this.refreshAccessToken(credentials);
307+
const refreshed = await this.refreshAccessToken(clixCredentials);
251308
return refreshed.accessToken;
252309
} catch {
253310
// Refresh failed - return null to indicate re-login needed
@@ -278,6 +335,81 @@ export class CredentialsManager {
278335
return !!process.env[AUTH_ENV_VARS.ACCESS_TOKEN];
279336
}
280337

338+
// ============================================
339+
// Firebase Token Methods
340+
// ============================================
341+
342+
/**
343+
* Get Firebase tokens from unified store.
344+
*/
345+
async getFirebaseTokens(): Promise<FirebaseTokens | null> {
346+
const credentials = await this.load();
347+
return credentials?.firebase ?? null;
348+
}
349+
350+
/**
351+
* Save Firebase tokens to unified store.
352+
*/
353+
async saveFirebaseTokens(firebaseTokens: FirebaseTokens): Promise<void> {
354+
const current = (await this.load()) ?? { version: CREDENTIALS_VERSION };
355+
await this.save({
356+
...current,
357+
version: CREDENTIALS_VERSION,
358+
firebase: firebaseTokens,
359+
});
360+
}
361+
362+
/**
363+
* Clear only Firebase tokens (keep Clix credentials).
364+
*/
365+
async clearFirebaseTokens(): Promise<void> {
366+
const current = await this.load();
367+
if (current) {
368+
const { firebase: _, ...rest } = current;
369+
if (rest.clix) {
370+
await this.save({ ...rest, version: CREDENTIALS_VERSION });
371+
} else {
372+
await this.delete();
373+
}
374+
}
375+
}
376+
377+
/**
378+
* Check if Firebase tokens exist.
379+
*/
380+
async hasFirebaseTokens(): Promise<boolean> {
381+
const tokens = await this.getFirebaseTokens();
382+
return tokens !== null;
383+
}
384+
385+
/**
386+
* Check if Firebase tokens are expired.
387+
*
388+
* @param tokens - Firebase tokens to check
389+
* @returns true if tokens are expired or will expire within 5 minutes
390+
*/
391+
isFirebaseExpired(tokens: FirebaseTokens): boolean {
392+
if (!tokens.expiry_date) {
393+
return false; // No expiry info, assume valid
394+
}
395+
// Consider expired if less than 5 minutes remaining
396+
return Date.now() >= tokens.expiry_date - EXPIRY_BUFFER_MS;
397+
}
398+
399+
/**
400+
* Check if Firebase tokens have a valid refresh token.
401+
*
402+
* @param tokens - Firebase tokens to check
403+
* @returns true if refresh token exists
404+
*/
405+
hasFirebaseRefreshToken(tokens: FirebaseTokens): boolean {
406+
return !!tokens.refresh_token;
407+
}
408+
409+
// ============================================
410+
// Utility Methods
411+
// ============================================
412+
281413
/**
282414
* Clear the cached credentials (useful for testing).
283415
*/

src/lib/auth/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,15 @@ export type { AuthErrorCode } from './errors';
1414
// Errors
1515
export { AUTH_ERROR_CODES, AuthError } from './errors';
1616
export { PKCEFlowService } from './pkce-flow';
17-
export type { Credentials } from './schema';
17+
export type { ClixCredentials, Credentials, FirebaseTokens } from './schema';
1818
// Schema
1919
export {
20+
ClixCredentialsSchema,
2021
CREDENTIALS_VERSION,
2122
CredentialsSchema,
23+
createClixCredentials,
2224
createCredentials,
25+
FirebaseTokensSchema,
2326
validateCredentials,
2427
} from './schema';
2528
export type { Auth0Config, RefreshTokenRequest, TokenResponse, UserInfo } from './types';

0 commit comments

Comments
 (0)