import { describe, test, expect } from 'vitest'; import { IntegrationAuthType, TokenScope, extractPublic, getAuthId, getDisplayName, getOAuthCodeVerifierStorageKey, isPkceEnabled } from './auth.js'; describe('OAuth code verifier storage', () => { test('scopes each verifier to its one-time code', () => { expect(getOAuthCodeVerifierStorageKey('first-attempt')).toBe('oauth-code-verifier:first-attempt'); expect(getOAuthCodeVerifierStorageKey('second-attempt')).toBe('oauth-code-verifier:second-attempt'); }); }); describe('isPkceEnabled', () => { test('treats a stringified marker the same as a boolean one', () => { expect(isPkceEnabled(true)).toBe(true); expect(isPkceEnabled('true')).toBe(true); expect(isPkceEnabled(1)).toBe(true); expect(isPkceEnabled('1')).toBe(true); }); test('treats every shape of an unset marker as off', () => { expect(isPkceEnabled(undefined)).toBe(false); expect(isPkceEnabled(null)).toBe(false); expect(isPkceEnabled(false)).toBe(false); expect(isPkceEnabled('false')).toBe(false); expect(isPkceEnabled('')).toBe(false); expect(isPkceEnabled(0)).toBe(false); }); }); describe('persisted OAuth auth types', () => { test('keeps the password grant wire value stable', () => { // Pinned to the literal on purpose: the enum builds this value by // concatenation, and re-deriving it here would pass right through a typo // made on both sides. expect(IntegrationAuthType.OAUTH2_PASSWORD).toBe('oauth-pword'); }); }); describe('getDisplayName', () => { test('returns "IdP Token Passthrough" for OAUTH2_IDP_TOKEN_PASSTHROUGH', () => { expect(getDisplayName(IntegrationAuthType.OAUTH2_IDP_TOKEN_PASSTHROUGH)).toBe('IdP Token Passthrough'); }); test('returns "OAuth2 - On-Behalf-Of Token Exchange" for OAUTH2_TOKEN_EXCHANGE', () => { expect(getDisplayName(IntegrationAuthType.OAUTH2_TOKEN_EXCHANGE)).toBe('OAuth2 - On-Behalf-Of Token Exchange'); }); test('returns "None" for NONE', () => { expect(getDisplayName(IntegrationAuthType.NONE)).toBe('None'); }); }); describe('extractPublic', () => { test('hands out the PKCE marker as a real boolean', () => { // Persisted configuration can hold the marker as a string, but the field is // typed boolean on the way out, so a consumer writing `if (config.usePkce)` // would read the string 'false' as PKCE being on. expect(extractPublic(IntegrationAuthType.OAUTH2_CODE, { usePkce: 'true' })).toMatchObject({ usePkce: true }); expect(extractPublic(IntegrationAuthType.OAUTH2_CODE, { usePkce: 'false' })).toMatchObject({ usePkce: false }); }); test('returns empty object for OAUTH2_IDP_TOKEN_PASSTHROUGH (no secrets to expose)', () => { const authConfig = { clientId: 'test' }; expect(extractPublic(IntegrationAuthType.OAUTH2_IDP_TOKEN_PASSTHROUGH, authConfig)).toEqual({}); }); test('returns token exchange fields for OAUTH2_TOKEN_EXCHANGE', () => { const authConfig = { clientId: 'my-client', clientSecret: 'my-secret', tokenUrl: 'https://token.example.com', audience: 'https://api.example.com', scope: 'read write', subjectTokenSource: 'SUBJECT_TOKEN_SOURCE_LOGIN_IDENTITY_PROVIDER', subjectTokenSourceStaticToken: 'static' }; const result = extractPublic(IntegrationAuthType.OAUTH2_TOKEN_EXCHANGE, authConfig); expect(result).toEqual({ clientId: 'my-client', tokenUrl: 'https://token.example.com', audience: 'https://api.example.com', scope: 'read write', subjectTokenSource: 'SUBJECT_TOKEN_SOURCE_LOGIN_IDENTITY_PROVIDER', subjectTokenSourceStaticToken: 'static' }); // clientSecret must not leak expect(result).not.toHaveProperty('clientSecret'); }); test('throws for unknown auth type', () => { expect(() => extractPublic('totally-unknown-type' as never, {})).toThrow('unknown auth type'); }); }); describe('extractPublic redacts credentials for NONE and FIREBASE (ENG-5206)', () => { // An authType of NONE or FIREBASE used to return authConfig verbatim, so any // credential left in the blob — by the 2022 flattening migration, or by a write // that set authType directly over the API — reached every reader of an // integration, including embed sessions and deployed-app viewers. const credentials = { bearerToken: 'bearer-token', clientSecret: 'client-secret', password: 'hunter2', value: 'api-key-value' }; test('returns nothing for NONE even when the config still holds credentials', () => { expect(extractPublic(IntegrationAuthType.NONE, credentials)).toEqual({}); }); test('returns nothing for NONE when the config is absent', () => { expect(extractPublic(IntegrationAuthType.NONE, undefined)).toEqual({}); }); test('returns only the public web config for FIREBASE, dropping credentials and migration residue', () => { const result = extractPublic(IntegrationAuthType.FIREBASE, { ...credentials, apiKey: '{"apiKey":"AIzaSyPublicWebKey","authDomain":"demo.firebaseapp.com","projectId":"demo"}', email: true, google: true, shareBasicAuthCreds: true, useFixedPasswordCreds: true }); // The Firebase web config is public by design — FirebaseLoginModal parses it in // the browser to call initializeApp — so these three stay, and nothing else does. expect(result).toEqual({ apiKey: '{"apiKey":"AIzaSyPublicWebKey","authDomain":"demo.firebaseapp.com","projectId":"demo"}', email: true, google: true }); }); test('returns nothing for FIREBASE when the config is absent', () => { expect(extractPublic(IntegrationAuthType.FIREBASE, undefined)).toEqual({}); }); }); describe('extractPublic normalizes boolean tokenScope for OAUTH2_CODE', () => { test('preserves the PKCE marker', () => { const result = extractPublic(IntegrationAuthType.OAUTH2_CODE, { clientId: 'public-client', usePkce: true }); expect(result.usePkce).toBe(true); }); test('normalizes boolean true tokenScope to TokenScope.DATASOURCE', () => { const authConfig = { clientId: 'my-client', authorizationUrl: 'https://auth.example.com', tokenUrl: 'https://token.example.com', scope: 'read', tokenScope: true as unknown as TokenScope }; const result = extractPublic(IntegrationAuthType.OAUTH2_CODE, authConfig); expect(result.tokenScope).toBe(TokenScope.DATASOURCE); }); test('normalizes boolean false tokenScope to TokenScope.USER', () => { const authConfig = { clientId: 'my-client', authorizationUrl: 'https://auth.example.com', tokenUrl: 'https://token.example.com', scope: 'read', tokenScope: false as unknown as TokenScope }; const result = extractPublic(IntegrationAuthType.OAUTH2_CODE, authConfig); expect(result.tokenScope).toBe(TokenScope.USER); }); test('passes through valid string TokenScope unchanged', () => { const authConfig = { clientId: 'my-client', authorizationUrl: 'https://auth.example.com', tokenUrl: 'https://token.example.com', scope: 'read', tokenScope: TokenScope.DATASOURCE }; const result = extractPublic(IntegrationAuthType.OAUTH2_CODE, authConfig); expect(result.tokenScope).toBe(TokenScope.DATASOURCE); }); test('returns undefined for unknown string tokenScope values', () => { const authConfig = { clientId: 'my-client', authorizationUrl: 'https://auth.example.com', tokenUrl: 'https://token.example.com', scope: 'read', tokenScope: 'INVALID' as unknown as TokenScope }; const result = extractPublic(IntegrationAuthType.OAUTH2_CODE, authConfig); expect(result.tokenScope).toBeUndefined(); }); }); describe('getAuthId normalizes boolean tokenScope in getClientId', () => { test('uses integrationConfigurationId when tokenScope is boolean true (datasource)', () => { const authConfig = { clientId: 'my-client', tokenScope: true as unknown as TokenScope, scope: 'read' }; const result = getAuthId(IntegrationAuthType.OAUTH2_CODE, authConfig, 'int-1', 'cfg-1'); // When tokenScope is datasource, getClientId uses integrationConfigurationId (cfg-1) expect(result).toMatch(/^oauth-code\.cfg-1/); }); test('uses clientId when tokenScope is boolean false (user)', () => { const authConfig = { clientId: 'my-client', tokenScope: false as unknown as TokenScope, scope: 'read' }; const result = getAuthId(IntegrationAuthType.OAUTH2_CODE, authConfig, 'int-1', 'cfg-1'); // When tokenScope is user, getClientId uses clientId (my-client) expect(result).toMatch(/^oauth-code\.my-client/); }); }); describe('getAuthId', () => { test('uses unknown-client for OAUTH2_IDP_TOKEN_PASSTHROUGH (no client identity)', () => { const authId = getAuthId(IntegrationAuthType.OAUTH2_IDP_TOKEN_PASSTHROUGH, {}, 'int-1', 'cfg-1'); expect(authId).toBe('oauth-idp-token-passthrough.unknown-client'); }); test('uses clientId for OAUTH2_TOKEN_EXCHANGE', () => { const authId = getAuthId(IntegrationAuthType.OAUTH2_TOKEN_EXCHANGE, { clientId: 'my-client' }, 'int-1', 'cfg-1'); // Token exchange builds: `clientId-hashedFields` — clientId prefix present expect(authId).toMatch(/^oauth-token-exchange\.my-client/); }); }); describe('getAuthId scopes OAUTH2_TOKEN_EXCHANGE by token endpoint (ENG-4431)', () => { // Two configurations of one integration that share clientId, scope, and audience // but exchange at different token endpoints (environments) must NOT share a cached // OBO token: each endpoint issues tokens scoped to its own environment, so one config's // token is rejected by the other's downstream API (401). The token endpoint is the // environment discriminator, so it must be part of the auth id. const base = { clientId: 'shared-client', scope: 'read write', audience: 'https://api.example.com' }; test('distinct tokenUrl yields distinct authId', () => { const ace = getAuthId( IntegrationAuthType.OAUTH2_TOKEN_EXCHANGE, { ...base, tokenUrl: 'https://ace.example.com/oauth/token' }, 'int-1', 'cfg-ace' ); const integrations = getAuthId( IntegrationAuthType.OAUTH2_TOKEN_EXCHANGE, { ...base, tokenUrl: 'https://integrations.example.com/oauth/token' }, 'int-1', 'cfg-integrations' ); expect(ace).not.toBe(integrations); }); test('identical config yields a stable authId', () => { const config = { ...base, tokenUrl: 'https://ace.example.com/oauth/token' }; expect(getAuthId(IntegrationAuthType.OAUTH2_TOKEN_EXCHANGE, config, 'int-1', 'cfg-ace')).toBe( getAuthId(IntegrationAuthType.OAUTH2_TOKEN_EXCHANGE, config, 'int-1', 'cfg-ace') ); }); }); describe('getAuthId normalizes the OBO token endpoint before hashing (ENG-4431)', () => { const base = { clientId: 'shared-client', scope: 'read write', audience: 'https://api.example.com' }; const authId = (tokenUrl: string): string => getAuthId(IntegrationAuthType.OAUTH2_TOKEN_EXCHANGE, { ...base, tokenUrl }, 'int-1', 'cfg'); test('treats DNS-equivalent endpoints as the same (trailing slash, host/scheme case)', () => { // Cosmetic variants of one endpoint should share a cached token, not force a redundant exchange. expect(authId('https://token.example.com')).toBe(authId('https://token.example.com/')); expect(authId('https://Token.Example.com/oauth/token')).toBe(authId('https://token.example.com/oauth/token')); }); test('keeps genuinely-distinct endpoints distinct (path is preserved, never collapsed)', () => { expect(authId('https://token.example.com/a')).not.toBe(authId('https://token.example.com/b')); }); test('falls back to the raw value for an unparseable tokenUrl without throwing', () => { expect(() => authId('not a url')).not.toThrow(); expect(authId('not a url')).toBe(authId('not a url')); }); });