import JSON5 from 'json5'; import { isEmpty } from 'lodash'; import { ClientAuthMethod } from '../../plugins/index.js'; import { OAuthErrorResponse, OAuthTokenResponse } from '../auth/index.js'; import { Property } from '../common/property.js'; export const OAUTH_CALLBACK_PATH = 'oauth/callback'; export const LS_OAUTH_DATASOURCE_KEY = 'oauth-datasource'; export const LS_OAUTH_ENV_KEY = 'oauth-env'; export const LS_OAUTH_ERROR_KEY = 'oauth-error'; export const LS_OAUTH_ONE_TIME_CODE = 'oauth-one-time-code'; export const LS_OAUTH_REDIRECT_FINISHED = 'oauth-redirect-finished'; export const LS_FULL_STATE = 'oauth-full-state'; const LS_OAUTH_CODE_VERIFIER_PREFIX = 'oauth-code-verifier'; export function getOAuthCodeVerifierStorageKey(oneTimeCode: string): string { return `${LS_OAUTH_CODE_VERIFIER_PREFIX}:${oneTimeCode}`; } /** * `usePkce` is typed as a boolean but reaches us from persisted integration * configuration, where a form checkbox can store it as the string `'true'`. * Every read of the flag goes through this predicate so that no two call * sites disagree about whether PKCE is on. */ export function isPkceEnabled(usePkce: unknown): boolean { return usePkce === true || usePkce === 'true' || usePkce === 1 || usePkce === '1'; } export type AuthId = string; // getAuthId returns the key that is used to identify which token should be used // for a specific auth type. // A suffix may be appended for each value that we're caching (eg "-token", // "-refresh", etc) export function getAuthId( authType: AuthType | undefined, authConfig: AuthConfig | undefined, integrationId?: string, integrationConfigurationId?: string ): AuthId { return `${authType}.${getClientId(authType, authConfig, integrationId, integrationConfigurationId)}`; } function getClientId( authType: AuthType | undefined, config: AuthConfig | undefined, integrationId?: string, integrationConfigurationId?: string ): string { const unknownClient = 'unknown-client'; if (!authType || !config) { return unknownClient; } const clientIdForAuthType = (): string | undefined => { switch (authType) { case IntegrationAuthType.BASIC: return integrationId ?? ''; case IntegrationAuthType.FIREBASE: { const firebaseConfig = config as FirebaseAuthConfig; if (!firebaseConfig.apiKey) { return undefined; } try { const parsed = JSON5.parse(firebaseConfig.apiKey); return parsed.projectId; } catch { return undefined; } } case IntegrationAuthType.OAUTH2_PASSWORD: { return (config as OAuthConfig).clientId; } case IntegrationAuthType.OAUTH2_CLIENT_CREDS: case IntegrationAuthType.OAUTH2_CODE: case IntegrationAuthType.OAUTH2_IMPLICIT: case GoogleSheetsAuthType.OAUTH2_CODE: { const oauthConfig = config as OAuthConfig; let apiId; if (normalizeTokenScope(oauthConfig.tokenScope) === TokenScope.DATASOURCE) { apiId = integrationConfigurationId; } else { apiId = config.clientId; } // in case there are muliple scopes, separated by a whitespace - sort them before hashing const scopeHash = 'scope' in oauthConfig && oauthConfig.scope ? insecureHash(oauthConfig.scope?.split(' ').sort().join(' ')) : null; if (!scopeHash || !isEmpty(scopeHash)) { // OAuth scopes are optional according to the OAuth spec. apiId += `-${scopeHash}`; } return apiId; } case IntegrationAuthType.OAUTH2_TOKEN_EXCHANGE: { const tokenExchangeConfig = config as OAuthTokenExchangeConfig; // tokenUrl is part of the key: each endpoint issues tokens scoped to its own environment, so two // configs sharing clientId/scope/audience but exchanging at different endpoints must not share a token (ENG-4431). const fieldsToHash = [ tokenExchangeConfig.scope?.split(' ').sort().join(' '), tokenExchangeConfig.audience, normalizeUrlForAuthId(tokenExchangeConfig.tokenUrl) ]; if (tokenExchangeConfig.subjectTokenSource === 'SUBJECT_TOKEN_SOURCE_STATIC_TOKEN') { fieldsToHash.push(tokenExchangeConfig.subjectTokenSourceStaticToken); } const hashedFields = fieldsToHash.filter(Boolean).map(insecureHash).join('-'); // clientId is optional for token exchange, so use integrationConfigurationId as fallback const identifier = config.clientId || integrationConfigurationId; return `${identifier}-${hashedFields}`; } default: return undefined; } }; return clientIdForAuthType() ?? unknownClient; } // This enum is persisted and used in UI <> agent communication, so take care // when modifying existing values. export enum IntegrationAuthType { NONE = 'None', BASIC = 'basic', OAUTH2_CODE = 'oauth-code', OAUTH2_CLIENT_CREDS = 'oauth-client-cred', OAUTH2_IMPLICIT = 'oauth-implicit', OAUTH2_PASSWORD = 'oauth-p' + 'word', OAUTH2_TOKEN_EXCHANGE = 'oauth-token-exchange', // since proto does not support hyphens OAUTH2_TOKEN_EXCHANGE_PROTO = 'oauthTokenExchange', /** Passes the user's SSO IdP access token through as {{ oauth.token }} — no OAuth client config needed. */ OAUTH2_IDP_TOKEN_PASSTHROUGH = 'oauth-idp-token-passthrough', FIREBASE = 'Firebase', BEARER = 'bearer', API_KEY = 'api-key', TOKEN_PREFIXED = 'token-prefixed', API_KEY_FORM = 'api-key-form' } // IntegrationAuthType are old auth types defined in typescript types // NewAuth Type is defined by proto Auth message export enum NewAuthType { OAUTH2_PASSWORD_GRANT_FLOW = 'passwordGrantFlow', OAUTH2_AUTH_CODE_FLOW = 'authorizationCodeFlow', OAUTH2_CLIENT_CREDS_FLOW = 'clientCredentialsFlow', OAUTH2_TOKEN_EXCHANGE_FLOW = 'tokenExchangeFlow' } export function isRedirectRequired(authType: AuthType | undefined): boolean { switch (authType) { case IntegrationAuthType.OAUTH2_CODE: case IntegrationAuthType.OAUTH2_IMPLICIT: return true; default: return false; } } // This enum is used in agent API execution, and API step headers display, so take care // when modifying existing values. export const IntegrationAuthHeaderPrefixMap = { [IntegrationAuthType.BASIC]: 'Basic ', [IntegrationAuthType.BEARER]: 'Bearer ', [IntegrationAuthType.OAUTH2_CODE]: 'Bearer ', // this is the default prefix for this auth type, but is overridable // using the prefix field in the corresponding authConfig [IntegrationAuthType.TOKEN_PREFIXED]: 'Bearer ' }; // This enum enumerates the different ways the API Key authentication method can // propagate the provided key-value pair. export enum ApiKeyMethod { HEADER = 'header', QUERY_PARAM = 'query-param' } export enum GoogleSheetsAuthType { // this is used in GoogleSheetsPlugin.preCreateValidate OAUTH2_CODE = 'oauth-code', SERVICE_ACCOUNT = 'service-account' } export enum AWSAuthType { ACCESS_KEY = 'access-key', TOKEN_FILE = 'token-file', EC2_INSTANCE_METADATA = 'ec2-instance-metadata' } export enum PostgresAuthType { AWS_IAM_ROLE = 'aws_iam_role', PASSWORD = 'password' } const awsAuthTypeDisplayName = new Map([ [AWSAuthType.ACCESS_KEY, 'Access Key'], [AWSAuthType.TOKEN_FILE, 'Token File'], [AWSAuthType.EC2_INSTANCE_METADATA, 'EC2 Instance Metadata'] ]); export function getAWSAuthTypeDisplayName(authType: AWSAuthType): string { return awsAuthTypeDisplayName.get(authType) ?? ''; } // generally used with authTypeField='connectionType' enum DatabaseAuthType { URL = 'url', FIELDS = 'fields', OKTA = 'okta', KEY_PAIR = 'key-pair' } export type AuthType = IntegrationAuthType | GoogleSheetsAuthType | NewAuthType | DatabaseAuthType; export function getDisplayName(authType: AuthType): string { switch (authType) { case IntegrationAuthType.BASIC: return 'Basic Authentication'; case IntegrationAuthType.FIREBASE: return 'Firebase'; case NewAuthType.OAUTH2_PASSWORD_GRANT_FLOW: case IntegrationAuthType.OAUTH2_PASSWORD: // APIs should be migrating away from this grant type. Add legacy to hint // to users this is probably not the grant type they want. return 'OAuth2 - Password Grant (Legacy)'; case NewAuthType.OAUTH2_CLIENT_CREDS_FLOW: case IntegrationAuthType.OAUTH2_CLIENT_CREDS: return 'OAuth2 - Client Credentials Grant'; case IntegrationAuthType.OAUTH2_IMPLICIT: return 'OAuth2 - Implicit Grant'; case NewAuthType.OAUTH2_AUTH_CODE_FLOW: case IntegrationAuthType.OAUTH2_CODE: return 'OAuth2 - Authorization Code'; case IntegrationAuthType.OAUTH2_TOKEN_EXCHANGE: return 'OAuth2 - On-Behalf-Of Token Exchange'; case IntegrationAuthType.OAUTH2_IDP_TOKEN_PASSTHROUGH: return 'IdP Token Passthrough'; case IntegrationAuthType.BEARER: return 'Bearer Token'; case IntegrationAuthType.TOKEN_PREFIXED: return 'Token'; case IntegrationAuthType.API_KEY: return 'API Key'; case IntegrationAuthType.API_KEY_FORM: return 'API Key'; case DatabaseAuthType.URL: return 'Connection URL'; case DatabaseAuthType.FIELDS: return 'Password-based authentication'; case DatabaseAuthType.OKTA: return 'SSO'; case DatabaseAuthType.KEY_PAIR: return 'Key Pair Authentication'; case IntegrationAuthType.NONE: default: return 'None'; } } type PublicFirebaseAuthConfig = { apiKey?: string; google?: boolean; email?: boolean; }; type FirebaseAuthConfig = PublicFirebaseAuthConfig; type PublicOAuthConfig = PublicOAuthPasswordConfig & PublicOAuthClientCredsConfig & PublicOAuthImplicitConfig & PublicOAuthCodeConfig & PublicOAuthTokenExchangeConfig; type OAuthConfig = OAuthPasswordConfig & OAuthClientCredsConfig & OAuthImplicitConfig & OAuthCodeConfig & OAuthBringYourOwnClientConfig & OAuthTokenExchangeConfig; type PublicOAuthPasswordConfig = { clientId?: string; tokenUrl?: string; useFixedPasswordCreds?: boolean; }; type OAuthPasswordConfig = PublicOAuthPasswordConfig & { clientSecret?: string; // A fixed username and password can be optionally provided. username?: string; password?: string; }; type PublicOAuthClientCredsConfig = { clientId?: string; tokenUrl?: string; scope?: string; }; type OAuthClientCredsConfig = PublicOAuthClientCredsConfig & { clientSecret?: string; audience?: string; }; type PublicOAuthImplicitConfig = { clientId?: string; authorizationUrl?: string; scope?: string; }; type OAuthImplicitConfig = PublicOAuthImplicitConfig & { clientSecret?: string; }; type PublicOAuthTokenExchangeConfig = { tokenUrl?: string; audience?: string; scope?: string; subjectTokenSource?: string; subjectTokenSourceStaticToken?: string; subjectTokenType?: string; clientId?: string; }; export type OAuthTokenExchangeConfig = PublicOAuthTokenExchangeConfig & { clientSecret?: string; }; type PublicOAuthCodeConfig = { clientId?: string; authorizationUrl?: string; authUrl?: string; // @deprecated orchestrator uses this userInfoUrl?: string; tokenUrl?: string; scope?: string; audience?: string; promptType?: string; refreshTokenFromServer?: boolean; //TODO(alex): maybe rename to something like bringYourOwnClient tokenScope?: TokenScope; revokeTokenUrl?: string; authToken?: string; hasToken?: boolean; // @deprecated not used anymore userEmail?: string; // Used by integrations that need user identity (e.g., Lakebase Token Federation) sendOAuthState?: boolean; usePkce?: boolean; }; // Persisted configuration can hold the PKCE marker as a string, so the stored // shape is wider than the public one that `extractPublic` normalizes it into. type OAuthCodeConfig = Omit & { clientSecret?: string; usePkce?: FakeBoolean; }; export enum TokenScope { DATASOURCE = 'datasource', USER = 'user' } type OAuthBringYourOwnClientConfig = { tokenUrl?: string; authorizationUrl?: string; revokeTokenUrl?: string; clientSecret?: string; clientAuthMethod?: ClientAuthMethod; }; export enum TokenType { REFRESH = 'refresh', USER = 'userId', // Access token is persisted as "token" for backwards compatibility. ACCESS = 'token', ID = 'id-token' } export type TokenMetadata = { email?: string; }; type FakeBoolean = boolean | string; type BasicAuthConfig = PublicBasicAuthConfig & { username?: string; password?: string; }; type PublicBasicAuthConfig = { shareBasicAuthCreds?: FakeBoolean; }; type BearerTokenAuthConfig = { bearerToken?: string; }; type TokenPrefixedAuthConfig = { prefix?: string; token?: string; }; type ApiKeyFormAuthConfig = { apiKeys?: Record; }; type ApiKeyAuthConfig = { key?: string; value?: string; method?: ApiKeyMethod; }; // normalizeTokenScope coerces a boolean tokenScope value (which can end up in the // DB when the UI checkbox's mapBooleansTo mapping doesn't fire) to the correct // TokenScope string enum. This prevents the orchestrator's protojson.Unmarshal // from rejecting a boolean for the proto string field token_scope. function normalizeTokenScope(value: unknown): TokenScope | undefined { if (value === true || value === TokenScope.DATASOURCE) return TokenScope.DATASOURCE; if (value === false || value === TokenScope.USER) return TokenScope.USER; if (value === undefined || value === null) return undefined; if (typeof value === 'string' && Object.values(TokenScope).includes(value as TokenScope)) return value as TokenScope; return undefined; } export const extractPublic = (authType: AuthType | undefined, authConfig: AuthConfig | undefined): PublicAuthConfig => { switch (authType) { case IntegrationAuthType.BASIC: return { shareBasicAuthCreds: authConfig?.shareBasicAuthCreds }; case IntegrationAuthType.OAUTH2_CLIENT_CREDS: return { clientId: authConfig?.clientId, tokenUrl: authConfig?.tokenUrl, scope: authConfig?.scope }; case IntegrationAuthType.OAUTH2_CODE: return { clientId: authConfig?.clientId, authorizationUrl: authConfig?.authorizationUrl, tokenUrl: authConfig?.tokenUrl, scope: authConfig?.scope, audience: authConfig?.audience, refreshTokenFromServer: authConfig?.refreshTokenFromServer, hasToken: authConfig?.hasToken, tokenScope: normalizeTokenScope(authConfig?.tokenScope), promptType: authConfig?.promptType, sendOAuthState: authConfig?.sendOAuthState, usePkce: isPkceEnabled(authConfig?.usePkce) }; case IntegrationAuthType.OAUTH2_IMPLICIT: return { clientId: authConfig?.clientId, authorizationUrl: authConfig?.authorizationUrl, scope: authConfig?.scope, audience: authConfig?.audience }; case IntegrationAuthType.OAUTH2_PASSWORD: return { clientId: authConfig?.clientId, tokenUrl: authConfig?.tokenUrl, useFixedPasswordCreds: authConfig?.useFixedPasswordCreds, audience: authConfig?.audience }; case IntegrationAuthType.OAUTH2_TOKEN_EXCHANGE: return { clientId: authConfig?.clientId, tokenUrl: authConfig?.tokenUrl, audience: authConfig?.audience, scope: authConfig?.scope, subjectTokenSource: authConfig?.subjectTokenSource, subjectTokenSourceStaticToken: authConfig?.subjectTokenSourceStaticToken }; // The Firebase web app config is public by design — FirebaseLoginModal parses it // in the browser to call initializeApp, and access control comes from Security // Rules and App Check — so it is projected rather than dropped. case IntegrationAuthType.FIREBASE: return { apiKey: authConfig?.apiKey, email: authConfig?.email, google: authConfig?.google }; case IntegrationAuthType.NONE: return {}; case GoogleSheetsAuthType.OAUTH2_CODE: case GoogleSheetsAuthType.SERVICE_ACCOUNT: case IntegrationAuthType.BEARER: case IntegrationAuthType.TOKEN_PREFIXED: case IntegrationAuthType.API_KEY: case IntegrationAuthType.API_KEY_FORM: case IntegrationAuthType.OAUTH2_IDP_TOKEN_PASSTHROUGH: case DatabaseAuthType.FIELDS: case DatabaseAuthType.OKTA: case DatabaseAuthType.KEY_PAIR: case DatabaseAuthType.URL: return {}; default: throw new Error(`unknown auth type: ${authType}`); } }; type ServiceAccountConfig = { googleServiceAccount?: Property; }; type PublicRestAuthConfig = PublicBasicAuthConfig & PublicOAuthConfig & PublicFirebaseAuthConfig; type PublicGoogleSheetsAuthConfig = PublicOAuthCodeConfig; type PublicAuthConfig = PublicRestAuthConfig & PublicGoogleSheetsAuthConfig; // TODO(aayush): Rename RestAuthConfig to something more generic as it is also // used for GraphQL. type RestAuthConfig = BasicAuthConfig & OAuthConfig & FirebaseAuthConfig & BearerTokenAuthConfig & TokenPrefixedAuthConfig & ApiKeyFormAuthConfig & ApiKeyAuthConfig; type GoogleSheetsAuthConfig = OAuthCodeConfig & ServiceAccountConfig; export type AuthConfig = RestAuthConfig & GoogleSheetsAuthConfig; // TODO: this lives here for now to avoid a cyclic dependency. The utils // directory transitively depends on this file. // An insecure hash function used to condense a string. const insecureHash = (s: string | undefined): string => { // We need the redundant `!s` check to satisfy the type checker that s is not // undefined. if (!s || isEmpty(s)) { return ''; } let hash = 0; for (let i = 0; i < s.length; i++) { const char = s.charCodeAt(i); hash = (hash << 5) - hash + char; // Convert to 32 bits. hash = hash & hash; } return hash.toString(); }; // Canonicalizes a token endpoint URL for the cache key so DNS-equivalent variants // (trailing slash, host/scheme case, default port) map to one key. Path and query are // preserved, so genuinely distinct endpoints never collapse; unparseable input falls // back to the raw value. const normalizeUrlForAuthId = (url: string | undefined): string | undefined => { if (!url) { return url; } try { return new URL(url).href; } catch { return url; } }; export type ExchangeCodeRequest = { authId: AuthId; authType: AuthType; authConfig: AuthConfig; accessCode: string; pluginId: string; origin: string; grantedScope: string | null; integrationId: string | undefined; // used for embedded user only configurationId: string | undefined; // used for embedded user only }; export type ExchangeCodeResponse = { successful: boolean; error?: string; }; export function isExchangeCodeResponse( obj: | ExchangeCodeResponse | { success: boolean; result?: void; error?: string | undefined; } ): obj is ExchangeCodeResponse { return 'successful' in obj; } export type RequestTokenRequest = { datasourceId: string; username: string; password: string; environment?: string; }; export type RequestTokenResponse = OAuthTokenResponse & OAuthErrorResponse & { expirationTimestamp?: number; }; export type DatasourceAuthState = DatasourceOneTimeState & { integrationId: string; configurationId: string; pluginId: string; authType: AuthType; authId: string; authConfig: { refreshTokenFromServer?: boolean; clientId: string; clientSecret: string; tokenUrl: string; authorizationUrl: string; userInfoUrl?: string; tokenScope: TokenScope; scope: string | undefined; clientAuthMethod?: ClientAuthMethod; usePkce?: boolean; }; origin: string; }; export function isDatasourceAuthState(aus: DatasourceAuthState | DatasourceOneTimeState): aus is DatasourceAuthState { return ( 'integrationId' in aus && 'configurationId' in aus && 'authType' in aus && 'authId' in aus && 'authConfig' in aus && 'origin' in aus ); } export type DatasourceOneTimeState = { oneTimeCode: string; /** * Explicitly selects the ui-legacy localStorage callback handoff. * ui app and deployed-shell flows must leave this unset so the * dashboard callback uses postMessage only. */ useLocalStorage?: boolean; integrationId: string; externalUser: boolean; // Optional disambiguator written by the deployed-embed shell so the // popup can pick the exact integration configuration without a Redux // profile lookup (the popup runs in the customer's logged-out browser // context and has no `organization.profiles` loaded). configurationId?: string; /** Profile key for agent-scoped auth when not supplied via postMessage. */ profileKey?: string; /** * Origin of the window that opened the OAuth popup. When the opener * is cross-origin (e.g. a deployed app iframe on a CDN), the callback * page must postMessage with this targetOrigin instead of its own. */ openerOrigin?: string; }; export type DeleteDatasourceOnAgentResult = { message?: string; success: boolean; };