From 3ce276a5cb5d8251cf0b98cc090932fe5ce0af17 Mon Sep 17 00:00:00 2001 From: A Ibrahim Date: Tue, 7 Apr 2026 16:00:17 +0200 Subject: [PATCH 1/3] fix: fix whoami for fly users also move fetchOrganizationsFromUserInfo from fly to client --- src/auth/client.ts | 142 +++++++++++++++------------------- src/auth/fly.ts | 42 +--------- src/auth/provider.ts | 23 +----- src/auth/storage.ts | 21 ++--- src/lib/configure/index.ts | 6 +- src/lib/login/credentials.ts | 2 +- src/lib/login/oauth.ts | 2 +- src/lib/organizations/list.ts | 17 +--- src/lib/presign.ts | 7 +- src/lib/whoami.ts | 16 +--- 10 files changed, 86 insertions(+), 192 deletions(-) diff --git a/src/auth/client.ts b/src/auth/client.ts index c1eb06f..ad38d89 100644 --- a/src/auth/client.ts +++ b/src/auth/client.ts @@ -3,18 +3,11 @@ * Uses Device Authorization Flow (OAuth 2.0 Device Flow) */ +import type { Organization } from '@tigrisdata/iam'; import { createRemoteJWKSet, jwtVerify } from 'jose'; import open from 'open'; -import type { OrganizationInfo, TokenSet } from './storage.js'; -import { - clearTokens, - getOrganizations, - getTokens, - storeLoginMethod, - storeOrganizations, - storeTokens, -} from './storage.js'; +import * as storage from './storage.js'; /** * Auth0 configuration for CLI authentication @@ -23,6 +16,7 @@ export interface Auth0Config { domain: string; clientId: string; audience: string; + claimsNamespace: string; } /** @@ -39,16 +33,11 @@ export function getAuth0Config(): Auth0Config { const audience = isDev ? 'https://tigris-api-dev' : (process.env.AUTH0_AUDIENCE ?? 'https://tigris-os-api'); - - return { domain, clientId, audience }; + const claimsNamespace = + process.env.TIGRIS_CLAIMS_NAMESPACE ?? 'https://tigris'; + return { domain, clientId, audience, claimsNamespace }; } -/** - * Custom claims namespace for Tigris - */ -export const TIGRIS_CLAIMS_NAMESPACE = - process.env.TIGRIS_CLAIMS_NAMESPACE || 'https://tigris'; - /** * OAuth-specific types */ @@ -59,17 +48,6 @@ export interface IdTokenClaims { [key: string]: unknown; } -interface TigrisOrg { - id: string; - name: string; - slug: string; -} - -interface TigrisNamespace { - ns?: (string | TigrisOrg)[]; - organizations?: (string | TigrisOrg)[]; -} - interface DeviceCodeResponse { device_code: string; user_code: string; @@ -173,13 +151,13 @@ export class TigrisAuthClient { ); // Store tokens securely - await storeTokens(tokens); + await storage.storeTokens(tokens); // Store login method - storeLoginMethod('oauth'); + storage.storeLoginMethod('oauth'); // Extract and store organizations - await this.extractAndStoreOrganizations(tokens.idToken); + await this.extractAndStoreOrganizations(); } /** @@ -188,7 +166,7 @@ export class TigrisAuthClient { private async pollForToken( deviceCode: string, interval: number - ): Promise { + ): Promise { const maxAttempts = 60; // 5 minutes with 5-second intervals let attempts = 0; @@ -264,7 +242,7 @@ export class TigrisAuthClient { * Get valid access token (refresh if needed) */ async getAccessToken(): Promise { - let tokens = await getTokens(); + let tokens = await storage.getTokens(); if (!tokens) { throw new Error( @@ -284,11 +262,13 @@ export class TigrisAuthClient { /** * Refresh access token using refresh token */ - async refreshAccessToken(tokens?: TokenSet): Promise { - let tokenSet: TokenSet | null; + async refreshAccessToken( + tokens?: storage.TokenSet + ): Promise { + let tokenSet: storage.TokenSet | null; if (!tokens?.refreshToken) { - tokenSet = await getTokens(); + tokenSet = await storage.getTokens(); } else { tokenSet = tokens; } @@ -317,18 +297,18 @@ export class TigrisAuthClient { const data: TokenResponse = JSON.parse(await response.text()); - const newTokens: TokenSet = { + const newTokens: storage.TokenSet = { accessToken: data.access_token, refreshToken: data.refresh_token || tokenSet.refreshToken, idToken: data.id_token || tokenSet.idToken, expiresAt: Date.now() + (data.expires_in || 3600) * 1000, }; - await storeTokens(newTokens); + await storage.storeTokens(newTokens); return newTokens; } catch { // Refresh failed, clear tokens and prompt re-login - await clearTokens(); + await storage.clearTokens(); throw new Error( 'Token refresh failed. Please run "tigris login" to re-authenticate.' @@ -340,7 +320,7 @@ export class TigrisAuthClient { * Get ID token claims */ async getIdTokenClaims(): Promise { - const tokens = await getTokens(); + const tokens = await storage.getTokens(); if (!tokens || !tokens.idToken) { throw new Error( @@ -360,77 +340,79 @@ export class TigrisAuthClient { /** * Extract organizations from ID token claims and store them */ - async extractAndStoreOrganizations( - idToken: string | undefined - ): Promise { - if (!idToken) { - return; + async extractAndStoreOrganizations(): Promise { + try { + const availableOrgs = await this.fetchOrganizationsFromUserInfo(); + if (availableOrgs) { + storage.storeOrganizations(availableOrgs); + } + } catch { + // Silently fail - organizations will need to be fetched another way } + } + async fetchOrganizationsFromUserInfo(): Promise { try { - const claims = await this.verifyIdToken(idToken); - - // Extract Tigris namespace - const tigrisNamespace = claims[TIGRIS_CLAIMS_NAMESPACE] as - | TigrisNamespace - | undefined; + const { domain, claimsNamespace } = this.config; + const accessToken = await this.getAccessToken(); + const response = await fetch(`https://${domain}/userinfo`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); - if (!tigrisNamespace) { - return; + if (!response.ok) { + return null; } - // Get organizations from 'ns' field - const availableOrgs: OrganizationInfo[] = - tigrisNamespace?.ns?.map((org: string | TigrisOrg) => { - // If org is already an object with id, name, slug - if (typeof org === 'object' && org !== null) { - return { - id: org.id, - name: org.name, - displayName: org.name, - }; - } - // Otherwise it's a string - return { - id: org as string, - name: org as string, - displayName: org as string, - }; - }) || []; - - if (availableOrgs.length === 0) { - return; + const data: Record = JSON.parse( + await response.text() + ); + + const namespaces: Organization[] | undefined = data[claimsNamespace]?.ns; + + if (!Array.isArray(namespaces)) { + return null; } - storeOrganizations(availableOrgs); + return namespaces.map((ns: Organization) => ({ + id: ns.id, + name: ns.name, + slug: ns.name, + })); } catch { - // Silently fail - organizations will need to be fetched another way + return null; } } /** * Get organizations from stored claims */ - async getOrganizations(): Promise { + async getOrganizations(): Promise { // First, ensure we have a valid token await this.getAccessToken(); // Return stored organizations - return getOrganizations(); + return storage.getOrganizations(); + } + + async isFlyUser(): Promise { + // First, ensure we have a valid token + const idTokenClaims = await this.getIdTokenClaims(); + + return idTokenClaims.sub.includes('fly-sso'); } /** * Logout and clear all stored data */ async logout(): Promise { - await clearTokens(); + await storage.clearTokens(); } /** * Check if user is authenticated */ async isAuthenticated(): Promise { - const tokens = await getTokens(); + const tokens = await storage.getTokens(); return tokens !== null; } diff --git a/src/auth/fly.ts b/src/auth/fly.ts index 063caa1..3b55bc5 100644 --- a/src/auth/fly.ts +++ b/src/auth/fly.ts @@ -1,9 +1,4 @@ -import { getAuth0Config, TIGRIS_CLAIMS_NAMESPACE } from './client.js'; -import { getSelectedOrganization, type OrganizationInfo } from './storage.js'; - -export function isFlyUser(organizationId?: string): boolean { - return !!organizationId?.startsWith('flyio_'); -} +import { getSelectedOrganization } from './storage.js'; /** * Check if current org is Fly.io. Prints message and returns true if so. @@ -11,7 +6,7 @@ export function isFlyUser(organizationId?: string): boolean { */ export function isFlyOrganization(feature: string): boolean { const selectedOrg = getSelectedOrganization(); - if (isFlyUser(selectedOrg ?? undefined)) { + if (selectedOrg?.startsWith('flyio_')) { console.log( `${feature} is not available for Fly.io organizations.\n` + 'Your resources are managed through Fly.io.\n\n' + @@ -21,36 +16,3 @@ export function isFlyOrganization(feature: string): boolean { } return false; } - -export async function fetchOrganizationsFromUserInfo( - accessToken: string -): Promise { - try { - const { domain } = getAuth0Config(); - const response = await fetch(`https://${domain}/userinfo`, { - headers: { Authorization: `Bearer ${accessToken}` }, - }); - - if (!response.ok) { - return null; - } - - const data: Record = JSON.parse( - await response.text() - ); - const namespaces: OrganizationInfo[] | undefined = - data[TIGRIS_CLAIMS_NAMESPACE]?.ns; - - if (!Array.isArray(namespaces)) { - return null; - } - - return namespaces.map((ns: OrganizationInfo) => ({ - id: ns.id, - name: ns.name, - displayName: ns.name, - })); - } catch { - return null; - } -} diff --git a/src/auth/provider.ts b/src/auth/provider.ts index 958e992..4294e24 100644 --- a/src/auth/provider.ts +++ b/src/auth/provider.ts @@ -90,18 +90,6 @@ export function getEnvCredentials(): EnvCredentials | null { 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. @@ -117,15 +105,6 @@ async function triggerAutoLogin(): Promise { return true; } -/** - * Get the login method used by the user - */ -export async function getLoginMethod(): Promise< - 'oauth' | 'credentials' | null -> { - return getStoredLoginMethod(); -} - // --------------------------------------------------------------------------- // Auth method resolution — single source of truth for auth priority // --------------------------------------------------------------------------- @@ -341,7 +320,7 @@ export function requireOAuthLogin(operation: string): boolean { const loginMethod = getStoredLoginMethod(); if (loginMethod === 'oauth') return false; - if (getCredentials()) { + if (getEnvCredentials() || getStoredCredentials()) { console.log( `You are using access key credentials, which belong to a single organization.\n` + `${operation} is only available with OAuth login.\n\n` + diff --git a/src/auth/storage.ts b/src/auth/storage.ts index e500d1a..59042b5 100644 --- a/src/auth/storage.ts +++ b/src/auth/storage.ts @@ -3,6 +3,7 @@ */ import { loadSharedConfigFiles } from '@smithy/shared-ini-file-loader'; +import type { Organization } from '@tigrisdata/iam'; import { execFileSync } from 'child_process'; import { chmodSync, @@ -21,12 +22,6 @@ export interface TokenSet { expiresAt: number; // Unix timestamp } -export interface OrganizationInfo { - id: string; - name: string; - displayName?: string; -} - /** * Credentials configuration interface (public — callers use this shape) */ @@ -54,7 +49,7 @@ interface TigrisConfigV2 { activeMethod?: 'oauth' | 'credentials'; oauth?: { tokens?: TokenSet; - organizations?: OrganizationInfo[]; + organizations?: Organization[]; selectedOrganization?: string; }; credentials?: { @@ -232,7 +227,7 @@ export async function clearTokens(): Promise { * Store organizations list */ export async function storeOrganizations( - organizations: OrganizationInfo[] + organizations: Organization[] ): Promise { const config = readConfig(); if (!config.oauth) { @@ -246,7 +241,7 @@ export async function storeOrganizations( /** * Get stored organizations */ -export function getOrganizations(): OrganizationInfo[] { +export function getOrganizations(): Organization[] { const config = readConfig(); return config.oauth?.organizations ?? []; } @@ -411,14 +406,14 @@ export function getLoginMethod(): 'oauth' | 'credentials' | null { } /** - * Store organizationId on the active credential slot - * (temporary if it exists, else saved) + * Store organizationId on the specified credential slot. */ export async function storeCredentialOrganization( - orgId: string + orgId: string, + target: 'saved' | 'temporary' ): Promise { const config = readConfig(); - const slot = config.credentials?.temporary ?? config.credentials?.saved; + const slot = config.credentials?.[target]; if (slot) { slot.organizationId = orgId; await writeConfig(config); diff --git a/src/lib/configure/index.ts b/src/lib/configure/index.ts index a0dd136..6a93220 100644 --- a/src/lib/configure/index.ts +++ b/src/lib/configure/index.ts @@ -4,7 +4,6 @@ import { getTigrisConfig } from '@auth/provider.js'; import { storeCredentialOrganization, storeCredentials, - storeLoginMethod, } from '@auth/storage.js'; import { whoami } from '@tigrisdata/iam'; import { exitWithError, failWithError, printNextActions } from '@utils/exit.js'; @@ -99,9 +98,6 @@ export default async function configure(options: Record) { endpoint: endpoint as string, }); - // Store login method - await storeLoginMethod('credentials'); - // Fetch and store organizationId from whoami (best-effort) try { const tigrisConfig = getTigrisConfig(); @@ -113,7 +109,7 @@ export default async function configure(options: Record) { }, }); if (data?.organizationId) { - await storeCredentialOrganization(data.organizationId); + await storeCredentialOrganization(data.organizationId, 'saved'); } } catch { // Non-fatal — org will just be missing diff --git a/src/lib/login/credentials.ts b/src/lib/login/credentials.ts index b1ca568..89aab7f 100644 --- a/src/lib/login/credentials.ts +++ b/src/lib/login/credentials.ts @@ -106,7 +106,7 @@ export default async function credentials(options: Record) { }, }); if (data?.organizationId) { - await storeCredentialOrganization(data.organizationId); + await storeCredentialOrganization(data.organizationId, 'temporary'); } } catch { // Non-fatal — org will just be missing diff --git a/src/lib/login/oauth.ts b/src/lib/login/oauth.ts index bac9099..bd84fc9 100644 --- a/src/lib/login/oauth.ts +++ b/src/lib/login/oauth.ts @@ -46,7 +46,7 @@ export async function oauth( console.log(JSON.stringify({ action: 'logged_in' })); } - printSuccess(context, { org: firstOrg.displayName || firstOrg.name }); + printSuccess(context, { org: firstOrg.name }); printNextActions(context); if (orgs.length > 1) { diff --git a/src/lib/organizations/list.ts b/src/lib/organizations/list.ts index beca0a9..133309c 100644 --- a/src/lib/organizations/list.ts +++ b/src/lib/organizations/list.ts @@ -1,11 +1,10 @@ import { getAuthClient } from '@auth/client.js'; -import { fetchOrganizationsFromUserInfo, isFlyUser } from '@auth/fly.js'; import { getStorageConfig, requireOAuthLogin } from '@auth/provider.js'; import { getSelectedOrganization, storeSelectedOrganization, } from '@auth/storage.js'; -import { listOrganizations } from '@tigrisdata/iam'; +import { listOrganizations, type Organization } from '@tigrisdata/iam'; import { failWithError } from '@utils/exit.js'; import { formatOutput } from '@utils/format.js'; import { requireInteractive } from '@utils/interactive.js'; @@ -22,20 +21,12 @@ export default async function list(options: Record) { const format = getFormat(options, 'select'); - // For Fly users, fetch organizations from userinfo endpoint const authClient = getAuthClient(); - const accessToken = await authClient.getAccessToken(); - const tokenOrgs = await fetchOrganizationsFromUserInfo(accessToken); - const flyUser = tokenOrgs?.some((org) => isFlyUser(org.id)) ?? false; - let orgs: { id: string; name: string; slug: string }[]; + let orgs: Organization[]; - if (flyUser && tokenOrgs) { - orgs = tokenOrgs.map((org) => ({ - id: org.id, - name: org.name, - slug: org.name, - })); + if (await authClient.isFlyUser()) { + orgs = (await authClient.fetchOrganizationsFromUserInfo()) ?? []; } else { const config = await getStorageConfig(); const { data, error } = await listOrganizations({ config }); diff --git a/src/lib/presign.ts b/src/lib/presign.ts index fdb4fde..f5e2bd6 100644 --- a/src/lib/presign.ts +++ b/src/lib/presign.ts @@ -1,7 +1,6 @@ import { getAuthClient } from '@auth/client.js'; -import { getLoginMethod, getStorageConfig } from '@auth/provider.js'; -import { getTigrisConfig } from '@auth/provider.js'; -import { getSelectedOrganization } from '@auth/storage.js'; +import { getStorageConfig, getTigrisConfig } from '@auth/provider.js'; +import { getLoginMethod, getSelectedOrganization } from '@auth/storage.js'; import type { AccessKey } from '@tigrisdata/iam'; import { listAccessKeys } from '@tigrisdata/iam'; import { getPresignedUrl } from '@tigrisdata/storage'; @@ -51,7 +50,7 @@ export default async function presign(options: Record) { accessKeyId = config.accessKeyId; } else { // 3. OAuth login — need to resolve an access key - const loginMethod = await getLoginMethod(); + const loginMethod = getLoginMethod(); if (loginMethod !== 'oauth') { exitWithError( diff --git a/src/lib/whoami.ts b/src/lib/whoami.ts index 9617473..7b176c5 100644 --- a/src/lib/whoami.ts +++ b/src/lib/whoami.ts @@ -1,11 +1,7 @@ import { getAuthClient } from '@auth/client.js'; -import { - getStorageConfig, - getTigrisConfig, - resolveAuthMethod, -} from '@auth/provider.js'; +import { getTigrisConfig, resolveAuthMethod } from '@auth/provider.js'; import { getSelectedOrganization } from '@auth/storage.js'; -import { listOrganizations, whoami as iamWhoami } from '@tigrisdata/iam'; +import { whoami as iamWhoami } from '@tigrisdata/iam'; import { failWithError } from '@utils/exit.js'; import { msg, printAlreadyDone } from '@utils/messages.js'; import { getFormat } from '@utils/options.js'; @@ -86,14 +82,8 @@ export default async function whoami( email = claims.email; userId = claims.sub; - // Fetch organizations - const config = await getStorageConfig(); selectedOrg = getSelectedOrganization(); - const { data, error } = await listOrganizations({ config }); - if (error) { - failWithError(context, error); - } - organizations = data?.organizations ?? []; + organizations = await authClient.getOrganizations(); break; } From 3537068f0682035057d8303a79ebefe346131df6 Mon Sep 17 00:00:00 2001 From: A Ibrahim Date: Tue, 7 Apr 2026 16:23:33 +0200 Subject: [PATCH 2/3] test: update storage tests for refactored APIs Update tests to match removed getCredentials from provider (now getStoredCredentials on storage) and storeCredentialOrganization's new explicit target parameter. Co-Authored-By: Claude Opus 4.6 (1M context) --- test/auth/storage.test.ts | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/test/auth/storage.test.ts b/test/auth/storage.test.ts index b251e83..5d4b231 100644 --- a/test/auth/storage.test.ts +++ b/test/auth/storage.test.ts @@ -214,9 +214,9 @@ describe('auth/storage', () => { }); // --------------------------------------------------------------------------- - // getCredentials priority: env > temporary > saved + // getStoredCredentials priority: temporary > saved // --------------------------------------------------------------------------- - describe('getCredentials', () => { + describe('getStoredCredentials', () => { it('returns temporary over saved', async () => { writeRawConfig({ version: 2, @@ -234,8 +234,8 @@ describe('auth/storage', () => { }, }); - const provider = await import('../../src/auth/provider.js'); - expect(provider.getCredentials()?.accessKeyId).toBe('TEMP'); + const storage = await import('../../src/auth/storage.js'); + expect(storage.getStoredCredentials()?.accessKeyId).toBe('TEMP'); }); it('falls back to saved when no temporary', async () => { @@ -250,14 +250,14 @@ describe('auth/storage', () => { }, }); - const provider = await import('../../src/auth/provider.js'); - expect(provider.getCredentials()?.accessKeyId).toBe('SAVED'); + const storage = await import('../../src/auth/storage.js'); + expect(storage.getStoredCredentials()?.accessKeyId).toBe('SAVED'); }); it('returns null when no credentials exist', async () => { writeRawConfig({ version: 2 }); - const provider = await import('../../src/auth/provider.js'); - expect(provider.getCredentials()).toBeNull(); + const storage = await import('../../src/auth/storage.js'); + expect(storage.getStoredCredentials()).toBeNull(); }); }); @@ -332,7 +332,7 @@ describe('auth/storage', () => { // storeCredentialOrganization // --------------------------------------------------------------------------- describe('storeCredentialOrganization', () => { - it('writes to temporary slot when it exists', async () => { + it('writes to temporary slot when target is temporary', async () => { writeRawConfig({ version: 2, activeMethod: 'credentials', @@ -351,7 +351,7 @@ describe('auth/storage', () => { }); const storage = await import('../../src/auth/storage.js'); - await storage.storeCredentialOrganization('my-org'); + await storage.storeCredentialOrganization('my-org', 'temporary'); const raw = readRawConfig() as Record; const creds = raw['credentials'] as Record; @@ -363,7 +363,7 @@ describe('auth/storage', () => { expect(saved['organizationId']).toBeUndefined(); }); - it('writes to saved slot when no temporary exists', async () => { + it('writes to saved slot when target is saved', async () => { writeRawConfig({ version: 2, activeMethod: 'credentials', @@ -377,7 +377,7 @@ describe('auth/storage', () => { }); const storage = await import('../../src/auth/storage.js'); - await storage.storeCredentialOrganization('saved-org'); + await storage.storeCredentialOrganization('saved-org', 'saved'); const raw = readRawConfig() as Record; const creds = raw['credentials'] as Record; @@ -386,11 +386,11 @@ describe('auth/storage', () => { expect(saved['organizationId']).toBe('saved-org'); }); - it('does nothing when no credential slots exist', async () => { + it('does nothing when target slot does not exist', async () => { writeRawConfig({ version: 2 }); const storage = await import('../../src/auth/storage.js'); - await storage.storeCredentialOrganization('org-id'); + await storage.storeCredentialOrganization('org-id', 'saved'); const raw = readRawConfig() as Record; expect(raw['credentials']).toBeUndefined(); From 09c8c19a561ace374ab8f75a64cc159f970970d2 Mon Sep 17 00:00:00 2001 From: A Ibrahim Date: Tue, 7 Apr 2026 16:35:35 +0200 Subject: [PATCH 3/3] fix: include organizationId and iamEndpoint in configured credentials path --- src/auth/provider.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/auth/provider.ts b/src/auth/provider.ts index 4294e24..eef6c58 100644 --- a/src/auth/provider.ts +++ b/src/auth/provider.ts @@ -279,12 +279,16 @@ export async function getStorageConfig(options?: { endpoint: getEnvCredentials()?.endpoint || DEFAULT_STORAGE_ENDPOINT, }; - case 'configured': + case 'configured': { + const selectedOrg = getSelectedOrganization(); return { accessKeyId: method.accessKeyId, secretAccessKey: method.secretAccessKey, endpoint: getStoredCredentials()?.endpoint || DEFAULT_STORAGE_ENDPOINT, + organizationId: selectedOrg ?? undefined, + iamEndpoint: tigrisConfig.iamEndpoint, }; + } case 'none': { // No valid auth method found — try auto-login in interactive terminals