Skip to content
Merged
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,10 @@
"build": "tsc --noEmit && tsup",
"dev": "export $(grep -v '^#' .env | xargs) && (tsc --noEmit --watch --preserveWatchOutput & tsup --watch)",
"cli": "node dist/cli.js",
"lint": "eslint src",
"lint:fix": "eslint src --fix",
"format": "prettier --write \"src/**/*.ts\"",
"format:check": "prettier --check \"src/**/*.ts\"",
"lint": "eslint src test",
"lint:fix": "eslint src test --fix",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\"",
"test": "vitest run --exclude test/cli.test.ts",
"test:watch": "vitest",
"test:all": "vitest run",
Expand Down
4 changes: 2 additions & 2 deletions src/auth/iam.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import type { MessageContext } from '@utils/messages.js';

import { getAuthClient } from './client.js';
import { isFlyUser } from './fly.js';
import { getLoginMethod, getTigrisConfig } from './provider.js';
import { getCredentials, getSelectedOrganization } from './storage.js';
import { getCredentials, getLoginMethod, getTigrisConfig } from './provider.js';
import { getSelectedOrganization } from './storage.js';

/**
* Check if current org is Fly.io. Prints message and returns true if so.
Expand Down
310 changes: 221 additions & 89 deletions src/auth/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,8 @@ import {
} from '../constants.js';
import { getAuth0Config, getAuthClient } from './client.js';
import {
type CredentialsConfig,
getAwsProfileConfig,
getCredentials,
getEnvCredentials,
getLoginMethod as getStoredLoginMethod,
getSelectedOrganization,
getStoredCredentials,
Expand All @@ -28,26 +27,81 @@ export interface TigrisConfig {
}

export function getTigrisConfig(): TigrisConfig {
// If any TIGRIS_ endpoint var is set, use TIGRIS_ vars exclusively
if (process.env.TIGRIS_STORAGE_ENDPOINT || process.env.TIGRIS_IAM_ENDPOINT) {
// AWS_ endpoint vars take priority
if (process.env.AWS_ENDPOINT_URL_S3 || process.env.AWS_ENDPOINT_URL_IAM) {
return {
endpoint: process.env.TIGRIS_STORAGE_ENDPOINT || DEFAULT_STORAGE_ENDPOINT,
iamEndpoint: process.env.TIGRIS_IAM_ENDPOINT || DEFAULT_IAM_ENDPOINT,
mgmtEndpoint: process.env.TIGRIS_MGMT_ENDPOINT || DEFAULT_MGMT_ENDPOINT,
endpoint: process.env.AWS_ENDPOINT_URL_S3 || DEFAULT_STORAGE_ENDPOINT,
iamEndpoint: process.env.AWS_ENDPOINT_URL_IAM || DEFAULT_IAM_ENDPOINT,
mgmtEndpoint: process.env.AWS_ENDPOINT_URL_MGMT || DEFAULT_MGMT_ENDPOINT,
};
}

// Fall back to AWS_ vars
// Fall back to TIGRIS_ vars
return {
endpoint: process.env.AWS_ENDPOINT_URL_S3 || DEFAULT_STORAGE_ENDPOINT,
iamEndpoint: process.env.AWS_ENDPOINT_URL_IAM || DEFAULT_IAM_ENDPOINT,
mgmtEndpoint: process.env.AWS_ENDPOINT_URL_MGMT || DEFAULT_MGMT_ENDPOINT,
endpoint: process.env.TIGRIS_STORAGE_ENDPOINT || DEFAULT_STORAGE_ENDPOINT,
iamEndpoint: process.env.TIGRIS_IAM_ENDPOINT || DEFAULT_IAM_ENDPOINT,
mgmtEndpoint: process.env.TIGRIS_MGMT_ENDPOINT || DEFAULT_MGMT_ENDPOINT,
};
}

const tigrisConfig = getTigrisConfig();
const auth0Config = getAuth0Config();

// ---------------------------------------------------------------------------
// Environment credential helpers
// ---------------------------------------------------------------------------

type EnvCredentials = CredentialsConfig & { source: 'tigris' | 'aws' };

/**
* Get credentials from environment variables.
* AWS_ vars take priority over TIGRIS_ vars.
* Within each family, both key and secret must be present.
* Returns the resolved credentials along with the env var family ('aws' | 'tigris').
*/
export function getEnvCredentials(): EnvCredentials | null {
// Check AWS_ vars first
if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) {
const endpoint =
process.env.AWS_ENDPOINT_URL_S3 || DEFAULT_STORAGE_ENDPOINT;
return {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
endpoint,
source: 'aws',
};
}

// Fall back to TIGRIS_ vars
if (
process.env.TIGRIS_STORAGE_ACCESS_KEY_ID &&
process.env.TIGRIS_STORAGE_SECRET_ACCESS_KEY
) {
const endpoint =
process.env.TIGRIS_STORAGE_ENDPOINT || DEFAULT_STORAGE_ENDPOINT;
return {
accessKeyId: process.env.TIGRIS_STORAGE_ACCESS_KEY_ID,
secretAccessKey: process.env.TIGRIS_STORAGE_SECRET_ACCESS_KEY,
endpoint,
source: 'tigris',
};
}

return null;
}

/**
* Get non-login credentials in priority order:
* 1. Environment variables (AWS_ACCESS_KEY_ID / TIGRIS_STORAGE_ACCESS_KEY_ID)
* 2. Stored credentials (temporary from login, then saved from configure)
*
* Note: AWS profile and login method checks are handled separately.
* Full resolution order: AWS_PROFILE → env vars → oauth → credentials login → configured
*/
export function getCredentials(): CredentialsConfig | null {
return getEnvCredentials() || getStoredCredentials() || null;
}

/**
* Trigger interactive login when not authenticated and stdin is a TTY.
* Returns true if login was triggered, false if non-interactive or already attempted.
Expand All @@ -72,6 +126,94 @@ export async function getLoginMethod(): Promise<
return getStoredLoginMethod();
}

// ---------------------------------------------------------------------------
// Auth method resolution — single source of truth for auth priority
// ---------------------------------------------------------------------------

export type AuthMethod =
| {
type: 'aws-profile';
profile: string;
accessKeyId: string;
secretAccessKey: string;
}
| { type: 'oauth' }
| { type: 'credentials'; accessKeyId: string; secretAccessKey: string }
| {
type: 'environment';
accessKeyId: string;
secretAccessKey: string;
source: 'tigris' | 'aws';
}
| { type: 'configured'; accessKeyId: string; secretAccessKey: string }
| { type: 'none' };

/**
* Resolve which auth method is active, following the same priority as getStorageConfig().
* 1. AWS Profile 2. Env vars (AWS_ then TIGRIS_) 3. OAuth 4. Credentials login 5. Configured
*
* Env vars come before login methods because setting them is an explicit
* per-session override that should win over a previously stored login.
*/
export async function resolveAuthMethod(): Promise<AuthMethod> {
// 1. AWS profile
if (hasAwsProfile()) {
const profile = process.env.AWS_PROFILE || 'default';
const resolved = await fromIni({ profile })();
return {
type: 'aws-profile',
profile,
accessKeyId: resolved.accessKeyId,
secretAccessKey: resolved.secretAccessKey,
};
}

// 2. Env vars (explicit per-session override)
const envCreds = getEnvCredentials();
if (envCreds) {
return {
type: 'environment',
accessKeyId: envCreds.accessKeyId,
secretAccessKey: envCreds.secretAccessKey,
source: envCreds.source,
};
}

// 3–4. Login (oauth or credentials)
const loginMethod = getStoredLoginMethod();

if (loginMethod === 'oauth') {
return { type: 'oauth' };
}

if (loginMethod === 'credentials') {
const stored = getStoredCredentials();
if (stored) {
return {
type: 'credentials',
accessKeyId: stored.accessKeyId,
secretAccessKey: stored.secretAccessKey,
};
}
}

// 5. Configured credentials
const configured = getStoredCredentials();
if (configured) {
return {
type: 'configured',
accessKeyId: configured.accessKeyId,
secretAccessKey: configured.secretAccessKey,
};
}

return { type: 'none' };
}

// ---------------------------------------------------------------------------
// Storage config
// ---------------------------------------------------------------------------

export type TigrisStorageConfig = {
bucket?: string;
accessKeyId?: string;
Expand All @@ -92,99 +234,89 @@ export type TigrisStorageConfig = {
export async function getStorageConfig(options?: {
withCredentialProvider?: boolean;
}): Promise<TigrisStorageConfig> {
// 1. AWS profile (only if AWS_PROFILE is set)
if (hasAwsProfile()) {
const profile = process.env.AWS_PROFILE || 'default';
const profileConfig = await getAwsProfileConfig(profile);
const resolved = await fromIni({ profile })();
return {
accessKeyId: resolved.accessKeyId,
secretAccessKey: resolved.secretAccessKey,
endpoint:
profileConfig.endpoint ||
tigrisConfig.endpoint ||
DEFAULT_STORAGE_ENDPOINT,
iamEndpoint: profileConfig.iamEndpoint || tigrisConfig.iamEndpoint,
};
}
const method = await resolveAuthMethod();

// 2. Login (oauth or credentials)
const loginMethod = await getLoginMethod();
switch (method.type) {
case 'aws-profile': {
const profileConfig = await getAwsProfileConfig(method.profile);
return {
accessKeyId: method.accessKeyId,
secretAccessKey: method.secretAccessKey,
endpoint:
profileConfig.endpoint ||
tigrisConfig.endpoint ||
DEFAULT_STORAGE_ENDPOINT,
iamEndpoint: profileConfig.iamEndpoint || tigrisConfig.iamEndpoint,
};
}

if (loginMethod === 'oauth') {
const authClient = getAuthClient();
const selectedOrg = getSelectedOrganization();
case 'oauth': {
const authClient = getAuthClient();
const selectedOrg = getSelectedOrganization();

if (!selectedOrg) {
throw new Error(
'No organization selected. Please run "tigris orgs select" first.'
);
}
if (!selectedOrg) {
throw new Error(
'No organization selected. Please run "tigris orgs select" first.'
);
}

return {
sessionToken: await authClient.getAccessToken(),
accessKeyId: '',
secretAccessKey: '',
// Only include credentialProvider for long-running operations (uploads)
// that need token refresh. Short-lived operations (ls, rm, head) use
// the static sessionToken above and benefit from S3Client caching.
...(options?.withCredentialProvider && {
credentialProvider: async () => ({
accessKeyId: '',
secretAccessKey: '',
sessionToken: await authClient.getAccessToken(),
expiration: new Date(Date.now() + 10 * 60 * 1000),
return {
sessionToken: await authClient.getAccessToken(),
accessKeyId: '',
secretAccessKey: '',
// Only include credentialProvider for long-running operations (uploads)
// that need token refresh. Short-lived operations (ls, rm, head) use
// the static sessionToken above and benefit from S3Client caching.
...(options?.withCredentialProvider && {
credentialProvider: async () => ({
accessKeyId: '',
secretAccessKey: '',
sessionToken: await authClient.getAccessToken(),
expiration: new Date(Date.now() + 10 * 60 * 1000),
}),
}),
}),
endpoint: tigrisConfig.endpoint,
organizationId: selectedOrg,
iamEndpoint: tigrisConfig.iamEndpoint,
authDomain: auth0Config.domain,
};
}
endpoint: tigrisConfig.endpoint,
organizationId: selectedOrg,
iamEndpoint: tigrisConfig.iamEndpoint,
authDomain: auth0Config.domain,
};
}

if (loginMethod === 'credentials') {
const loginCredentials = getStoredCredentials();
if (loginCredentials) {
case 'credentials': {
const selectedOrg = getSelectedOrganization();
return {
accessKeyId: loginCredentials.accessKeyId,
secretAccessKey: loginCredentials.secretAccessKey,
endpoint: loginCredentials.endpoint,
accessKeyId: method.accessKeyId,
secretAccessKey: method.secretAccessKey,
endpoint: getStoredCredentials()?.endpoint || DEFAULT_STORAGE_ENDPOINT,
organizationId: selectedOrg ?? undefined,
iamEndpoint: tigrisConfig.iamEndpoint,
};
}
}

// 3. Env vars
const envCredentials = getEnvCredentials();
if (envCredentials) {
return {
accessKeyId: envCredentials.accessKeyId,
secretAccessKey: envCredentials.secretAccessKey,
endpoint: envCredentials.endpoint,
};
}

// 4. Configured credentials
const credentials = getStoredCredentials();
case 'environment':
return {
accessKeyId: method.accessKeyId,
secretAccessKey: method.secretAccessKey,
endpoint: getEnvCredentials()?.endpoint || DEFAULT_STORAGE_ENDPOINT,
};

if (credentials) {
return {
accessKeyId: credentials.accessKeyId,
secretAccessKey: credentials.secretAccessKey,
endpoint: credentials.endpoint,
};
}
case 'configured':
return {
accessKeyId: method.accessKeyId,
secretAccessKey: method.secretAccessKey,
endpoint: getStoredCredentials()?.endpoint || DEFAULT_STORAGE_ENDPOINT,
};

// No valid auth method found — try auto-login in interactive terminals
if (await triggerAutoLogin()) {
return getStorageConfig(options);
case 'none': {
// No valid auth method found — try auto-login in interactive terminals
if (await triggerAutoLogin()) {
return getStorageConfig(options);
}
throw new Error(
'Not authenticated. Please run "tigris login" or "tigris configure" first.'
);
}
}
throw new Error(
'Not authenticated. Please run "tigris login" or "tigris configure" first.'
);
}

/**
Expand All @@ -193,8 +325,8 @@ export async function getStorageConfig(options?: {
export async function isAuthenticated(): Promise<boolean> {
return (
hasAwsProfile() ||
(await getLoginMethod()) !== null ||
getEnvCredentials() !== null ||
(await getLoginMethod()) !== null ||
getStoredCredentials() !== null
);
}
Expand Down
Loading