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
142 changes: 62 additions & 80 deletions src/auth/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -23,6 +16,7 @@ export interface Auth0Config {
domain: string;
clientId: string;
audience: string;
claimsNamespace: string;
}

/**
Expand All @@ -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
*/
Expand All @@ -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;
Expand Down Expand Up @@ -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();
}

/**
Expand All @@ -188,7 +166,7 @@ export class TigrisAuthClient {
private async pollForToken(
deviceCode: string,
interval: number
): Promise<TokenSet> {
): Promise<storage.TokenSet> {
const maxAttempts = 60; // 5 minutes with 5-second intervals
let attempts = 0;

Expand Down Expand Up @@ -264,7 +242,7 @@ export class TigrisAuthClient {
* Get valid access token (refresh if needed)
*/
async getAccessToken(): Promise<string> {
let tokens = await getTokens();
let tokens = await storage.getTokens();

if (!tokens) {
throw new Error(
Expand All @@ -284,11 +262,13 @@ export class TigrisAuthClient {
/**
* Refresh access token using refresh token
*/
async refreshAccessToken(tokens?: TokenSet): Promise<TokenSet> {
let tokenSet: TokenSet | null;
async refreshAccessToken(
tokens?: storage.TokenSet
): Promise<storage.TokenSet> {
let tokenSet: storage.TokenSet | null;

if (!tokens?.refreshToken) {
tokenSet = await getTokens();
tokenSet = await storage.getTokens();
} else {
tokenSet = tokens;
}
Expand Down Expand Up @@ -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.'
Expand All @@ -340,7 +320,7 @@ export class TigrisAuthClient {
* Get ID token claims
*/
async getIdTokenClaims(): Promise<IdTokenClaims> {
const tokens = await getTokens();
const tokens = await storage.getTokens();

if (!tokens || !tokens.idToken) {
throw new Error(
Expand All @@ -360,77 +340,79 @@ export class TigrisAuthClient {
/**
* Extract organizations from ID token claims and store them
*/
async extractAndStoreOrganizations(
idToken: string | undefined
): Promise<void> {
if (!idToken) {
return;
async extractAndStoreOrganizations(): Promise<void> {
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<Organization[] | null> {
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<string, { ns?: Organization[] }> = 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<OrganizationInfo[]> {
async getOrganizations(): Promise<Organization[]> {
// First, ensure we have a valid token
await this.getAccessToken();

// Return stored organizations
return getOrganizations();
return storage.getOrganizations();
}

async isFlyUser(): Promise<boolean> {
// 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<void> {
await clearTokens();
await storage.clearTokens();
}

/**
* Check if user is authenticated
*/
async isAuthenticated(): Promise<boolean> {
const tokens = await getTokens();
const tokens = await storage.getTokens();
return tokens !== null;
}

Expand Down
42 changes: 2 additions & 40 deletions src/auth/fly.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,12 @@
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.
* @param feature - what's unavailable, e.g. "User management" or "Organization creation"
*/
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' +
Expand All @@ -21,36 +16,3 @@ export function isFlyOrganization(feature: string): boolean {
}
return false;
}

export async function fetchOrganizationsFromUserInfo(
accessToken: string
): Promise<OrganizationInfo[] | null> {
try {
const { domain } = getAuth0Config();
const response = await fetch(`https://${domain}/userinfo`, {
headers: { Authorization: `Bearer ${accessToken}` },
});

if (!response.ok) {
return null;
}

const data: Record<string, { ns?: OrganizationInfo[] }> = 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;
}
}
29 changes: 6 additions & 23 deletions src/auth/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -117,15 +105,6 @@ async function triggerAutoLogin(): Promise<boolean> {
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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -300,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
Expand Down Expand Up @@ -341,7 +324,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` +
Expand Down
Loading