Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions core/src/auth/auth_credential.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
9 changes: 3 additions & 6 deletions core/src/auth/exchanger/base_credential_exchanger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ExchangeResult>;
Expand Down
216 changes: 216 additions & 0 deletions core/src/auth/oauth2/oauth2_credential_exchanger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
/**
* @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 {
async exchange({
authCredential,
authScheme,
}: {
authCredential: AuthCredential;
authScheme?: AuthScheme;
}): Promise<ExchangeResult> {
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<ExchangeResult> {
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<ExchangeResult> {
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 (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.',
);
}

const body = createOAuth2TokenRequestBody({
grantType: 'authorization_code',
clientId: authCredential.oauth2.clientId,
clientSecret: authCredential.oauth2.clientSecret,
code,
redirectUri: authCredential.oauth2.redirectUri,
codeVerifier: authCredential.oauth2.codeVerifier,
});

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)}`,
);
}
}
106 changes: 106 additions & 0 deletions core/src/auth/oauth2/oauth2_credential_refresher.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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<AuthCredential> {
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;
}
}
}
Loading
Loading