Skip to content

Commit 11cc183

Browse files
designcodeclaude
andauthored
feat: unified auth resolution and whoami improvements (#60)
- Add resolveAuthMethod() as single source of truth for auth priority - Rewrite whoami to display active auth method with proper labels - Auth priority: AWS profile → env vars (AWS_ > TIGRIS_) → OAuth → credentials → configured - Clear stale auth state on re-login to prevent method conflicts - Fix clearOAuthData leaving stale activeMethod causing crashes - Limit whoami org list to first 5 with hint to see all - Include test files in lint and format scripts * chore: include test files in lint and format scripts Extend lint, lint:fix, format, and format:check to cover test/**/*.ts alongside src. Fix import ordering and formatting across all test files. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: clear stale auth state on re-login Allow OAuth re-login (remove isAuthenticated block) and clear the other method's temporary state when switching login methods to prevent stale sessions from persisting. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: make whoami reflect auth resolution priority Add resolveAuthMethod() as a single source of truth for the auth priority chain (AWS profile → OAuth → credentials login → env vars → configured). Refactor getStorageConfig() to use it, and rewrite whoami to display the active auth method with IAM identity for credential-based methods. Move getEnvCredentials, getEnvCredentialSource, and getCredentials from storage.ts to provider.ts where they belong as auth resolution logic. Add comprehensive tests for resolveAuthMethod() covering all 5 auth methods, priority ordering, and edge cases (19 tests). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: limit whoami org list to first 5 with hint to see all Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: merge getEnvCredentialSource into getEnvCredentials Single function now returns credentials with source field, eliminating duplicated env var logic and the inconsistency flagged in PR review. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: reorder auth priority so env vars override login state New priority: AWS profile → env vars → oauth → credentials → configured. Env vars are explicit per-session overrides and should win over a previously stored login. Also replace unnecessary dynamic import of getStorageConfig in whoami with the existing static import. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: prioritize AWS_ env vars over TIGRIS_ in config and credentials AWS_ variables are the standard and should be checked before TIGRIS_ in getTigrisConfig() and getEnvCredentials(). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: clear stale activeMethod in clearOAuthData to prevent broken state When clearOAuthData() is called without first updating activeMethod, resolveAuthMethod() would return oauth but no tokens exist, causing getStorageConfig() to crash on authClient.getAccessToken(). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 12f8e1f commit 11cc183

22 files changed

Lines changed: 997 additions & 404 deletions

package.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,10 @@
2525
"build": "tsc --noEmit && tsup",
2626
"dev": "export $(grep -v '^#' .env | xargs) && (tsc --noEmit --watch --preserveWatchOutput & tsup --watch)",
2727
"cli": "node dist/cli.js",
28-
"lint": "eslint src",
29-
"lint:fix": "eslint src --fix",
30-
"format": "prettier --write \"src/**/*.ts\"",
31-
"format:check": "prettier --check \"src/**/*.ts\"",
28+
"lint": "eslint src test",
29+
"lint:fix": "eslint src test --fix",
30+
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
31+
"format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\"",
3232
"test": "vitest run --exclude test/cli.test.ts",
3333
"test:watch": "vitest",
3434
"test:all": "vitest run",

src/auth/iam.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ import type { MessageContext } from '@utils/messages.js';
88

99
import { getAuthClient } from './client.js';
1010
import { isFlyUser } from './fly.js';
11-
import { getLoginMethod, getTigrisConfig } from './provider.js';
12-
import { getCredentials, getSelectedOrganization } from './storage.js';
11+
import { getCredentials, getLoginMethod, getTigrisConfig } from './provider.js';
12+
import { getSelectedOrganization } from './storage.js';
1313

1414
/**
1515
* Check if current org is Fly.io. Prints message and returns true if so.

src/auth/provider.ts

Lines changed: 221 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,8 @@ import {
1212
} from '../constants.js';
1313
import { getAuth0Config, getAuthClient } from './client.js';
1414
import {
15+
type CredentialsConfig,
1516
getAwsProfileConfig,
16-
getCredentials,
17-
getEnvCredentials,
1817
getLoginMethod as getStoredLoginMethod,
1918
getSelectedOrganization,
2019
getStoredCredentials,
@@ -28,26 +27,81 @@ export interface TigrisConfig {
2827
}
2928

3029
export function getTigrisConfig(): TigrisConfig {
31-
// If any TIGRIS_ endpoint var is set, use TIGRIS_ vars exclusively
32-
if (process.env.TIGRIS_STORAGE_ENDPOINT || process.env.TIGRIS_IAM_ENDPOINT) {
30+
// AWS_ endpoint vars take priority
31+
if (process.env.AWS_ENDPOINT_URL_S3 || process.env.AWS_ENDPOINT_URL_IAM) {
3332
return {
34-
endpoint: process.env.TIGRIS_STORAGE_ENDPOINT || DEFAULT_STORAGE_ENDPOINT,
35-
iamEndpoint: process.env.TIGRIS_IAM_ENDPOINT || DEFAULT_IAM_ENDPOINT,
36-
mgmtEndpoint: process.env.TIGRIS_MGMT_ENDPOINT || DEFAULT_MGMT_ENDPOINT,
33+
endpoint: process.env.AWS_ENDPOINT_URL_S3 || DEFAULT_STORAGE_ENDPOINT,
34+
iamEndpoint: process.env.AWS_ENDPOINT_URL_IAM || DEFAULT_IAM_ENDPOINT,
35+
mgmtEndpoint: process.env.AWS_ENDPOINT_URL_MGMT || DEFAULT_MGMT_ENDPOINT,
3736
};
3837
}
3938

40-
// Fall back to AWS_ vars
39+
// Fall back to TIGRIS_ vars
4140
return {
42-
endpoint: process.env.AWS_ENDPOINT_URL_S3 || DEFAULT_STORAGE_ENDPOINT,
43-
iamEndpoint: process.env.AWS_ENDPOINT_URL_IAM || DEFAULT_IAM_ENDPOINT,
44-
mgmtEndpoint: process.env.AWS_ENDPOINT_URL_MGMT || DEFAULT_MGMT_ENDPOINT,
41+
endpoint: process.env.TIGRIS_STORAGE_ENDPOINT || DEFAULT_STORAGE_ENDPOINT,
42+
iamEndpoint: process.env.TIGRIS_IAM_ENDPOINT || DEFAULT_IAM_ENDPOINT,
43+
mgmtEndpoint: process.env.TIGRIS_MGMT_ENDPOINT || DEFAULT_MGMT_ENDPOINT,
4544
};
4645
}
4746

4847
const tigrisConfig = getTigrisConfig();
4948
const auth0Config = getAuth0Config();
5049

50+
// ---------------------------------------------------------------------------
51+
// Environment credential helpers
52+
// ---------------------------------------------------------------------------
53+
54+
type EnvCredentials = CredentialsConfig & { source: 'tigris' | 'aws' };
55+
56+
/**
57+
* Get credentials from environment variables.
58+
* AWS_ vars take priority over TIGRIS_ vars.
59+
* Within each family, both key and secret must be present.
60+
* Returns the resolved credentials along with the env var family ('aws' | 'tigris').
61+
*/
62+
export function getEnvCredentials(): EnvCredentials | null {
63+
// Check AWS_ vars first
64+
if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) {
65+
const endpoint =
66+
process.env.AWS_ENDPOINT_URL_S3 || DEFAULT_STORAGE_ENDPOINT;
67+
return {
68+
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
69+
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
70+
endpoint,
71+
source: 'aws',
72+
};
73+
}
74+
75+
// Fall back to TIGRIS_ vars
76+
if (
77+
process.env.TIGRIS_STORAGE_ACCESS_KEY_ID &&
78+
process.env.TIGRIS_STORAGE_SECRET_ACCESS_KEY
79+
) {
80+
const endpoint =
81+
process.env.TIGRIS_STORAGE_ENDPOINT || DEFAULT_STORAGE_ENDPOINT;
82+
return {
83+
accessKeyId: process.env.TIGRIS_STORAGE_ACCESS_KEY_ID,
84+
secretAccessKey: process.env.TIGRIS_STORAGE_SECRET_ACCESS_KEY,
85+
endpoint,
86+
source: 'tigris',
87+
};
88+
}
89+
90+
return null;
91+
}
92+
93+
/**
94+
* Get non-login credentials in priority order:
95+
* 1. Environment variables (AWS_ACCESS_KEY_ID / TIGRIS_STORAGE_ACCESS_KEY_ID)
96+
* 2. Stored credentials (temporary from login, then saved from configure)
97+
*
98+
* Note: AWS profile and login method checks are handled separately.
99+
* Full resolution order: AWS_PROFILE → env vars → oauth → credentials login → configured
100+
*/
101+
export function getCredentials(): CredentialsConfig | null {
102+
return getEnvCredentials() || getStoredCredentials() || null;
103+
}
104+
51105
/**
52106
* Trigger interactive login when not authenticated and stdin is a TTY.
53107
* Returns true if login was triggered, false if non-interactive or already attempted.
@@ -72,6 +126,94 @@ export async function getLoginMethod(): Promise<
72126
return getStoredLoginMethod();
73127
}
74128

129+
// ---------------------------------------------------------------------------
130+
// Auth method resolution — single source of truth for auth priority
131+
// ---------------------------------------------------------------------------
132+
133+
export type AuthMethod =
134+
| {
135+
type: 'aws-profile';
136+
profile: string;
137+
accessKeyId: string;
138+
secretAccessKey: string;
139+
}
140+
| { type: 'oauth' }
141+
| { type: 'credentials'; accessKeyId: string; secretAccessKey: string }
142+
| {
143+
type: 'environment';
144+
accessKeyId: string;
145+
secretAccessKey: string;
146+
source: 'tigris' | 'aws';
147+
}
148+
| { type: 'configured'; accessKeyId: string; secretAccessKey: string }
149+
| { type: 'none' };
150+
151+
/**
152+
* Resolve which auth method is active, following the same priority as getStorageConfig().
153+
* 1. AWS Profile 2. Env vars (AWS_ then TIGRIS_) 3. OAuth 4. Credentials login 5. Configured
154+
*
155+
* Env vars come before login methods because setting them is an explicit
156+
* per-session override that should win over a previously stored login.
157+
*/
158+
export async function resolveAuthMethod(): Promise<AuthMethod> {
159+
// 1. AWS profile
160+
if (hasAwsProfile()) {
161+
const profile = process.env.AWS_PROFILE || 'default';
162+
const resolved = await fromIni({ profile })();
163+
return {
164+
type: 'aws-profile',
165+
profile,
166+
accessKeyId: resolved.accessKeyId,
167+
secretAccessKey: resolved.secretAccessKey,
168+
};
169+
}
170+
171+
// 2. Env vars (explicit per-session override)
172+
const envCreds = getEnvCredentials();
173+
if (envCreds) {
174+
return {
175+
type: 'environment',
176+
accessKeyId: envCreds.accessKeyId,
177+
secretAccessKey: envCreds.secretAccessKey,
178+
source: envCreds.source,
179+
};
180+
}
181+
182+
// 3–4. Login (oauth or credentials)
183+
const loginMethod = getStoredLoginMethod();
184+
185+
if (loginMethod === 'oauth') {
186+
return { type: 'oauth' };
187+
}
188+
189+
if (loginMethod === 'credentials') {
190+
const stored = getStoredCredentials();
191+
if (stored) {
192+
return {
193+
type: 'credentials',
194+
accessKeyId: stored.accessKeyId,
195+
secretAccessKey: stored.secretAccessKey,
196+
};
197+
}
198+
}
199+
200+
// 5. Configured credentials
201+
const configured = getStoredCredentials();
202+
if (configured) {
203+
return {
204+
type: 'configured',
205+
accessKeyId: configured.accessKeyId,
206+
secretAccessKey: configured.secretAccessKey,
207+
};
208+
}
209+
210+
return { type: 'none' };
211+
}
212+
213+
// ---------------------------------------------------------------------------
214+
// Storage config
215+
// ---------------------------------------------------------------------------
216+
75217
export type TigrisStorageConfig = {
76218
bucket?: string;
77219
accessKeyId?: string;
@@ -92,99 +234,89 @@ export type TigrisStorageConfig = {
92234
export async function getStorageConfig(options?: {
93235
withCredentialProvider?: boolean;
94236
}): Promise<TigrisStorageConfig> {
95-
// 1. AWS profile (only if AWS_PROFILE is set)
96-
if (hasAwsProfile()) {
97-
const profile = process.env.AWS_PROFILE || 'default';
98-
const profileConfig = await getAwsProfileConfig(profile);
99-
const resolved = await fromIni({ profile })();
100-
return {
101-
accessKeyId: resolved.accessKeyId,
102-
secretAccessKey: resolved.secretAccessKey,
103-
endpoint:
104-
profileConfig.endpoint ||
105-
tigrisConfig.endpoint ||
106-
DEFAULT_STORAGE_ENDPOINT,
107-
iamEndpoint: profileConfig.iamEndpoint || tigrisConfig.iamEndpoint,
108-
};
109-
}
237+
const method = await resolveAuthMethod();
110238

111-
// 2. Login (oauth or credentials)
112-
const loginMethod = await getLoginMethod();
239+
switch (method.type) {
240+
case 'aws-profile': {
241+
const profileConfig = await getAwsProfileConfig(method.profile);
242+
return {
243+
accessKeyId: method.accessKeyId,
244+
secretAccessKey: method.secretAccessKey,
245+
endpoint:
246+
profileConfig.endpoint ||
247+
tigrisConfig.endpoint ||
248+
DEFAULT_STORAGE_ENDPOINT,
249+
iamEndpoint: profileConfig.iamEndpoint || tigrisConfig.iamEndpoint,
250+
};
251+
}
113252

114-
if (loginMethod === 'oauth') {
115-
const authClient = getAuthClient();
116-
const selectedOrg = getSelectedOrganization();
253+
case 'oauth': {
254+
const authClient = getAuthClient();
255+
const selectedOrg = getSelectedOrganization();
117256

118-
if (!selectedOrg) {
119-
throw new Error(
120-
'No organization selected. Please run "tigris orgs select" first.'
121-
);
122-
}
257+
if (!selectedOrg) {
258+
throw new Error(
259+
'No organization selected. Please run "tigris orgs select" first.'
260+
);
261+
}
123262

124-
return {
125-
sessionToken: await authClient.getAccessToken(),
126-
accessKeyId: '',
127-
secretAccessKey: '',
128-
// Only include credentialProvider for long-running operations (uploads)
129-
// that need token refresh. Short-lived operations (ls, rm, head) use
130-
// the static sessionToken above and benefit from S3Client caching.
131-
...(options?.withCredentialProvider && {
132-
credentialProvider: async () => ({
133-
accessKeyId: '',
134-
secretAccessKey: '',
135-
sessionToken: await authClient.getAccessToken(),
136-
expiration: new Date(Date.now() + 10 * 60 * 1000),
263+
return {
264+
sessionToken: await authClient.getAccessToken(),
265+
accessKeyId: '',
266+
secretAccessKey: '',
267+
// Only include credentialProvider for long-running operations (uploads)
268+
// that need token refresh. Short-lived operations (ls, rm, head) use
269+
// the static sessionToken above and benefit from S3Client caching.
270+
...(options?.withCredentialProvider && {
271+
credentialProvider: async () => ({
272+
accessKeyId: '',
273+
secretAccessKey: '',
274+
sessionToken: await authClient.getAccessToken(),
275+
expiration: new Date(Date.now() + 10 * 60 * 1000),
276+
}),
137277
}),
138-
}),
139-
endpoint: tigrisConfig.endpoint,
140-
organizationId: selectedOrg,
141-
iamEndpoint: tigrisConfig.iamEndpoint,
142-
authDomain: auth0Config.domain,
143-
};
144-
}
278+
endpoint: tigrisConfig.endpoint,
279+
organizationId: selectedOrg,
280+
iamEndpoint: tigrisConfig.iamEndpoint,
281+
authDomain: auth0Config.domain,
282+
};
283+
}
145284

146-
if (loginMethod === 'credentials') {
147-
const loginCredentials = getStoredCredentials();
148-
if (loginCredentials) {
285+
case 'credentials': {
149286
const selectedOrg = getSelectedOrganization();
150287
return {
151-
accessKeyId: loginCredentials.accessKeyId,
152-
secretAccessKey: loginCredentials.secretAccessKey,
153-
endpoint: loginCredentials.endpoint,
288+
accessKeyId: method.accessKeyId,
289+
secretAccessKey: method.secretAccessKey,
290+
endpoint: getStoredCredentials()?.endpoint || DEFAULT_STORAGE_ENDPOINT,
154291
organizationId: selectedOrg ?? undefined,
155292
iamEndpoint: tigrisConfig.iamEndpoint,
156293
};
157294
}
158-
}
159-
160-
// 3. Env vars
161-
const envCredentials = getEnvCredentials();
162-
if (envCredentials) {
163-
return {
164-
accessKeyId: envCredentials.accessKeyId,
165-
secretAccessKey: envCredentials.secretAccessKey,
166-
endpoint: envCredentials.endpoint,
167-
};
168-
}
169295

170-
// 4. Configured credentials
171-
const credentials = getStoredCredentials();
296+
case 'environment':
297+
return {
298+
accessKeyId: method.accessKeyId,
299+
secretAccessKey: method.secretAccessKey,
300+
endpoint: getEnvCredentials()?.endpoint || DEFAULT_STORAGE_ENDPOINT,
301+
};
172302

173-
if (credentials) {
174-
return {
175-
accessKeyId: credentials.accessKeyId,
176-
secretAccessKey: credentials.secretAccessKey,
177-
endpoint: credentials.endpoint,
178-
};
179-
}
303+
case 'configured':
304+
return {
305+
accessKeyId: method.accessKeyId,
306+
secretAccessKey: method.secretAccessKey,
307+
endpoint: getStoredCredentials()?.endpoint || DEFAULT_STORAGE_ENDPOINT,
308+
};
180309

181-
// No valid auth method found — try auto-login in interactive terminals
182-
if (await triggerAutoLogin()) {
183-
return getStorageConfig(options);
310+
case 'none': {
311+
// No valid auth method found — try auto-login in interactive terminals
312+
if (await triggerAutoLogin()) {
313+
return getStorageConfig(options);
314+
}
315+
throw new Error(
316+
'Not authenticated. Please run "tigris login" or "tigris configure" first.'
317+
);
318+
}
184319
}
185-
throw new Error(
186-
'Not authenticated. Please run "tigris login" or "tigris configure" first.'
187-
);
188320
}
189321

190322
/**
@@ -193,8 +325,8 @@ export async function getStorageConfig(options?: {
193325
export async function isAuthenticated(): Promise<boolean> {
194326
return (
195327
hasAwsProfile() ||
196-
(await getLoginMethod()) !== null ||
197328
getEnvCredentials() !== null ||
329+
(await getLoginMethod()) !== null ||
198330
getStoredCredentials() !== null
199331
);
200332
}

0 commit comments

Comments
 (0)