Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,13 @@

import {OpenAPIV3} from 'openapi-types';
import {Context} from '../../../agents/context.js';
import {AuthCredential} from '../../../auth/auth_credential.js';
import {
AuthCredential,
AuthCredentialTypes,
} from '../../../auth/auth_credential.js';
import {AuthScheme, OAuthGrantType} from '../../../auth/auth_schemes.js';
import {AuthConfig} from '../../../auth/auth_tool.js';
import {determineGrantType} from '../../../auth/oauth2/oauth2_credential_exchanger.js';
import {experimental} from '../../../utils/experimental.js';
import {AutoAuthCredentialExchanger} from '../auth/credential_exchangers/auto_auth_credential_exchanger.js';

Expand All @@ -16,6 +21,41 @@ export interface AuthPreparationResult {
authCredential?: AuthCredential;
}

/**
* Whether a token for `credential` can only be minted once the end user has
* signed in.
*
* The two-legged `clientCredentials` flow authenticates the application
* itself, so a configured `{clientId, clientSecret}` is all it needs. The
* `authorizationCode` grant mints a token against a user's consent: until one
* comes back the configured credential authorizes nothing, and handing it to
* the exchanger only raises a missing-authorization-code error.
*
* Narrows `_external_exchange_required` from adk-python's
* `tool_auth_handler.py` to the one grant that needs a human and that
* `OAuth2CredentialExchanger` can finish afterwards. The `implicit` and
* `password` grants also need a human, but neither the exchanger nor the
* `response_type=code` URI `AuthHandler` builds can complete them, so asking
* the user to sign in for those would only replace one dead end with another.
*/
function requiresUserSignIn(
authScheme: AuthScheme,
credential: AuthCredential,
): boolean {
if (
credential.authType !== AuthCredentialTypes.OAUTH2 &&
credential.authType !== AuthCredentialTypes.OPEN_ID_CONNECT
) {
return false;
}

if (credential.oauth2?.accessToken) {
return false;
}

return determineGrantType(authScheme) === OAuthGrantType.AUTHORIZATION_CODE;
}

class ToolContextCredentialStore {
constructor(private readonly context: Context) {}

Expand Down Expand Up @@ -90,9 +130,16 @@ export class ToolAuthHandler {
// the client. Otherwise fall back to the credential the tool was
// configured with: schemes such as `apiKey`, `http` and `serviceAccount`
// need no user interaction, so requesting one would strand the tool in
// `pending` forever.
// `pending` forever. A user-interactive OAuth2 grant is the exception: its
// configured client id and secret cannot authorize anything until the user
// has signed in.
const authResponseCredential = this.context.getAuthResponse(authConfig);
const credential = authResponseCredential ?? this.authCredential;
const configuredCredential =
this.authCredential &&
requiresUserSignIn(this.authScheme, this.authCredential)
? undefined
: this.authCredential;
const credential = authResponseCredential ?? configuredCredential;

if (!credential) {
// No credential to work with, so ask the client for one.
Expand Down
185 changes: 185 additions & 0 deletions core/test/tools/openapi_tool/tool_auth_handler_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,13 @@ import {
AuthCredential,
AuthCredentialTypes,
Context,
createSession,
InvocationContext,
LlmAgent,
PluginManager,
ToolAuthHandler,
} from '@google/adk';
import {OpenAPIV3} from 'openapi-types';
import {describe, expect, it, vi} from 'vitest';
import {State} from '../../../src/sessions/state.js';
import {AutoAuthCredentialExchanger} from '../../../src/tools/openapi_tool/auth/credential_exchangers/auto_auth_credential_exchanger.js';
Expand Down Expand Up @@ -243,4 +248,184 @@ describe('ToolAuthHandler', () => {
);
expect(stored?.http?.credentials.token).toBe('exchanged-token');
});

describe('user-interactive OAuth2 grants', () => {
const FUNCTION_CALL_ID = 'function-call-1';

const CLIENT_CREDENTIAL: AuthCredential = {
authType: AuthCredentialTypes.OAUTH2,
oauth2: {clientId: 'client-id', clientSecret: 'client-secret'},
};

const AUTHORIZATION_CODE_SCHEME: OpenAPIV3.SecuritySchemeObject = {
type: 'oauth2',
flows: {
authorizationCode: {
authorizationUrl: 'https://example.com/auth',
tokenUrl: 'https://example.com/token',
scopes: {},
},
},
};

// A real Context, so `requestCredential` mints the sign-in URI through the
// production AuthHandler and records it on the event actions, and
// `getAuthResponse` reads the client's answer back out of session state.
function createToolContext(state: Record<string, unknown> = {}): Context {
return new Context({
invocationContext: new InvocationContext({
invocationId: 'invocation-1',
agent: new LlmAgent({name: 'test_agent'}),
session: createSession({
id: 'session-1',
appName: 'app',
userId: 'user',
state,
}),
pluginManager: new PluginManager(),
}),
functionCallId: FUNCTION_CALL_ID,
});
}

it('asks the client to sign in for the authorization-code grant', async () => {
const context = createToolContext();

const result = await new ToolAuthHandler(
context,
AUTHORIZATION_CODE_SCHEME,
CLIENT_CREDENTIAL,
).prepareAuthCredentials();

expect(result.state).toBe('pending');
// The exchanger would have returned `exchanged-token`, so an absent
// credential proves the exchanger never ran.
expect(result.authCredential).toBeUndefined();
const requested =
context.eventActions.requestedAuthConfigs[FUNCTION_CALL_ID];
expect(requested?.exchangedAuthCredential?.oauth2?.authUri).toContain(
'client_id=client-id',
);
});

it('exchanges a client-credentials credential without asking the user', async () => {
const context = createToolContext();

const result = await new ToolAuthHandler(
context,
{
type: 'oauth2',
flows: {
clientCredentials: {
tokenUrl: 'https://example.com/token',
scopes: {},
},
},
},
CLIENT_CREDENTIAL,
).prepareAuthCredentials();

expect(result.state).toBe('done');
expect(result.authCredential?.http?.credentials.token).toBe(
'exchanged-token',
);
expect(context.eventActions.requestedAuthConfigs).toEqual({});
});

it('uses a configured OAuth2 credential that already carries an access token', async () => {
const context = createToolContext();

const result = await new ToolAuthHandler(
context,
AUTHORIZATION_CODE_SCHEME,
{
authType: AuthCredentialTypes.OAUTH2,
oauth2: {
...CLIENT_CREDENTIAL.oauth2,
accessToken: 'preprovisioned-token',
},
},
).prepareAuthCredentials();

expect(result.state).toBe('done');
expect(context.eventActions.requestedAuthConfigs).toEqual({});
});

it('uses a configured bearer credential on an authorization-code scheme', async () => {
const context = createToolContext();

const result = await new ToolAuthHandler(
context,
AUTHORIZATION_CODE_SCHEME,
{
authType: AuthCredentialTypes.HTTP,
http: {scheme: 'bearer', credentials: {token: 'static-token'}},
},
).prepareAuthCredentials();

// Only an OAuth2/OIDC credential needs a token minted for it; a bearer
// token the developer already holds authorizes the request as it is.
expect(result.state).toBe('done');
expect(context.eventActions.requestedAuthConfigs).toEqual({});
});

it('exchanges the credential the client returned after signing in', async () => {
// What the client fills in on the second leg: AuthHandler reads the auth
// response from this session state key.
const context = createToolContext({
'temp:default_openapi_key': {
authType: AuthCredentialTypes.OAUTH2,
oauth2: {
...CLIENT_CREDENTIAL.oauth2,
authResponseUri: 'https://example.com/callback?code=abc',
},
},
});

const result = await new ToolAuthHandler(
context,
AUTHORIZATION_CODE_SCHEME,
CLIENT_CREDENTIAL,
).prepareAuthCredentials();

expect(result.state).toBe('done');
expect(result.authCredential?.http?.credentials.token).toBe(
'exchanged-token',
);
const stored = context.state.get<AuthCredential>(
'oauth2_existing_exchanged_credential',
);
expect(stored?.http?.credentials.token).toBe('exchanged-token');
});

it('asks the client to sign in for an OAuth2 credential with no oauth2 field', async () => {
const context = createToolContext();

const prepared = new ToolAuthHandler(context, AUTHORIZATION_CODE_SCHEME, {
authType: AuthCredentialTypes.OAUTH2,
}).prepareAuthCredentials();

// A credential this malformed authorizes nothing, so it takes the
// sign-in path and hits AuthHandler's own validation there. Reading it
// must not raise a TypeError of its own.
await expect(prepared).rejects.toThrowError(
'Auth Scheme oauth2 requires oauth2 in authCredential.',
);
});

it('leaves an unclassifiable oauth2 scheme to the exchanger', async () => {
const context = createToolContext();

const result = await new ToolAuthHandler(
context,
{type: 'oauth2', flows: {}},
CLIENT_CREDENTIAL,
).prepareAuthCredentials();

// Fail open: a scheme whose grant type cannot be determined keeps its
// existing behaviour rather than stranding the tool in `pending`.
expect(result.state).toBe('done');
expect(context.eventActions.requestedAuthConfigs).toEqual({});
});
});
});
Loading