-
Notifications
You must be signed in to change notification settings - Fork 116
feat: oauth support: add oauth2 related classes #225
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
14b0376
feat: oauth support: add oauth2 related classes
kalenkevich 7bbd979
fix docs check
kalenkevich 5514b26
fix pr comments
kalenkevich 528c2c3
update imports in tests
kalenkevich 6ad5480
fix test imports
kalenkevich 9373f2c
add defence for verity oauth2 flow attacks:
kalenkevich d9f608e
fix type doc check
kalenkevich File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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)}`, | ||
| ); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.