From 14b0376674a021e827ecf0e14d6c1eafbea871c3 Mon Sep 17 00:00:00 2001 From: kalenkevich Date: Tue, 31 Mar 2026 13:41:43 -0700 Subject: [PATCH 1/7] feat: oauth support: add oauth2 related classes --- .../oauth2/oauth2_credential_exchanger.ts | 207 ++++++++++ .../oauth2/oauth2_credential_refresher.ts | 106 +++++ core/src/auth/oauth2/oauth2_discovery.ts | 163 ++++++++ core/src/auth/oauth2/oauth2_utils.ts | 155 +++++++ core/src/common.ts | 3 + .../oauth2_credential_exchanger_test.ts | 387 ++++++++++++++++++ .../test/auth/oauth2/oauth2_discovery_test.ts | 257 ++++++++++++ core/test/auth/oauth2/oauth2_utils_test.ts | 227 ++++++++++ 8 files changed, 1505 insertions(+) create mode 100644 core/src/auth/oauth2/oauth2_credential_exchanger.ts create mode 100644 core/src/auth/oauth2/oauth2_credential_refresher.ts create mode 100644 core/src/auth/oauth2/oauth2_discovery.ts create mode 100644 core/src/auth/oauth2/oauth2_utils.ts create mode 100644 core/test/auth/oauth2/oauth2_credential_exchanger_test.ts create mode 100644 core/test/auth/oauth2/oauth2_discovery_test.ts create mode 100644 core/test/auth/oauth2/oauth2_utils_test.ts diff --git a/core/src/auth/oauth2/oauth2_credential_exchanger.ts b/core/src/auth/oauth2/oauth2_credential_exchanger.ts new file mode 100644 index 00000000..92c7ce52 --- /dev/null +++ b/core/src/auth/oauth2/oauth2_credential_exchanger.ts @@ -0,0 +1,207 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {logger} from '../../utils/logger.js'; +import {AuthCredential} from '../auth_credential.js'; +import { + AuthScheme, + getOAuthGrantTypeFromFlow, + OAuthGrantType, + OpenIdConnectWithConfig, +} from '../auth_schemes.js'; +import { + BaseCredentialExchanger, + CredentialExchangeError, + ExchangeResult, +} from '../exchanger/base_credential_exchanger.js'; +import { + createOAuth2TokenRequestBody, + fetchOAuth2Tokens, + getTokenEndpoint, + parseAuthorizationCode, +} from './oauth2_utils.js'; + +/** + * Exchanges OAuth2 credentials from authorization responses using standard fetch. + */ +export class OAuth2CredentialExchanger implements BaseCredentialExchanger { + /** + * Exchange OAuth2 credential if needed. + * + * @param authCredential - The OAuth2 credential to exchange. + * @param authScheme - The OAuth2 authentication scheme. + * @returns The exchanged credential. + * @throws CredentialExchangeError: If authScheme is missing. + */ + async exchange({ + authCredential, + authScheme, + }: { + authCredential: AuthCredential; + authScheme?: AuthScheme; + }): Promise { + if (!authScheme) { + throw new CredentialExchangeError( + 'authScheme is required for OAuth2 credential exchange', + ); + } + + if (authCredential.oauth2?.accessToken) { + return { + credential: authCredential, + wasExchanged: false, + }; + } + + const grantType = determineGrantType(authScheme); + + if (grantType === OAuthGrantType.CLIENT_CREDENTIALS) { + return exchangeClientCredentials({authCredential, authScheme}); + } + + if (grantType === OAuthGrantType.AUTHORIZATION_CODE) { + return exchangeAuthorizationCode({authCredential, authScheme}); + } + + logger.warn(`Unsupported OAuth2 grant type: ${grantType}`); + return { + credential: authCredential, + wasExchanged: false, + }; + } +} + +export function determineGrantType( + authScheme: AuthScheme, +): OAuthGrantType | undefined { + if ('flows' in authScheme && authScheme.flows) { + return getOAuthGrantTypeFromFlow(authScheme.flows); + } + + if ((authScheme as OpenIdConnectWithConfig).grantTypesSupported) { + const oidcScheme = authScheme as OpenIdConnectWithConfig; + + if (oidcScheme.grantTypesSupported?.includes('client_credentials')) { + return OAuthGrantType.CLIENT_CREDENTIALS; + } + + return OAuthGrantType.AUTHORIZATION_CODE; + } + return undefined; +} + +export async function exchangeClientCredentials({ + authCredential, + authScheme, +}: { + authCredential: AuthCredential; + authScheme: AuthScheme; +}): Promise { + const tokenEndpoint = getTokenEndpoint(authScheme); + if (!tokenEndpoint) { + throw new CredentialExchangeError( + 'Token endpoint not found in auth scheme.', + ); + } + + if ( + !authCredential.oauth2?.clientId || + !authCredential.oauth2?.clientSecret + ) { + throw new CredentialExchangeError( + 'clientId and clientSecret are required for client credentials exchange.', + ); + } + + const body = createOAuth2TokenRequestBody({ + grantType: 'client_credentials', + clientId: authCredential.oauth2.clientId, + clientSecret: authCredential.oauth2.clientSecret, + }); + + try { + const oauth2Auth = await fetchOAuth2Tokens(tokenEndpoint, body); + + return { + credential: { + ...authCredential, + oauth2: { + ...authCredential.oauth2, + ...oauth2Auth, + }, + }, + wasExchanged: true, + }; + } catch (error) { + throw new CredentialExchangeError( + `Failed to exchange tokens: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +export async function exchangeAuthorizationCode({ + authCredential, + authScheme, +}: { + authCredential: AuthCredential; + authScheme: AuthScheme; +}): Promise { + const tokenEndpoint = getTokenEndpoint(authScheme); + if (!tokenEndpoint) { + throw new CredentialExchangeError( + 'Token endpoint not found in auth scheme.', + ); + } + + if ( + !authCredential.oauth2?.clientId || + !authCredential.oauth2?.clientSecret || + (!authCredential.oauth2?.authCode && + !authCredential.oauth2?.authResponseUri) + ) { + throw new CredentialExchangeError( + 'clientId, clientSecret, and either authCode or authResponseUri are required for authorization code exchange.', + ); + } + + let code = authCredential.oauth2.authCode; + if (!code && authCredential.oauth2.authResponseUri) { + code = parseAuthorizationCode(authCredential.oauth2.authResponseUri); + } + + if (!code) { + throw new CredentialExchangeError( + 'Authorization code not found in auth response.', + ); + } + + const body = createOAuth2TokenRequestBody({ + grantType: 'authorization_code', + clientId: authCredential.oauth2.clientId, + clientSecret: authCredential.oauth2.clientSecret, + code, + redirectUri: authCredential.oauth2.redirectUri, + }); + + try { + const oauth2Auth = await fetchOAuth2Tokens(tokenEndpoint, body); + + return { + credential: { + ...authCredential, + oauth2: { + ...authCredential.oauth2, + ...oauth2Auth, + }, + }, + wasExchanged: true, + }; + } catch (error: unknown) { + throw new CredentialExchangeError( + `Failed to exchange tokens: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} diff --git a/core/src/auth/oauth2/oauth2_credential_refresher.ts b/core/src/auth/oauth2/oauth2_credential_refresher.ts new file mode 100644 index 00000000..414d7e27 --- /dev/null +++ b/core/src/auth/oauth2/oauth2_credential_refresher.ts @@ -0,0 +1,106 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {logger} from '../../utils/logger.js'; +import {AuthCredential} from '../auth_credential.js'; +import {AuthScheme} from '../auth_schemes.js'; +import {BaseCredentialRefresher} from '../refresher/base_credential_refresher.js'; +import { + fetchOAuth2Tokens, + getTokenEndpoint, + isTokenExpired, +} from './oauth2_utils.js'; + +/** + * Refreshes OAuth2 credentials using standard fetch. + */ +export class OAuth2CredentialRefresher implements BaseCredentialRefresher { + /** + * Check if the OAuth2 credential needs to be refreshed. + * + * @param authCredential The OAuth2 credential to check. + * @param authScheme The OAuth2 authentication scheme (optional). + * @returns True if the credential needs to be refreshed, False otherwise. + */ + async isRefreshNeeded(authCredential: AuthCredential): Promise { + if (!authCredential.oauth2) { + return false; + } + + if (authCredential.oauth2 && authCredential.oauth2.expiresAt) { + return isTokenExpired(authCredential.oauth2); + } + + return false; + } + + /** + * Refresh the OAuth2 credential. + * + * @param authCredential The OAuth2 credential to refresh. + * @param authScheme The OAuth2 authentication scheme. + * @returns The refreshed credential. + */ + async refresh( + authCredential: AuthCredential, + authScheme?: AuthScheme, + ): Promise { + if (!authCredential.oauth2 || !authScheme) { + return authCredential; + } + + if (!authCredential.oauth2.refreshToken) { + logger.warn('No refresh token available to refresh credential'); + return authCredential; + } + + const isNeeded = await this.isRefreshNeeded(authCredential); + if (!isNeeded) { + return authCredential; + } + + const tokenEndpoint = getTokenEndpoint(authScheme); + if (!tokenEndpoint) { + logger.warn('Token endpoint not found in auth scheme.'); + return authCredential; + } + + if ( + !authCredential.oauth2.clientId || + !authCredential.oauth2.clientSecret + ) { + logger.warn('clientId and clientSecret are required for token refresh.'); + return authCredential; + } + + const body = new URLSearchParams(); + body.set('grant_type', 'refresh_token'); + body.set('refresh_token', authCredential.oauth2.refreshToken); + body.set('client_id', authCredential.oauth2.clientId); + body.set('client_secret', authCredential.oauth2.clientSecret); + + try { + const data = await fetchOAuth2Tokens(tokenEndpoint, body); + + const updatedOAuth2 = { + ...authCredential.oauth2, + accessToken: data.accessToken || authCredential.oauth2.accessToken, + refreshToken: data.refreshToken || authCredential.oauth2.refreshToken, + expiresIn: data.expiresIn, + expiresAt: data.expiresAt || authCredential.oauth2.expiresAt, + }; + + return { + ...authCredential, + oauth2: updatedOAuth2, + }; + } catch (error) { + logger.error('Failed to refresh tokens:', error); + // Return original credential on failure, as per Python implementation + return authCredential; + } + } +} diff --git a/core/src/auth/oauth2/oauth2_discovery.ts b/core/src/auth/oauth2/oauth2_discovery.ts new file mode 100644 index 00000000..869b35d8 --- /dev/null +++ b/core/src/auth/oauth2/oauth2_discovery.ts @@ -0,0 +1,163 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {z} from 'zod'; +import {logger} from '../../utils/logger.js'; + +/** + * Represents the OAuth2 authorization server metadata per RFC8414. + */ +export const AuthorizationServerMetadataSchema = z.object({ + issuer: z.string(), + authorization_endpoint: z.string(), + token_endpoint: z.string(), + scopes_supported: z.array(z.string()).optional(), + registration_endpoint: z.string().optional(), +}); + +export type AuthorizationServerMetadata = z.infer< + typeof AuthorizationServerMetadataSchema +>; + +/** + * Represents the OAuth2 protected resource metadata per RFC9728. + */ +export const ProtectedResourceMetadataSchema = z.object({ + resource: z.string(), + authorization_servers: z.array(z.string()).default([]), +}); + +export type ProtectedResourceMetadata = z.infer< + typeof ProtectedResourceMetadataSchema +>; + +/** + * Implements Metadata discovery for OAuth2 following RFC8414 and RFC9728. + */ +export class OAuth2DiscoveryManager { + /** + * Discovers the OAuth2 authorization server metadata. + */ + async discoverAuthServerMetadata( + issuerUrl: string, + ): Promise { + let baseUrl: string; + let path: string; + + try { + const url = new URL(issuerUrl); + baseUrl = `${url.protocol}//${url.host}`; + path = url.pathname; + } catch (e) { + logger.warn(`Failed to parse issuerUrl ${issuerUrl}: ${e}`); + return undefined; + } + + const endpointsToTry: string[] = []; + + if (path && path !== '/') { + endpointsToTry.push( + `${baseUrl}/.well-known/oauth-authorization-server${path}`, + `${baseUrl}/.well-known/openid-configuration${path}`, + `${baseUrl}${path}/.well-known/openid-configuration`, + ); + } else { + endpointsToTry.push( + `${baseUrl}/.well-known/oauth-authorization-server`, + `${baseUrl}/.well-known/openid-configuration`, + ); + } + + for (const endpoint of endpointsToTry) { + try { + const response = await fetch(endpoint, { + method: 'GET', + headers: { + Accept: 'application/json', + }, + }); + + if (!response.ok) { + continue; + } + + const data = await response.json(); + const metadata = AuthorizationServerMetadataSchema.parse(data); + + // Validate issuer to defend against MIX-UP attacks + if ( + metadata.issuer.replace(/\/$/, '') === issuerUrl.replace(/\/$/, '') + ) { + return metadata; + } else { + logger.warn( + `Issuer in metadata ${metadata.issuer} does not match issuerUrl ${issuerUrl}`, + ); + } + } catch (e) { + logger.debug(`Failed to fetch metadata from ${endpoint}: ${e}`); + } + } + + return undefined; + } + + /** + * Discovers the OAuth2 protected resource metadata. + */ + async discoverResourceMetadata( + resourceUrl: string, + ): Promise { + let baseUrl: string; + let path: string; + + try { + const url = new URL(resourceUrl); + baseUrl = `${url.protocol}//${url.host}`; + path = url.pathname; + } catch (e) { + logger.warn(`Failed to parse resourceUrl ${resourceUrl}: ${e}`); + return undefined; + } + + let wellKnownEndpoint: string; + if (path && path !== '/') { + wellKnownEndpoint = `${baseUrl}/.well-known/oauth-protected-resource${path}`; + } else { + wellKnownEndpoint = `${baseUrl}/.well-known/oauth-protected-resource`; + } + + try { + const response = await fetch(wellKnownEndpoint, { + method: 'GET', + headers: { + Accept: 'application/json', + }, + }); + + if (!response.ok) { + return undefined; + } + + const data = await response.json(); + const metadata = ProtectedResourceMetadataSchema.parse(data); + + if ( + metadata.resource.replace(/\/$/, '') === resourceUrl.replace(/\/$/, '') + ) { + return metadata; + } else { + logger.warn( + `Resource in metadata ${metadata.resource} does not match resourceUrl ${resourceUrl}`, + ); + } + } catch (e) { + logger.debug(`Failed to fetch metadata from ${wellKnownEndpoint}: ${e}`); + } + + return undefined; + } +} diff --git a/core/src/auth/oauth2/oauth2_utils.ts b/core/src/auth/oauth2/oauth2_utils.ts new file mode 100644 index 00000000..6e3f5753 --- /dev/null +++ b/core/src/auth/oauth2/oauth2_utils.ts @@ -0,0 +1,155 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {logger} from '../../utils/logger.js'; +import {OAuth2Auth} from '../auth_credential.js'; + +import {AuthScheme, OpenIdConnectWithConfig} from '../auth_schemes.js'; + +/** + * Returns the token endpoint for the given auth scheme. + */ +export function getTokenEndpoint(authScheme: AuthScheme): string | undefined { + if ('tokenEndpoint' in authScheme) { + return (authScheme as OpenIdConnectWithConfig).tokenEndpoint; + } + + if ('flows' in authScheme && authScheme.flows) { + const flows = authScheme.flows; + const flow = + flows.authorizationCode || + flows.clientCredentials || + flows.password || + flows.implicit; + + if (flow && 'tokenUrl' in flow) { + return flow.tokenUrl; + } + } + + return undefined; +} + +interface OAuth2TokenResponse { + access_token?: string; + refresh_token?: string; + expires_in?: number; +} + +/** + * Fetches OAuth2 tokens from the endpoint using the given body. + */ +export async function fetchOAuth2Tokens( + endpoint: string, + body: URLSearchParams, +): Promise { + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: body.toString(), + }); + + if (!response.ok) { + throw new Error(`Token request failed with status ${response.status}`); + } + + const data = (await response.json()) as OAuth2TokenResponse; + + return { + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresIn: data.expires_in, + expiresAt: data.expires_in + ? Date.now() + data.expires_in * 1000 + : undefined, + }; +} + +/** + * Parses the authorization code from an authorization response URI. + */ +export function parseAuthorizationCode(uri: string): string | undefined { + try { + const url = new URL(uri); + return url.searchParams.get('code') || undefined; + } catch (e) { + logger.warn(`Failed to parse authorization URI ${uri}: ${e}`); + return undefined; + } +} + +/** + * Parameters for a Client Credentials token request. + */ +export interface ClientCredentialsParams { + grantType: 'client_credentials'; + clientId: string; + clientSecret: string; +} + +/** + * Parameters for an Authorization Code token request. + */ +export interface AuthorizationCodeParams { + grantType: 'authorization_code'; + clientId: string; + clientSecret: string; + code: string; + redirectUri?: string; +} + +/** + * Parameters for a Refresh Token request. + */ +export interface RefreshTokenParams { + grantType: 'refresh_token'; + clientId: string; + clientSecret: string; + refreshToken: string; +} + +/** + * Parameters for creating an OAuth2 token request body. + */ +export type OAuth2TokenRequestParams = + | ClientCredentialsParams + | AuthorizationCodeParams + | RefreshTokenParams; + +/** + * Creates URLSearchParams for an OAuth2 token request. + */ +export function createOAuth2TokenRequestBody( + params: OAuth2TokenRequestParams, +): URLSearchParams { + const body = new URLSearchParams(); + body.set('grant_type', params.grantType); + body.set('client_id', params.clientId); + body.set('client_secret', params.clientSecret); + + if (params.grantType === 'authorization_code') { + body.set('code', params.code); + if (params.redirectUri) { + body.set('redirect_uri', params.redirectUri); + } + } else if (params.grantType === 'refresh_token') { + body.set('refresh_token', params.refreshToken); + } + + return body; +} + +export function isTokenExpired(token: OAuth2Auth, leeway = 60): boolean { + if (typeof token.expiresAt !== 'number') { + return false; + } + + const expirationThreshold = token.expiresAt - leeway * 1000; + + return expirationThreshold < Date.now(); +} diff --git a/core/src/common.ts b/core/src/common.ts index b92a6322..f6cf1394 100644 --- a/core/src/common.ts +++ b/core/src/common.ts @@ -78,6 +78,9 @@ export type {BaseAuthProvider} from './auth/base_auth_provider.js'; export type {BaseCredentialService} from './auth/credential_service/base_credential_service.js'; export {InMemoryCredentialService} from './auth/credential_service/in_memory_credential_service.js'; export {SessionStateCredentialService} from './auth/credential_service/session_state_credential_service.js'; +export {CredentialExchangeError} from './auth/exchanger/base_credential_exchanger.js'; +export type {BaseCredentialExchanger} from './auth/exchanger/base_credential_exchanger.js'; +export {OAuth2CredentialExchanger} from './auth/oauth2/oauth2_credential_exchanger.js'; export type {BaseCredentialRefresher} from './auth/refresher/base_credential_refresher.js'; export {CredentialRefresherRegistry} from './auth/refresher/credential_refresher_registry.js'; export {BaseCodeExecutor} from './code_executors/base_code_executor.js'; diff --git a/core/test/auth/oauth2/oauth2_credential_exchanger_test.ts b/core/test/auth/oauth2/oauth2_credential_exchanger_test.ts new file mode 100644 index 00000000..d030fe61 --- /dev/null +++ b/core/test/auth/oauth2/oauth2_credential_exchanger_test.ts @@ -0,0 +1,387 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + AuthCredential, + OAuth2CredentialExchanger, + OAuthGrantType, +} from '@google/adk'; +import {describe, expect, it, vi} from 'vitest'; +import {AuthScheme} from '../../../src/auth/auth_schemes.js'; +import {CredentialExchangeError} from '../../../src/auth/exchanger/base_credential_exchanger.js'; +import { + determineGrantType, + exchangeAuthorizationCode, + exchangeClientCredentials, +} from '../../../src/auth/oauth2/oauth2_credential_exchanger.js'; +import * as oauth2Utils from '../../../src/auth/oauth2/oauth2_utils.js'; + +vi.mock('../../../src/auth/oauth2/oauth2_utils.js', () => ({ + getTokenEndpoint: vi.fn(), + fetchOAuth2Tokens: vi.fn(), + parseAuthorizationCode: vi.fn(), + createOAuth2TokenRequestBody: vi.fn(), +})); + +describe('OAuth2CredentialExchanger', () => { + describe('exchange', () => { + it('throws CredentialExchangeError if authScheme is missing', async () => { + const exchanger = new OAuth2CredentialExchanger(); + const authCredential = {} as AuthCredential; + + await expect(exchanger.exchange({authCredential})).rejects.toThrow( + CredentialExchangeError, + ); + }); + + it('returns early if accessToken is already present and wasExchanged is false', async () => { + const exchanger = new OAuth2CredentialExchanger(); + const authCredential = { + oauth2: {accessToken: 'existing-token'}, + } as AuthCredential; + const authScheme = {} as AuthScheme; + + const result = await exchanger.exchange({authCredential, authScheme}); + + expect(result.wasExchanged).toBe(false); + expect(result.credential).toBe(authCredential); + }); + + it('logs warning and returns if grant type is unsupported', async () => { + const exchanger = new OAuth2CredentialExchanger(); + const authCredential = {oauth2: {}} as AuthCredential; + const authScheme = { + flows: { + implicit: {}, // Unsupported for exchange by this exchanger usually, if determineGrantType returns undefined + }, + } as AuthScheme; + + const result = await exchanger.exchange({authCredential, authScheme}); + + expect(result.wasExchanged).toBe(false); + expect(result.credential).toBe(authCredential); + }); + + it('delegates to exchangeClientCredentials when grant type is client credentials', async () => { + const exchanger = new OAuth2CredentialExchanger(); + const authCredential = { + oauth2: {clientId: 'id', clientSecret: 'secret'}, + } as AuthCredential; + const authScheme = { + flows: { + clientCredentials: {}, + }, + } as AuthScheme; + const mockTokens = {accessToken: 'new-token'}; + + vi.mocked(oauth2Utils.getTokenEndpoint).mockReturnValue( + 'https://example.com/token', + ); + vi.mocked(oauth2Utils.fetchOAuth2Tokens).mockResolvedValue(mockTokens); + + const result = await exchanger.exchange({authCredential, authScheme}); + + expect(result.wasExchanged).toBe(true); + expect(result.credential.oauth2?.accessToken).toBe('new-token'); + }); + + it('delegates to exchangeAuthorizationCode when grant type is authorization code', async () => { + const exchanger = new OAuth2CredentialExchanger(); + const authCredential = { + oauth2: {clientId: 'id', clientSecret: 'secret', authCode: 'code'}, + } as AuthCredential; + const authScheme = { + flows: { + authorizationCode: {}, + }, + } as AuthScheme; + const mockTokens = {accessToken: 'new-token'}; + + vi.mocked(oauth2Utils.getTokenEndpoint).mockReturnValue( + 'https://example.com/token', + ); + vi.mocked(oauth2Utils.fetchOAuth2Tokens).mockResolvedValue(mockTokens); + + const result = await exchanger.exchange({authCredential, authScheme}); + + expect(result.wasExchanged).toBe(true); + expect(result.credential.oauth2?.accessToken).toBe('new-token'); + }); + }); + + describe('determineGrantType', () => { + it('returns CLIENT_CREDENTIALS if flows has clientCredentials', () => { + const authScheme = { + flows: { + clientCredentials: {}, + }, + } as AuthScheme; + + expect(determineGrantType(authScheme)).toBe( + OAuthGrantType.CLIENT_CREDENTIALS, + ); + }); + + it('returns AUTHORIZATION_CODE if flows has authorizationCode', () => { + const authScheme = { + flows: { + authorizationCode: {}, + }, + } as AuthScheme; + + expect(determineGrantType(authScheme)).toBe( + OAuthGrantType.AUTHORIZATION_CODE, + ); + }); + + it('returns CLIENT_CREDENTIALS for OpenIdConnect with client_credentials in grantTypesSupported', () => { + const authScheme = { + grantTypesSupported: ['client_credentials'], + } as AuthScheme; + + expect(determineGrantType(authScheme)).toBe( + OAuthGrantType.CLIENT_CREDENTIALS, + ); + }); + + it('returns AUTHORIZATION_CODE for OpenIdConnect without client_credentials in grantTypesSupported', () => { + const authScheme = { + grantTypesSupported: ['authorization_code'], + } as AuthScheme; + + expect(determineGrantType(authScheme)).toBe( + OAuthGrantType.AUTHORIZATION_CODE, + ); + }); + + it('returns undefined if no flows or grantTypesSupported', () => { + const authScheme = {} as AuthScheme; + + expect(determineGrantType(authScheme)).toBeUndefined(); + }); + }); + + describe('exchangeClientCredentials', () => { + it('throws CredentialExchangeError if token endpoint is missing', async () => { + const authCredential = { + oauth2: {clientId: 'id', clientSecret: 'secret'}, + } as AuthCredential; + const authScheme = {} as AuthScheme; + + vi.mocked(oauth2Utils.getTokenEndpoint).mockReturnValue(undefined); + + await expect( + exchangeClientCredentials({authCredential, authScheme}), + ).rejects.toThrow(CredentialExchangeError); + }); + + it('throws CredentialExchangeError if clientId or clientSecret is missing', async () => { + const authCredential = {oauth2: {}} as AuthCredential; + const authScheme = {} as AuthScheme; + + vi.mocked(oauth2Utils.getTokenEndpoint).mockReturnValue( + 'https://example.com/token', + ); + + await expect( + exchangeClientCredentials({authCredential, authScheme}), + ).rejects.toThrow(CredentialExchangeError); + }); + + it('calls fetchOAuth2Tokens and returns updated credential', async () => { + const authCredential = { + oauth2: {clientId: 'id', clientSecret: 'secret'}, + } as AuthCredential; + const authScheme = {} as AuthScheme; + const mockTokens = {accessToken: 'new-token', expiresIn: 3600}; + + vi.mocked(oauth2Utils.getTokenEndpoint).mockReturnValue( + 'https://example.com/token', + ); + vi.mocked(oauth2Utils.createOAuth2TokenRequestBody).mockReturnValue( + new URLSearchParams(), + ); + vi.mocked(oauth2Utils.fetchOAuth2Tokens).mockResolvedValue(mockTokens); + + const result = await exchangeClientCredentials({ + authCredential, + authScheme, + }); + + expect(result.wasExchanged).toBe(true); + expect(result.credential.oauth2?.accessToken).toBe('new-token'); + }); + + it('throws CredentialExchangeError if fetchOAuth2Tokens fails', async () => { + const authCredential = { + oauth2: {clientId: 'id', clientSecret: 'secret'}, + } as AuthCredential; + const authScheme = {} as AuthScheme; + + vi.mocked(oauth2Utils.getTokenEndpoint).mockReturnValue( + 'https://example.com/token', + ); + vi.mocked(oauth2Utils.fetchOAuth2Tokens).mockRejectedValue( + new Error('Network error'), + ); + + await expect( + exchangeClientCredentials({authCredential, authScheme}), + ).rejects.toThrow(CredentialExchangeError); + }); + + it('throws CredentialExchangeError if fetchOAuth2Tokens fails with non-Error', async () => { + const authCredential = { + oauth2: {clientId: 'id', clientSecret: 'secret'}, + } as AuthCredential; + const authScheme = {} as AuthScheme; + + vi.mocked(oauth2Utils.getTokenEndpoint).mockReturnValue( + 'https://example.com/token', + ); + vi.mocked(oauth2Utils.fetchOAuth2Tokens).mockRejectedValue( + 'String error', + ); + + await expect( + exchangeClientCredentials({authCredential, authScheme}), + ).rejects.toThrow(CredentialExchangeError); + }); + }); + + describe('exchangeAuthorizationCode', () => { + it('throws CredentialExchangeError if token endpoint is missing', async () => { + const authCredential = { + oauth2: {clientId: 'id', clientSecret: 'secret', authCode: 'code'}, + } as AuthCredential; + const authScheme = {} as AuthScheme; + + vi.mocked(oauth2Utils.getTokenEndpoint).mockReturnValue(undefined); + + await expect( + exchangeAuthorizationCode({authCredential, authScheme}), + ).rejects.toThrow(CredentialExchangeError); + }); + + it('throws CredentialExchangeError if required fields are missing', async () => { + const authCredential = {oauth2: {clientId: 'id'}} as AuthCredential; + const authScheme = {} as AuthScheme; + + vi.mocked(oauth2Utils.getTokenEndpoint).mockReturnValue( + 'https://example.com/token', + ); + + await expect( + exchangeAuthorizationCode({authCredential, authScheme}), + ).rejects.toThrow(CredentialExchangeError); + }); + + it('parses code from authResponseUri if authCode is missing', async () => { + const authCredential = { + oauth2: { + clientId: 'id', + clientSecret: 'secret', + authResponseUri: 'https://callback?code=abc', + }, + } as AuthCredential; + const authScheme = {} as AuthScheme; + const mockTokens = {accessToken: 'new-token'}; + + vi.mocked(oauth2Utils.getTokenEndpoint).mockReturnValue( + 'https://example.com/token', + ); + vi.mocked(oauth2Utils.parseAuthorizationCode).mockReturnValue('abc'); + vi.mocked(oauth2Utils.fetchOAuth2Tokens).mockResolvedValue(mockTokens); + + const result = await exchangeAuthorizationCode({ + authCredential, + authScheme, + }); + + expect(result.wasExchanged).toBe(true); + expect(oauth2Utils.parseAuthorizationCode).toHaveBeenCalledWith( + 'https://callback?code=abc', + ); + }); + + it('throws if no code found in authResponseUri', async () => { + const authCredential = { + oauth2: { + clientId: 'id', + clientSecret: 'secret', + authResponseUri: 'https://callback', + }, + } as AuthCredential; + const authScheme = {} as AuthScheme; + + vi.mocked(oauth2Utils.getTokenEndpoint).mockReturnValue( + 'https://example.com/token', + ); + vi.mocked(oauth2Utils.parseAuthorizationCode).mockReturnValue(undefined); + + await expect( + exchangeAuthorizationCode({authCredential, authScheme}), + ).rejects.toThrow(CredentialExchangeError); + }); + + it('calls fetchOAuth2Tokens and returns updated credential', async () => { + const authCredential = { + oauth2: {clientId: 'id', clientSecret: 'secret', authCode: 'code'}, + } as AuthCredential; + const authScheme = {} as AuthScheme; + const mockTokens = {accessToken: 'new-token'}; + + vi.mocked(oauth2Utils.getTokenEndpoint).mockReturnValue( + 'https://example.com/token', + ); + vi.mocked(oauth2Utils.fetchOAuth2Tokens).mockResolvedValue(mockTokens); + + const result = await exchangeAuthorizationCode({ + authCredential, + authScheme, + }); + + expect(result.wasExchanged).toBe(true); + expect(result.credential.oauth2?.accessToken).toBe('new-token'); + }); + + it('throws CredentialExchangeError if fetchOAuth2Tokens fails', async () => { + const authCredential = { + oauth2: {clientId: 'id', clientSecret: 'secret', authCode: 'code'}, + } as AuthCredential; + const authScheme = {} as AuthScheme; + + vi.mocked(oauth2Utils.getTokenEndpoint).mockReturnValue( + 'https://example.com/token', + ); + vi.mocked(oauth2Utils.fetchOAuth2Tokens).mockRejectedValue( + new Error('Network error'), + ); + + await expect( + exchangeAuthorizationCode({authCredential, authScheme}), + ).rejects.toThrow(CredentialExchangeError); + }); + + it('throws CredentialExchangeError if fetchOAuth2Tokens fails with non-Error', async () => { + const authCredential = { + oauth2: {clientId: 'id', clientSecret: 'secret', authCode: 'code'}, + } as AuthCredential; + const authScheme = {} as AuthScheme; + + vi.mocked(oauth2Utils.getTokenEndpoint).mockReturnValue( + 'https://example.com/token', + ); + vi.mocked(oauth2Utils.fetchOAuth2Tokens).mockRejectedValue( + 'String error', + ); + + await expect( + exchangeAuthorizationCode({authCredential, authScheme}), + ).rejects.toThrow(CredentialExchangeError); + }); + }); +}); diff --git a/core/test/auth/oauth2/oauth2_discovery_test.ts b/core/test/auth/oauth2/oauth2_discovery_test.ts new file mode 100644 index 00000000..b7ec0593 --- /dev/null +++ b/core/test/auth/oauth2/oauth2_discovery_test.ts @@ -0,0 +1,257 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {OAuth2DiscoveryManager} from '../../../src/auth/oauth2/oauth2_discovery.js'; + +describe('OAuth2DiscoveryManager', () => { + let manager: OAuth2DiscoveryManager; + + beforeEach(() => { + manager = new OAuth2DiscoveryManager(); + vi.stubGlobal('fetch', vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + describe('discoverAuthServerMetadata', () => { + it('returns undefined and logs warning for invalid issuerUrl', async () => { + const result = await manager.discoverAuthServerMetadata('not-a-url'); + expect(result).toBeUndefined(); + }); + + it('tries endpoints in order if path is present', async () => { + const issuerUrl = 'https://example.com/api'; + + // Mock fetch to fail for all endpoints + vi.mocked(fetch).mockResolvedValue({ + ok: false, + } as Response); + + await manager.discoverAuthServerMetadata(issuerUrl); + + expect(fetch).toHaveBeenCalledTimes(3); + expect(fetch).toHaveBeenNthCalledWith( + 1, + 'https://example.com/.well-known/oauth-authorization-server/api', + expect.anything(), + ); + expect(fetch).toHaveBeenNthCalledWith( + 2, + 'https://example.com/.well-known/openid-configuration/api', + expect.anything(), + ); + expect(fetch).toHaveBeenNthCalledWith( + 3, + 'https://example.com/api/.well-known/openid-configuration', + expect.anything(), + ); + }); + + it('tries endpoints in order if path is not present or is root', async () => { + const issuerUrl = 'https://example.com/'; + + vi.mocked(fetch).mockResolvedValue({ + ok: false, + } as Response); + + await manager.discoverAuthServerMetadata(issuerUrl); + + expect(fetch).toHaveBeenCalledTimes(2); + expect(fetch).toHaveBeenNthCalledWith( + 1, + 'https://example.com/.well-known/oauth-authorization-server', + expect.anything(), + ); + expect(fetch).toHaveBeenNthCalledWith( + 2, + 'https://example.com/.well-known/openid-configuration', + expect.anything(), + ); + }); + + it('returns metadata when discovery succeeds and issuer matches', async () => { + const issuerUrl = 'https://example.com'; + const mockMetadata = { + issuer: 'https://example.com', + authorization_endpoint: 'https://example.com/authorize', + token_endpoint: 'https://example.com/token', + }; + + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => mockMetadata, + } as Response); + + const result = await manager.discoverAuthServerMetadata(issuerUrl); + + expect(result).toEqual(mockMetadata); + }); + + it('logs warning and returns undefined if issuer does not match', async () => { + const issuerUrl = 'https://example.com'; + const mockMetadata = { + issuer: 'https://malicious.com', // Fake issuer + authorization_endpoint: 'https://example.com/authorize', + token_endpoint: 'https://example.com/token', + }; + + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => mockMetadata, + } as Response); + + const result = await manager.discoverAuthServerMetadata(issuerUrl); + + expect(result).toBeUndefined(); + }); + + it('continues to next endpoint if fetch fails (throws error)', async () => { + const issuerUrl = 'https://example.com'; + + vi.mocked(fetch) + .mockRejectedValueOnce(new Error('Network failure')) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + issuer: 'https://example.com', + authorization_endpoint: 'https://example.com/authorize', + token_endpoint: 'https://example.com/token', + }), + } as Response); + + const result = await manager.discoverAuthServerMetadata(issuerUrl); + + expect(fetch).toHaveBeenCalledTimes(2); + expect(result).toBeDefined(); + }); + + it('continues to next endpoint if parsing fails', async () => { + const issuerUrl = 'https://example.com'; + + vi.mocked(fetch) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + invalid_field: 'broken', + }), + } as Response) // Fails validation + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + issuer: 'https://example.com', + authorization_endpoint: 'https://example.com/authorize', + token_endpoint: 'https://example.com/token', + }), + } as Response); + + const result = await manager.discoverAuthServerMetadata(issuerUrl); + + expect(fetch).toHaveBeenCalledTimes(2); + expect(result).toBeDefined(); + }); + }); + + describe('discoverResourceMetadata', () => { + it('returns undefined and logs warning for invalid resourceUrl', async () => { + const result = await manager.discoverResourceMetadata('not-a-url'); + expect(result).toBeUndefined(); + }); + + it('uses correct endpoint if path is present', async () => { + const resourceUrl = 'https://example.com/api'; + + vi.mocked(fetch).mockResolvedValue({ + ok: false, + } as Response); + + await manager.discoverResourceMetadata(resourceUrl); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith( + 'https://example.com/.well-known/oauth-protected-resource/api', + expect.anything(), + ); + }); + + it('uses correct endpoint if path is not present or is root', async () => { + const resourceUrl = 'https://example.com/'; + + vi.mocked(fetch).mockResolvedValue({ + ok: false, + } as Response); + + await manager.discoverResourceMetadata(resourceUrl); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith( + 'https://example.com/.well-known/oauth-protected-resource', + expect.anything(), + ); + }); + + it('returns metadata when discovery succeeds and resource matches', async () => { + const resourceUrl = 'https://example.com'; + const mockMetadata = { + resource: 'https://example.com', + authorization_servers: ['https://example.com/auth'], + }; + + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => mockMetadata, + } as Response); + + const result = await manager.discoverResourceMetadata(resourceUrl); + + expect(result).toEqual(mockMetadata); + }); + + it('logs warning and returns undefined if resource does not match', async () => { + const resourceUrl = 'https://example.com'; + const mockMetadata = { + resource: 'https://malicious.com', + authorization_servers: ['https://example.com/auth'], + }; + + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => mockMetadata, + } as Response); + + const result = await manager.discoverResourceMetadata(resourceUrl); + + expect(result).toBeUndefined(); + }); + + it('returns undefined if fetch fails (throws error)', async () => { + const resourceUrl = 'https://example.com'; + + vi.mocked(fetch).mockRejectedValue(new Error('Network failure')); + + const result = await manager.discoverResourceMetadata(resourceUrl); + + expect(result).toBeUndefined(); + }); + + it('returns undefined if parsing fails', async () => { + const resourceUrl = 'https://example.com'; + + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ + invalid_field: 'broken', + }), + } as Response); + + const result = await manager.discoverResourceMetadata(resourceUrl); + + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/core/test/auth/oauth2/oauth2_utils_test.ts b/core/test/auth/oauth2/oauth2_utils_test.ts new file mode 100644 index 00000000..67cd8fc5 --- /dev/null +++ b/core/test/auth/oauth2/oauth2_utils_test.ts @@ -0,0 +1,227 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {AuthScheme, OAuth2Auth} from '@google/adk'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import { + AuthorizationCodeParams, + ClientCredentialsParams, + createOAuth2TokenRequestBody, + fetchOAuth2Tokens, + getTokenEndpoint, + isTokenExpired, + parseAuthorizationCode, + RefreshTokenParams, +} from '../../../src/auth/oauth2/oauth2_utils.js'; + +describe('oauth2_utils', () => { + describe('getTokenEndpoint', () => { + it('returns tokenEndpoint from OpenIdConnectWithConfig', () => { + const scheme = { + tokenEndpoint: 'https://example.com/token', + } as AuthScheme; + expect(getTokenEndpoint(scheme)).toBe('https://example.com/token'); + }); + + it('returns tokenUrl from flows.authorizationCode', () => { + const scheme = { + flows: { + authorizationCode: { + tokenUrl: 'https://example.com/token-auth', + }, + }, + } as AuthScheme; + expect(getTokenEndpoint(scheme)).toBe('https://example.com/token-auth'); + }); + + it('returns tokenUrl from flows.clientCredentials', () => { + const scheme = { + flows: { + clientCredentials: { + tokenUrl: 'https://example.com/token-cc', + }, + }, + } as AuthScheme; + expect(getTokenEndpoint(scheme)).toBe('https://example.com/token-cc'); + }); + + it('returns undefined if no token URIs are found', () => { + const scheme = { + flows: { + implicit: { + authorizationUrl: 'https://example.com/auth', + }, + }, + } as AuthScheme; + expect(getTokenEndpoint(scheme)).toBeUndefined(); + }); + + it('returns undefined if flows is empty', () => { + const scheme = { + flows: {}, + } as AuthScheme; + expect(getTokenEndpoint(scheme)).toBeUndefined(); + }); + }); + + describe('fetchOAuth2Tokens', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('fetches tokens successfully and maps snake_case to camelCase', async () => { + const mockResponse = { + access_token: 'acc-123', + refresh_token: 'ref-456', + expires_in: 3600, + }; + + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => mockResponse, + } as Response); + + const body = new URLSearchParams(); + const result = await fetchOAuth2Tokens('https://example.com/token', body); + + expect(result.accessToken).toBe('acc-123'); + expect(result.refreshToken).toBe('ref-456'); + expect(result.expiresIn).toBe(3600); + expect(result.expiresAt).toBeGreaterThan(Date.now()); + }); + + it('handles missing refresh_token or expires_in', async () => { + const mockResponse = { + access_token: 'acc-123', + }; + + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => mockResponse, + } as Response); + + const body = new URLSearchParams(); + const result = await fetchOAuth2Tokens('https://example.com/token', body); + + expect(result.accessToken).toBe('acc-123'); + expect(result.refreshToken).toBeUndefined(); + expect(result.expiresIn).toBeUndefined(); + expect(result.expiresAt).toBeUndefined(); + }); + + it('throws error if response is not ok', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: false, + status: 401, + } as Response); + + const body = new URLSearchParams(); + await expect( + fetchOAuth2Tokens('https://example.com/token', body), + ).rejects.toThrow('Token request failed with status 401'); + }); + }); + + describe('parseAuthorizationCode', () => { + it('parses code from query string', () => { + const uri = 'https://example.com/callback?code=super-secret&state=abc'; + expect(parseAuthorizationCode(uri)).toBe('super-secret'); + }); + + it('returns undefined if code is missing', () => { + const uri = 'https://example.com/callback?state=abc'; + expect(parseAuthorizationCode(uri)).toBeUndefined(); + }); + + it('returns undefined and logs warning for invalid URI', () => { + const uri = 'not-a-valid-url'; + expect(parseAuthorizationCode(uri)).toBeUndefined(); + }); + }); + + describe('createOAuth2TokenRequestBody', () => { + it('creates body for client_credentials', () => { + const params: ClientCredentialsParams = { + grantType: 'client_credentials', + clientId: 'client-id', + clientSecret: 'client-secret', + }; + + const body = createOAuth2TokenRequestBody(params); + + expect(body.get('grant_type')).toBe('client_credentials'); + expect(body.get('client_id')).toBe('client-id'); + expect(body.get('client_secret')).toBe('client-secret'); + }); + + it('creates body for authorization_code', () => { + const params: AuthorizationCodeParams = { + grantType: 'authorization_code', + clientId: 'client-id', + clientSecret: 'client-secret', + code: 'auth-code', + redirectUri: 'https://example.com/callback', + }; + + const body = createOAuth2TokenRequestBody(params); + + expect(body.get('grant_type')).toBe('authorization_code'); + expect(body.get('client_id')).toBe('client-id'); + expect(body.get('client_secret')).toBe('client-secret'); + expect(body.get('code')).toBe('auth-code'); + expect(body.get('redirect_uri')).toBe('https://example.com/callback'); + }); + + it('creates body for refresh_token', () => { + const params: RefreshTokenParams = { + grantType: 'refresh_token', + clientId: 'client-id', + clientSecret: 'client-secret', + refreshToken: 'refresh-token', + }; + + const body = createOAuth2TokenRequestBody(params); + + expect(body.get('grant_type')).toBe('refresh_token'); + expect(body.get('client_id')).toBe('client-id'); + expect(body.get('client_secret')).toBe('client-secret'); + expect(body.get('refresh_token')).toBe('refresh-token'); + }); + }); + + describe('isTokenExpired', () => { + it('returns false if expiresAt is not a number', () => { + expect(isTokenExpired({} as unknown as OAuth2Auth)).toBe(false); + expect( + isTokenExpired({expiresAt: 'not-a-number'} as unknown as OAuth2Auth), + ).toBe(false); + }); + + it('returns false if token is not expired (future expiresAt in milliseconds)', () => { + const futureTimeMs = Date.now() + 3600 * 1000; // 1 hour in future + expect(isTokenExpired({expiresAt: futureTimeMs} as OAuth2Auth)).toBe( + false, + ); + }); + + it('returns true if token is expired (past expiresAt in milliseconds)', () => { + const pastTimeMs = Date.now() - 3600 * 1000; // 1 hour in past + expect(isTokenExpired({expiresAt: pastTimeMs} as OAuth2Auth)).toBe(true); + }); + + it('uses leeway (default 60s)', () => { + const nearFutureTimeMs = Date.now() + 30 * 1000; // 30s in future + // With 60s leeway, 30s should be considered expired + expect(isTokenExpired({expiresAt: nearFutureTimeMs} as OAuth2Auth)).toBe( + true, + ); + }); + }); +}); From 7bbd97941de186e3104d326dcecf8ec8671059a6 Mon Sep 17 00:00:00 2001 From: kalenkevich Date: Wed, 1 Apr 2026 12:37:13 -0700 Subject: [PATCH 2/7] fix docs check --- core/src/auth/exchanger/base_credential_exchanger.ts | 9 +++------ core/src/auth/oauth2/oauth2_credential_exchanger.ts | 8 -------- core/src/common.ts | 5 ++++- 3 files changed, 7 insertions(+), 15 deletions(-) diff --git a/core/src/auth/exchanger/base_credential_exchanger.ts b/core/src/auth/exchanger/base_credential_exchanger.ts index 1db50f47..e4b1e988 100644 --- a/core/src/auth/exchanger/base_credential_exchanger.ts +++ b/core/src/auth/exchanger/base_credential_exchanger.ts @@ -30,16 +30,13 @@ export interface BaseCredentialExchanger { /** * Exchange credential if needed. * - * @param authCredential - The credential to exchange. - * @param authScheme - The authentication scheme (optional, some exchangers don't need it). + * @param params.authCredential - The credential to exchange. + * @param params.authScheme - The authentication scheme (optional, some exchangers don't need it). * @returns The exchanged credential. * @throws CredentialExchangeError: If credential exchange fails. */ - exchange({ - authScheme, - authCredential, - }: { + exchange(params: { authScheme?: AuthScheme; authCredential: AuthCredential; }): Promise; diff --git a/core/src/auth/oauth2/oauth2_credential_exchanger.ts b/core/src/auth/oauth2/oauth2_credential_exchanger.ts index 92c7ce52..00998358 100644 --- a/core/src/auth/oauth2/oauth2_credential_exchanger.ts +++ b/core/src/auth/oauth2/oauth2_credential_exchanger.ts @@ -28,14 +28,6 @@ import { * Exchanges OAuth2 credentials from authorization responses using standard fetch. */ export class OAuth2CredentialExchanger implements BaseCredentialExchanger { - /** - * Exchange OAuth2 credential if needed. - * - * @param authCredential - The OAuth2 credential to exchange. - * @param authScheme - The OAuth2 authentication scheme. - * @returns The exchanged credential. - * @throws CredentialExchangeError: If authScheme is missing. - */ async exchange({ authCredential, authScheme, diff --git a/core/src/common.ts b/core/src/common.ts index f6cf1394..80a3fea3 100644 --- a/core/src/common.ts +++ b/core/src/common.ts @@ -79,7 +79,10 @@ export type {BaseCredentialService} from './auth/credential_service/base_credent export {InMemoryCredentialService} from './auth/credential_service/in_memory_credential_service.js'; export {SessionStateCredentialService} from './auth/credential_service/session_state_credential_service.js'; export {CredentialExchangeError} from './auth/exchanger/base_credential_exchanger.js'; -export type {BaseCredentialExchanger} from './auth/exchanger/base_credential_exchanger.js'; +export type { + BaseCredentialExchanger, + ExchangeResult, +} from './auth/exchanger/base_credential_exchanger.js'; export {OAuth2CredentialExchanger} from './auth/oauth2/oauth2_credential_exchanger.js'; export type {BaseCredentialRefresher} from './auth/refresher/base_credential_refresher.js'; export {CredentialRefresherRegistry} from './auth/refresher/credential_refresher_registry.js'; From 5514b26caad05ad54aad8354a39fe9466064e543 Mon Sep 17 00:00:00 2001 From: kalenkevich Date: Wed, 1 Apr 2026 12:37:13 -0700 Subject: [PATCH 3/7] fix pr comments --- core/src/auth/oauth2/oauth2_utils.ts | 52 ++++++++------ .../test/auth/oauth2/oauth2_discovery_test.ts | 70 +++++++++++++++++++ core/test/auth/oauth2/oauth2_utils_test.ts | 3 + 3 files changed, 103 insertions(+), 22 deletions(-) diff --git a/core/src/auth/oauth2/oauth2_utils.ts b/core/src/auth/oauth2/oauth2_utils.ts index 6e3f5753..209b2ab3 100644 --- a/core/src/auth/oauth2/oauth2_utils.ts +++ b/core/src/auth/oauth2/oauth2_utils.ts @@ -13,11 +13,14 @@ import {AuthScheme, OpenIdConnectWithConfig} from '../auth_schemes.js'; * Returns the token endpoint for the given auth scheme. */ export function getTokenEndpoint(authScheme: AuthScheme): string | undefined { - if ('tokenEndpoint' in authScheme) { + if ( + authScheme.type === 'openIdConnect' && + (authScheme as OpenIdConnectWithConfig).tokenEndpoint + ) { return (authScheme as OpenIdConnectWithConfig).tokenEndpoint; } - if ('flows' in authScheme && authScheme.flows) { + if (authScheme.type === 'oauth2' && authScheme.flows) { const flows = authScheme.flows; const flow = flows.authorizationCode || @@ -46,28 +49,33 @@ export async function fetchOAuth2Tokens( endpoint: string, body: URLSearchParams, ): Promise { - const response = await fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: body.toString(), - }); - - if (!response.ok) { - throw new Error(`Token request failed with status ${response.status}`); - } + try { + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: body.toString(), + }); + + if (!response.ok) { + throw new Error(`Token request failed with status ${response.status}`); + } - const data = (await response.json()) as OAuth2TokenResponse; + const data = (await response.json()) as OAuth2TokenResponse; - return { - accessToken: data.access_token, - refreshToken: data.refresh_token, - expiresIn: data.expires_in, - expiresAt: data.expires_in - ? Date.now() + data.expires_in * 1000 - : undefined, - }; + return { + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresIn: data.expires_in, + expiresAt: data.expires_in + ? Date.now() + data.expires_in * 1000 + : undefined, + }; + } catch (e) { + logger.error(`Failed to fetch OAuth2 tokens: ${e}`); + throw e; + } } /** diff --git a/core/test/auth/oauth2/oauth2_discovery_test.ts b/core/test/auth/oauth2/oauth2_discovery_test.ts index b7ec0593..805ac51b 100644 --- a/core/test/auth/oauth2/oauth2_discovery_test.ts +++ b/core/test/auth/oauth2/oauth2_discovery_test.ts @@ -111,6 +111,42 @@ describe('OAuth2DiscoveryManager', () => { expect(result).toBeUndefined(); }); + it('returns metadata when issuer matches even with trailing slash differences', async () => { + const issuerUrl = 'https://example.com/'; + const mockMetadata = { + issuer: 'https://example.com', + authorization_endpoint: 'https://example.com/authorize', + token_endpoint: 'https://example.com/token', + }; + + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => mockMetadata, + } as Response); + + const result = await manager.discoverAuthServerMetadata(issuerUrl); + + expect(result).toEqual(mockMetadata); + }); + + it('rejects metadata if issuer is a subdomain of the expected issuer', async () => { + const issuerUrl = 'https://example.com'; + const mockMetadata = { + issuer: 'https://example.com.evil.com', + authorization_endpoint: 'https://example.com/authorize', + token_endpoint: 'https://example.com/token', + }; + + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => mockMetadata, + } as Response); + + const result = await manager.discoverAuthServerMetadata(issuerUrl); + + expect(result).toBeUndefined(); + }); + it('continues to next endpoint if fetch fails (throws error)', async () => { const issuerUrl = 'https://example.com'; @@ -229,6 +265,40 @@ describe('OAuth2DiscoveryManager', () => { expect(result).toBeUndefined(); }); + it('returns metadata when resource matches even with trailing slash differences', async () => { + const resourceUrl = 'https://example.com/'; + const mockMetadata = { + resource: 'https://example.com', + authorization_servers: ['https://example.com/auth'], + }; + + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => mockMetadata, + } as Response); + + const result = await manager.discoverResourceMetadata(resourceUrl); + + expect(result).toEqual(mockMetadata); + }); + + it('rejects metadata if resource is a subdomain of the expected resource', async () => { + const resourceUrl = 'https://example.com'; + const mockMetadata = { + resource: 'https://example.com.evil.com', + authorization_servers: ['https://example.com/auth'], + }; + + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => mockMetadata, + } as Response); + + const result = await manager.discoverResourceMetadata(resourceUrl); + + expect(result).toBeUndefined(); + }); + it('returns undefined if fetch fails (throws error)', async () => { const resourceUrl = 'https://example.com'; diff --git a/core/test/auth/oauth2/oauth2_utils_test.ts b/core/test/auth/oauth2/oauth2_utils_test.ts index 67cd8fc5..e48e92b0 100644 --- a/core/test/auth/oauth2/oauth2_utils_test.ts +++ b/core/test/auth/oauth2/oauth2_utils_test.ts @@ -21,6 +21,7 @@ describe('oauth2_utils', () => { describe('getTokenEndpoint', () => { it('returns tokenEndpoint from OpenIdConnectWithConfig', () => { const scheme = { + type: 'openIdConnect', tokenEndpoint: 'https://example.com/token', } as AuthScheme; expect(getTokenEndpoint(scheme)).toBe('https://example.com/token'); @@ -28,6 +29,7 @@ describe('oauth2_utils', () => { it('returns tokenUrl from flows.authorizationCode', () => { const scheme = { + type: 'oauth2', flows: { authorizationCode: { tokenUrl: 'https://example.com/token-auth', @@ -39,6 +41,7 @@ describe('oauth2_utils', () => { it('returns tokenUrl from flows.clientCredentials', () => { const scheme = { + type: 'oauth2', flows: { clientCredentials: { tokenUrl: 'https://example.com/token-cc', From 528c2c36b440e1749496607b9d28960c8715f3db Mon Sep 17 00:00:00 2001 From: kalenkevich Date: Wed, 1 Apr 2026 13:17:24 -0700 Subject: [PATCH 4/7] update imports in tests --- core/test/auth/oauth2/oauth2_credential_exchanger_test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/test/auth/oauth2/oauth2_credential_exchanger_test.ts b/core/test/auth/oauth2/oauth2_credential_exchanger_test.ts index d030fe61..bbe4a90b 100644 --- a/core/test/auth/oauth2/oauth2_credential_exchanger_test.ts +++ b/core/test/auth/oauth2/oauth2_credential_exchanger_test.ts @@ -6,12 +6,12 @@ import { AuthCredential, + AuthScheme, + CredentialExchangeError, OAuth2CredentialExchanger, OAuthGrantType, } from '@google/adk'; import {describe, expect, it, vi} from 'vitest'; -import {AuthScheme} from '../../../src/auth/auth_schemes.js'; -import {CredentialExchangeError} from '../../../src/auth/exchanger/base_credential_exchanger.js'; import { determineGrantType, exchangeAuthorizationCode, From 6ad5480b429b31340aecb59bd162f5df4bc95169 Mon Sep 17 00:00:00 2001 From: kalenkevich Date: Wed, 1 Apr 2026 13:18:36 -0700 Subject: [PATCH 5/7] fix test imports --- core/src/common.ts | 1 + core/test/auth/oauth2/oauth2_discovery_test.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/core/src/common.ts b/core/src/common.ts index 80a3fea3..ca890783 100644 --- a/core/src/common.ts +++ b/core/src/common.ts @@ -84,6 +84,7 @@ export type { ExchangeResult, } from './auth/exchanger/base_credential_exchanger.js'; export {OAuth2CredentialExchanger} from './auth/oauth2/oauth2_credential_exchanger.js'; +export {OAuth2DiscoveryManager} from './auth/oauth2/oauth2_discovery.js'; export type {BaseCredentialRefresher} from './auth/refresher/base_credential_refresher.js'; export {CredentialRefresherRegistry} from './auth/refresher/credential_refresher_registry.js'; export {BaseCodeExecutor} from './code_executors/base_code_executor.js'; diff --git a/core/test/auth/oauth2/oauth2_discovery_test.ts b/core/test/auth/oauth2/oauth2_discovery_test.ts index 805ac51b..b34ea7c3 100644 --- a/core/test/auth/oauth2/oauth2_discovery_test.ts +++ b/core/test/auth/oauth2/oauth2_discovery_test.ts @@ -4,8 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +import {OAuth2DiscoveryManager} from '@google/adk'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; -import {OAuth2DiscoveryManager} from '../../../src/auth/oauth2/oauth2_discovery.js'; describe('OAuth2DiscoveryManager', () => { let manager: OAuth2DiscoveryManager; From 9373f2c8e7a5a936cd6ab748af08e395dae78af5 Mon Sep 17 00:00:00 2001 From: kalenkevich Date: Wed, 1 Apr 2026 13:32:03 -0700 Subject: [PATCH 6/7] add defence for verity oauth2 flow attacks: - PKCE (Proof Key for Code Exchange) - CSRF (State Parameter) - Mix-Up attacks - SSRF via Discovery - Token Storage (XSS) --- core/src/auth/auth_credential.ts | 1 + .../session_state_credential_service.ts | 5 +- .../oauth2/oauth2_credential_exchanger.ts | 17 +++++ core/src/auth/oauth2/oauth2_discovery.ts | 48 ++++++++++++ core/src/auth/oauth2/oauth2_utils.ts | 4 + .../oauth2_credential_exchanger_test.ts | 74 +++++++++++++++++++ .../test/auth/oauth2/oauth2_discovery_test.ts | 38 ++++++++++ core/test/auth/oauth2/oauth2_utils_test.ts | 15 ++++ 8 files changed, 201 insertions(+), 1 deletion(-) diff --git a/core/src/auth/auth_credential.ts b/core/src/auth/auth_credential.ts index e5ff751c..3034e085 100644 --- a/core/src/auth/auth_credential.ts +++ b/core/src/auth/auth_credential.ts @@ -45,6 +45,7 @@ export interface OAuth2Auth { */ authUri?: string; state?: string; + codeVerifier?: string; /** * tool or adk can decide the redirect_uri if they don't want client to decide */ diff --git a/core/src/auth/credential_service/session_state_credential_service.ts b/core/src/auth/credential_service/session_state_credential_service.ts index 8dd9bb28..83cf8d9e 100644 --- a/core/src/auth/credential_service/session_state_credential_service.ts +++ b/core/src/auth/credential_service/session_state_credential_service.ts @@ -11,7 +11,10 @@ import {BaseCredentialService} from './base_credential_service.js'; /** * Class for implementation of credential service using session state as the store. - * Note: store credential in session may not be secure, use at your own risk. + * + * @warning Storing credentials in session state is insecure. Session state may be + * persisted in plaintext, logged, or accessible via XSS depending on the runner + * environment. Use a secure vault or encrypted storage for production applications. */ export class SessionStateCredentialService implements BaseCredentialService { loadCredential( diff --git a/core/src/auth/oauth2/oauth2_credential_exchanger.ts b/core/src/auth/oauth2/oauth2_credential_exchanger.ts index 00998358..66c800b9 100644 --- a/core/src/auth/oauth2/oauth2_credential_exchanger.ts +++ b/core/src/auth/oauth2/oauth2_credential_exchanger.ts @@ -164,6 +164,22 @@ export async function exchangeAuthorizationCode({ code = parseAuthorizationCode(authCredential.oauth2.authResponseUri); } + if (authCredential.oauth2.authResponseUri && authCredential.oauth2.state) { + try { + const url = new URL(authCredential.oauth2.authResponseUri); + const receivedState = url.searchParams.get('state') || undefined; + if (authCredential.oauth2.state !== receivedState) { + throw new CredentialExchangeError( + 'State mismatch detected. Potential CSRF attack.', + ); + } + } catch (e) { + throw new CredentialExchangeError( + `Failed to parse authResponseUri for state validation: ${e instanceof Error ? e.message : String(e)}`, + ); + } + } + if (!code) { throw new CredentialExchangeError( 'Authorization code not found in auth response.', @@ -176,6 +192,7 @@ export async function exchangeAuthorizationCode({ clientSecret: authCredential.oauth2.clientSecret, code, redirectUri: authCredential.oauth2.redirectUri, + codeVerifier: authCredential.oauth2.codeVerifier, }); try { diff --git a/core/src/auth/oauth2/oauth2_discovery.ts b/core/src/auth/oauth2/oauth2_discovery.ts index 869b35d8..c403134b 100644 --- a/core/src/auth/oauth2/oauth2_discovery.ts +++ b/core/src/auth/oauth2/oauth2_discovery.ts @@ -44,6 +44,10 @@ export class OAuth2DiscoveryManager { async discoverAuthServerMetadata( issuerUrl: string, ): Promise { + if (!validateDiscoveryUrl(issuerUrl)) { + return undefined; + } + let baseUrl: string; let path: string; @@ -111,6 +115,10 @@ export class OAuth2DiscoveryManager { async discoverResourceMetadata( resourceUrl: string, ): Promise { + if (!validateDiscoveryUrl(resourceUrl)) { + return undefined; + } + let baseUrl: string; let path: string; @@ -161,3 +169,43 @@ export class OAuth2DiscoveryManager { return undefined; } } + +function validateDiscoveryUrl(urlStr: string): boolean { + try { + const url = new URL(urlStr); + if (url.protocol !== 'https:') { + logger.warn(`Unsafe protocol for discovery URL: ${url.protocol}`); + return false; + } + + const host = url.hostname.toLowerCase(); + + // Block localhost and common private IP ranges + if ( + host === 'localhost' || + host === '127.0.0.1' || + host === '[::1]' || + host.startsWith('10.') || + host.startsWith('192.168.') || + host.startsWith('169.254.') + ) { + logger.warn(`Unsafe host for discovery URL: ${host}`); + return false; + } + + // Check for 172.16.x.x - 172.31.x.x + const match = host.match(/^172\.(\d+)\./); + if (match) { + const secondOctet = parseInt(match[1], 10); + if (secondOctet >= 16 && secondOctet <= 31) { + logger.warn(`Unsafe host for discovery URL: ${host}`); + return false; + } + } + + return true; + } catch (e) { + logger.warn(`Failed to parse URL for validation ${urlStr}: ${e}`); + return false; + } +} diff --git a/core/src/auth/oauth2/oauth2_utils.ts b/core/src/auth/oauth2/oauth2_utils.ts index 209b2ab3..1cbd7715 100644 --- a/core/src/auth/oauth2/oauth2_utils.ts +++ b/core/src/auth/oauth2/oauth2_utils.ts @@ -109,6 +109,7 @@ export interface AuthorizationCodeParams { clientSecret: string; code: string; redirectUri?: string; + codeVerifier?: string; } /** @@ -145,6 +146,9 @@ export function createOAuth2TokenRequestBody( if (params.redirectUri) { body.set('redirect_uri', params.redirectUri); } + if (params.codeVerifier) { + body.set('code_verifier', params.codeVerifier); + } } else if (params.grantType === 'refresh_token') { body.set('refresh_token', params.refreshToken); } diff --git a/core/test/auth/oauth2/oauth2_credential_exchanger_test.ts b/core/test/auth/oauth2/oauth2_credential_exchanger_test.ts index bbe4a90b..9214afae 100644 --- a/core/test/auth/oauth2/oauth2_credential_exchanger_test.ts +++ b/core/test/auth/oauth2/oauth2_credential_exchanger_test.ts @@ -383,5 +383,79 @@ describe('OAuth2CredentialExchanger', () => { exchangeAuthorizationCode({authCredential, authScheme}), ).rejects.toThrow(CredentialExchangeError); }); + + it('throws CredentialExchangeError if state in authResponseUri does not match expected state', async () => { + const authCredential = { + oauth2: { + clientId: 'id', + clientSecret: 'secret', + authResponseUri: 'https://callback?code=abc&state=wrong', + state: 'expected-state', + }, + } as AuthCredential; + const authScheme = {} as AuthScheme; + + vi.mocked(oauth2Utils.getTokenEndpoint).mockReturnValue( + 'https://example.com/token', + ); + vi.mocked(oauth2Utils.parseAuthorizationCode).mockReturnValue('abc'); + + await expect( + exchangeAuthorizationCode({authCredential, authScheme}), + ).rejects.toThrow('State mismatch detected'); + }); + + it('succeeds if state in authResponseUri matches expected state', async () => { + const authCredential = { + oauth2: { + clientId: 'id', + clientSecret: 'secret', + authResponseUri: 'https://callback?code=abc&state=correct', + state: 'correct', + }, + } as AuthCredential; + const authScheme = {} as AuthScheme; + const mockTokens = {accessToken: 'new-token'}; + + vi.mocked(oauth2Utils.getTokenEndpoint).mockReturnValue( + 'https://example.com/token', + ); + vi.mocked(oauth2Utils.parseAuthorizationCode).mockReturnValue('abc'); + vi.mocked(oauth2Utils.fetchOAuth2Tokens).mockResolvedValue(mockTokens); + + const result = await exchangeAuthorizationCode({ + authCredential, + authScheme, + }); + + expect(result.wasExchanged).toBe(true); + }); + + it('passes codeVerifier to createOAuth2TokenRequestBody', async () => { + const authCredential = { + authType: 'oauth2', + oauth2: { + clientId: 'id', + clientSecret: 'secret', + authCode: 'code', + codeVerifier: 'verifier-123', + }, + } as AuthCredential; + const authScheme = {} as AuthScheme; + const mockTokens = {accessToken: 'new-token'}; + + vi.mocked(oauth2Utils.getTokenEndpoint).mockReturnValue( + 'https://example.com/token', + ); + vi.mocked(oauth2Utils.fetchOAuth2Tokens).mockResolvedValue(mockTokens); + + await exchangeAuthorizationCode({authCredential, authScheme}); + + expect(oauth2Utils.createOAuth2TokenRequestBody).toHaveBeenCalledWith( + expect.objectContaining({ + codeVerifier: 'verifier-123', + }), + ); + }); }); }); diff --git a/core/test/auth/oauth2/oauth2_discovery_test.ts b/core/test/auth/oauth2/oauth2_discovery_test.ts index b34ea7c3..ec9ae670 100644 --- a/core/test/auth/oauth2/oauth2_discovery_test.ts +++ b/core/test/auth/oauth2/oauth2_discovery_test.ts @@ -25,6 +25,25 @@ describe('OAuth2DiscoveryManager', () => { expect(result).toBeUndefined(); }); + it('returns undefined if issuerUrl uses non-https protocol', async () => { + const result = + await manager.discoverAuthServerMetadata('http://example.com'); + expect(result).toBeUndefined(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('returns undefined if issuerUrl uses private IP or localhost', async () => { + const result = + await manager.discoverAuthServerMetadata('https://127.0.0.1'); + expect(result).toBeUndefined(); + + const result2 = + await manager.discoverAuthServerMetadata('https://localhost'); + expect(result2).toBeUndefined(); + + expect(fetch).not.toHaveBeenCalled(); + }); + it('tries endpoints in order if path is present', async () => { const issuerUrl = 'https://example.com/api'; @@ -199,6 +218,25 @@ describe('OAuth2DiscoveryManager', () => { expect(result).toBeUndefined(); }); + it('returns undefined if resourceUrl uses non-https protocol', async () => { + const result = + await manager.discoverResourceMetadata('http://example.com'); + expect(result).toBeUndefined(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('returns undefined if resourceUrl uses private IP or localhost', async () => { + const result = + await manager.discoverResourceMetadata('https://127.0.0.1'); + expect(result).toBeUndefined(); + + const result2 = + await manager.discoverResourceMetadata('https://localhost'); + expect(result2).toBeUndefined(); + + expect(fetch).not.toHaveBeenCalled(); + }); + it('uses correct endpoint if path is present', async () => { const resourceUrl = 'https://example.com/api'; diff --git a/core/test/auth/oauth2/oauth2_utils_test.ts b/core/test/auth/oauth2/oauth2_utils_test.ts index e48e92b0..1109a57b 100644 --- a/core/test/auth/oauth2/oauth2_utils_test.ts +++ b/core/test/auth/oauth2/oauth2_utils_test.ts @@ -182,6 +182,21 @@ describe('oauth2_utils', () => { expect(body.get('redirect_uri')).toBe('https://example.com/callback'); }); + it('creates body for authorization_code with code_verifier', () => { + const params: AuthorizationCodeParams = { + grantType: 'authorization_code', + clientId: 'client-id', + clientSecret: 'client-secret', + code: 'auth-code', + redirectUri: 'https://example.com/callback', + codeVerifier: 'verifier-123', + }; + + const body = createOAuth2TokenRequestBody(params); + + expect(body.get('code_verifier')).toBe('verifier-123'); + }); + it('creates body for refresh_token', () => { const params: RefreshTokenParams = { grantType: 'refresh_token', From d9f608e8f6542750073d14c8d927cb081aeffc4b Mon Sep 17 00:00:00 2001 From: kalenkevich Date: Wed, 1 Apr 2026 14:19:42 -0700 Subject: [PATCH 7/7] fix type doc check --- .../auth/credential_service/session_state_credential_service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/auth/credential_service/session_state_credential_service.ts b/core/src/auth/credential_service/session_state_credential_service.ts index 83cf8d9e..71795cbd 100644 --- a/core/src/auth/credential_service/session_state_credential_service.ts +++ b/core/src/auth/credential_service/session_state_credential_service.ts @@ -12,7 +12,7 @@ import {BaseCredentialService} from './base_credential_service.js'; /** * Class for implementation of credential service using session state as the store. * - * @warning Storing credentials in session state is insecure. Session state may be + * Warning: Storing credentials in session state is insecure. Session state may be * persisted in plaintext, logged, or accessible via XSS depending on the runner * environment. Use a secure vault or encrypted storage for production applications. */