diff --git a/deploy/apigee/deploy_apigee.sh b/deploy/apigee/deploy_apigee.sh
index ab439634b..f7bb2049b 100755
--- a/deploy/apigee/deploy_apigee.sh
+++ b/deploy/apigee/deploy_apigee.sh
@@ -109,6 +109,7 @@ function prep_proxies() {
copy_resources "$proxy_name" "policies" "policies"
copy_resources "$proxy_name" "proxy_endpoints" "proxies"
copy_resources "$proxy_name" "target_endpoints" "targets"
+ copy_js_resources "$proxy_name"
cd "$ENV_TMP_DIR"
mv "$proxy_name" apiproxy
# Set a constant modification timestamp on all files so zip archive hash
@@ -120,6 +121,26 @@ function prep_proxies() {
done
}
+# Copies all JavaScript files listed under .jsc in config yaml for the given
+# proxy into apiproxy/resources/jsc/.
+function copy_js_resources() {
+ proxy_name="$1"
+ resources=($(yq eval ".proxies[] | select(.name == \"$proxy_name\") | .jsc // [] | .[]" "$ENV_DATA"))
+ for resource in "${resources[@]}"; do
+ if [[ "$resource" == "" ]]; then
+ continue
+ fi
+ write_dir="$ENV_TMP_DIR/$proxy_name/resources/jsc"
+ mkdir -p "$write_dir"
+ if [[ -f "jsc/$resource.js" ]]; then
+ cp "jsc/$resource.js" "$write_dir/$resource.js"
+ else
+ echo "Not found: jsc/$resource.js"
+ exit 1
+ fi
+ done
+}
+
# Copies all files listed in config yaml for the given proxy and
# resource source directory.
function copy_resources() {
diff --git a/deploy/apigee/envs/nonprod.yaml b/deploy/apigee/envs/nonprod.yaml
index 5bf83f497..88478d13f 100644
--- a/deploy/apigee/envs/nonprod.yaml
+++ b/deploy/apigee/envs/nonprod.yaml
@@ -28,3 +28,35 @@ proxies:
- nl
target_endpoints:
- nl
+ - name: oauth
+ policies:
+ - add-cors
+ - set-southbound-key
+ - rewrite-quota-exceeded-message
+ - oauth-set-config
+ - oauth-script
+ - oauth-verify-client-id
+ - oauth-generate-state2-code
+ - oauth-get-state2-attributes
+ - oauth-delete-state2-code
+ - oauth-call-google-token
+ - oauth-verify-google-id-token
+ - oauth-compute-uid
+ - oauth-generate-auth-code
+ - oauth-decode-basic-auth
+ - oauth-verify-client-form
+ - oauth-get-code-attributes
+ - oauth-generate-access-token
+ - oauth-refresh-access-token
+ - oauth-verify-access-token
+ - oauth-enforce-user-quota
+ - oauth-strip-auth-headers
+ - oauth-raise-error
+ - oauth-rewrite-error
+ - oauth-rewrite-unauthorized-mcp
+ jsc:
+ - oauth
+ proxy_endpoints:
+ - oauth
+ target_endpoints:
+ - oauth
diff --git a/deploy/apigee/jsc/oauth.js b/deploy/apigee/jsc/oauth.js
new file mode 100644
index 000000000..9e0b2a97a
--- /dev/null
+++ b/deploy/apigee/jsc/oauth.js
@@ -0,0 +1,453 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Unified OAuth 2.0 request/response/fault handler for the Apigee oauth proxy.
+(function() {
+ function fail(statusCode, error, description) {
+ context.setVariable('oauth.failed', 'true');
+ context.setVariable('oauth.status_code', String(statusCode));
+ context.setVariable('oauth.error', error);
+ context.setVariable('oauth.error_description', description);
+ }
+
+ function constantTimeEquals(a, b) {
+ if (typeof a !== 'string' || typeof b !== 'string' || a.length === 0 || a.length !== b.length) {
+ return false;
+ }
+ var diff = 0;
+ for (var i = 0; i < a.length; i++) {
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
+ }
+ return diff === 0;
+ }
+
+ function matchesCsvUri(redirectUri, csvValue) {
+ if (!csvValue || String(csvValue).trim() === '') {
+ return false;
+ }
+ var parts = String(csvValue).split(',');
+ for (var i = 0; i < parts.length; i++) {
+ if (parts[i].trim() === redirectUri) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ function isExactRedirectUriAllowed(redirectUri, allowedCsv, singleCallbackUri) {
+ if (!redirectUri) {
+ return false;
+ }
+ return matchesCsvUri(redirectUri, allowedCsv) || matchesCsvUri(redirectUri, singleCallbackUri);
+ }
+
+ function generateCsrfNonce() {
+ var sha256 = crypto.getSHA256();
+ sha256.update([
+ context.getVariable('messageid') || '',
+ context.getVariable('system.uuid') || '',
+ context.getVariable('system.timestamp') || String(Date.now()),
+ context.getVariable('request.queryparam.code_challenge') || '',
+ context.getVariable('request.queryparam.state') || '',
+ context.getVariable('private.oauth.uid_hmac_key') || ''
+ ].join(':'));
+ return sha256.digest64()
+ .replace(/=+$/, '')
+ .replace(/\+/g, '-')
+ .replace(/\//g, '_');
+ }
+
+ function getCookieValue(cookieHeader, name) {
+ if (!cookieHeader) {
+ return null;
+ }
+ var pairs = String(cookieHeader).split(';');
+ for (var i = 0; i < pairs.length; i++) {
+ var pair = pairs[i].trim();
+ var eqIdx = pair.indexOf('=');
+ if (eqIdx > 0) {
+ var k = pair.substring(0, eqIdx).trim();
+ var v = pair.substring(eqIdx + 1).trim();
+ if (k === name) {
+ return v;
+ }
+ }
+ }
+ return null;
+ }
+
+ var flowScope = context.flow || 'PROXY_REQ_FLOW';
+ var flowName = context.getVariable('current.flow.name') || '';
+ var host = context.getVariable('oauth.public_hostname') || '';
+
+ // 1. Proxy Response Flow: construct discovery JSON, 302 redirects, or jwt-bearer check response.
+ if (flowScope === 'PROXY_RESP_FLOW') {
+ if (flowName === 'as-metadata') {
+ context.setVariable('response.status.code', 200);
+ context.setVariable('response.header.Content-Type', 'application/json');
+ context.setVariable('response.header.Cache-Control', 'public, max-age=300');
+ context.setVariable('response.content', JSON.stringify({
+ issuer: 'https://' + host + '/oauth',
+ authorization_endpoint: 'https://' + host + '/oauth/authorize',
+ token_endpoint: 'https://' + host + '/oauth/token',
+ response_types_supported: ['code'],
+ grant_types_supported: [
+ 'authorization_code',
+ 'refresh_token',
+ 'urn:ietf:params:oauth:grant-type:jwt-bearer'
+ ],
+ token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post'],
+ code_challenge_methods_supported: ['S256'],
+ scopes_supported: ['mcp']
+ }));
+ return;
+ }
+
+ if (flowName === 'protected-resource-metadata') {
+ context.setVariable('response.status.code', 200);
+ context.setVariable('response.header.Content-Type', 'application/json');
+ context.setVariable('response.header.Cache-Control', 'public, max-age=300');
+ context.setVariable('response.content', JSON.stringify({
+ resource: 'https://' + host + '/oauth/mcp',
+ authorization_servers: ['https://' + host + '/oauth'],
+ scopes_supported: ['mcp'],
+ bearer_methods_supported: ['header']
+ }));
+ return;
+ }
+
+ if (flowName === 'authorize') {
+ var googleClientId = context.getVariable('private.oauth.google_client_id') || '';
+ var state2Code = context.getVariable('oauthv2authcode.oauth-generate-state2-code.code') || '';
+ var csrfNonce = context.getVariable('oauth.authorize.csrf_nonce') || '';
+ var googleAuthUrl = 'https://accounts.google.com/o/oauth2/v2/auth'
+ + '?client_id=' + encodeURIComponent(googleClientId)
+ + '&redirect_uri=' + encodeURIComponent('https://' + host + '/oauth/callback')
+ + '&response_type=code&scope=openid'
+ + '&state=' + encodeURIComponent(state2Code);
+ context.setVariable('response.status.code', 302);
+ context.setVariable('response.header.Location', googleAuthUrl);
+ context.setVariable('response.header.Set-Cookie', 'dc_oauth_state=' + csrfNonce + '; HttpOnly; Secure; SameSite=Lax; Path=/oauth/callback; Max-Age=600');
+ context.setVariable('response.header.Cache-Control', 'no-store');
+ return;
+ }
+
+ if (flowName === 'callback') {
+ var appRedirectUri = context.getVariable('oauth.callback.redirect_uri') || '';
+ var issuedAuthCode = context.getVariable('oauthv2authcode.oauth-generate-auth-code.code') || '';
+ var encodedState1 = context.getVariable('oauth.callback.encoded_state1') || '';
+ var sep = appRedirectUri.indexOf('?') === -1 ? '?' : '&';
+ var appLocation = appRedirectUri + sep + 'code=' + encodeURIComponent(issuedAuthCode) + '&state=' + encodedState1;
+ context.setVariable('response.status.code', 302);
+ context.setVariable('response.header.Location', appLocation);
+ context.setVariable('response.header.Set-Cookie', 'dc_oauth_state=; HttpOnly; Secure; SameSite=Lax; Path=/oauth/callback; Max-Age=0');
+ context.setVariable('response.header.Cache-Control', 'no-store');
+ return;
+ }
+
+ if (flowName === 'token') {
+ if (context.getVariable('oauth.jwt_bearer.intent') === 'check') {
+ context.setVariable('response.status.code', 200);
+ context.setVariable('response.header.Content-Type', 'application/json');
+ context.setVariable('response.header.Cache-Control', 'no-store');
+ context.setVariable('response.content', JSON.stringify({
+ account_found: true
+ }));
+ }
+ return;
+ }
+ return;
+ }
+
+ // 2. FaultRule execution: map Apigee policy faults to OAuth 2.0 JSON error fields.
+ // Guard against soft faults from continueOnError="true" lookup policies before validation completes.
+ var faultName = context.getVariable('fault.name') || '';
+ var clientFaults = {
+ 'InvalidApiKey': true,
+ 'FailedToResolveAPIKey': true,
+ 'InvalidApiKeyForGivenResource': true,
+ 'DeveloperStatusNotActive': true,
+ 'invalid_client': true,
+ 'InvalidClientIdentifier': true
+ };
+ var isClientFault = Boolean(clientFaults[faultName]) ||
+ (faultName === 'FailedToDecode' && flowName === 'token' && context.getVariable('oauth.token.validated') !== 'true');
+ var inFaultRule = isClientFault ||
+ (flowName === 'callback' && context.getVariable('oauth.callback.step') === 'post_token') ||
+ (flowName === 'token' && context.getVariable('oauth.token.validated') === 'true') ||
+ (flowName !== 'callback' && flowName !== 'token');
+ if (faultName && inFaultRule) {
+ if (isClientFault) {
+ fail(401, 'invalid_client', 'Invalid client_id.');
+ } else {
+ fail(400, 'invalid_grant', 'Invalid, expired, or already used authorization code or state.');
+ }
+ return;
+ }
+
+ // 3. Proxy Request Flow
+ if (flowName === 'authorize') {
+ var clientId = context.getVariable('request.queryparam.client_id') || '';
+ var redirectUri = context.getVariable('request.queryparam.redirect_uri') || '';
+ var responseType = context.getVariable('request.queryparam.response_type') || '';
+ var scope = context.getVariable('request.queryparam.scope');
+ var state1 = context.getVariable('request.queryparam.state') || '';
+ var codeChallenge = context.getVariable('request.queryparam.code_challenge') || '';
+ var codeChallengeMethod = context.getVariable('request.queryparam.code_challenge_method') || '';
+
+ var allowedCsv = context.getVariable('verifyapikey.oauth-verify-client-id.allowed_redirect_uris');
+ var singleCallbackUri = context.getVariable('verifyapikey.oauth-verify-client-id.redirection_uris');
+
+ if (!isExactRedirectUriAllowed(redirectUri, allowedCsv, singleCallbackUri)) {
+ fail(400, 'invalid_request', 'Missing or unauthorized redirect_uri.');
+ return;
+ }
+ if (responseType !== 'code') {
+ fail(400, 'unsupported_response_type', 'Only response_type=code is supported.');
+ return;
+ }
+ if (scope === null || scope === undefined || String(scope).trim() === '') {
+ scope = 'mcp';
+ } else {
+ scope = String(scope).trim();
+ }
+ if (scope !== 'mcp') {
+ fail(400, 'invalid_scope', 'Only scope=mcp is supported.');
+ return;
+ }
+ if (codeChallengeMethod !== 'S256') {
+ fail(400, 'invalid_request', 'PKCE code_challenge_method=S256 is required.');
+ return;
+ }
+ var pkcePattern = /^[A-Za-z0-9\-._~]{43,128}$/;
+ if (!pkcePattern.test(codeChallenge)) {
+ fail(400, 'invalid_request', 'Missing or invalid PKCE code_challenge.');
+ return;
+ }
+
+ var nonce = generateCsrfNonce();
+ context.setVariable('request.formparam.client_id', clientId);
+ context.setVariable('request.queryparam.scope', scope);
+ context.setVariable('oauth.authorize.client_id', clientId);
+ context.setVariable('oauth.authorize.redirect_uri', redirectUri);
+ context.setVariable('oauth.authorize.response_type', 'code');
+ context.setVariable('oauth.authorize.scope', scope);
+ context.setVariable('oauth.authorize.state1', state1);
+ context.setVariable('oauth.authorize.code_challenge', codeChallenge);
+ context.setVariable('oauth.authorize.code_challenge_method', 'S256');
+ context.setVariable('oauth.authorize.csrf_nonce', nonce);
+ context.setVariable('oauth.authorize.issued_at_ms', String(Date.now()));
+ return;
+ }
+
+ if (flowName === 'callback') {
+ var callbackStep = context.getVariable('oauth.callback.step') || '';
+ if (callbackStep === 'post_token') {
+ var rawTokenResp = context.getVariable('googleTokenResponse.content') || '';
+ var idToken = '';
+ try {
+ var parsed = JSON.parse(rawTokenResp);
+ idToken = parsed && parsed.id_token ? String(parsed.id_token) : '';
+ } catch (e) {
+ idToken = '';
+ }
+ if (!idToken) {
+ fail(400, 'invalid_grant', 'Failed to exchange Google authorization code for id_token.');
+ return;
+ }
+ context.setVariable('private.oauth.google_id_token', idToken);
+ // Set default error metadata in case sub claim check fails after VerifyJWT.
+ context.setVariable('oauth.status_code', '400');
+ context.setVariable('oauth.error', 'invalid_grant');
+ context.setVariable('oauth.error_description', 'Invalid Google ID token.');
+ return;
+ }
+
+ var upstreamError = context.getVariable('request.queryparam.error') || '';
+ var googleCode = context.getVariable('request.queryparam.code') || '';
+ var state2 = context.getVariable('request.queryparam.state') || '';
+ if (upstreamError || !googleCode || !state2) {
+ fail(400, 'invalid_grant', 'Missing or rejected authorization parameters.');
+ return;
+ }
+
+ var authStep = context.getVariable('oauthv2authcode.oauth-get-state2-attributes.auth_step') || '';
+ var expectedNonce = context.getVariable('oauthv2authcode.oauth-get-state2-attributes.csrf_nonce') || '';
+ var issuedAtMs = Number(context.getVariable('oauthv2authcode.oauth-get-state2-attributes.issued_at_ms') || '0');
+ var nowMs = Date.now();
+ if (authStep !== 'authorize' || !expectedNonce || !issuedAtMs || (nowMs - issuedAtMs) > 600000) {
+ fail(400, 'invalid_grant', 'Invalid, expired, or already used OAuth state.');
+ return;
+ }
+
+ var cookieHeader = context.getVariable('request.header.cookie') || context.getVariable('request.header.Cookie') || '';
+ var cookieNonce = getCookieValue(cookieHeader, 'dc_oauth_state') || '';
+ if (!constantTimeEquals(cookieNonce, expectedNonce)) {
+ fail(400, 'invalid_request', 'OAuth state cookie mismatch.');
+ return;
+ }
+
+ var cbClientId = context.getVariable('oauthv2authcode.oauth-get-state2-attributes.client_id') || '';
+ var cbRedirectUri = context.getVariable('oauthv2authcode.oauth-get-state2-attributes.redirect_uri') || '';
+ var cbResponseType = context.getVariable('oauthv2authcode.oauth-get-state2-attributes.response_type') || 'code';
+ var cbScope = context.getVariable('oauthv2authcode.oauth-get-state2-attributes.scope') || 'mcp';
+ var cbState1 = context.getVariable('oauthv2authcode.oauth-get-state2-attributes.state1') || '';
+ var cbChallenge = context.getVariable('oauthv2authcode.oauth-get-state2-attributes.code_challenge') || '';
+ var cbChallengeMethod = context.getVariable('oauthv2authcode.oauth-get-state2-attributes.code_challenge_method') || 'S256';
+
+ context.setVariable('oauth.google_code', googleCode);
+ context.setVariable('oauth.state2', state2);
+ context.setVariable('request.formparam.client_id', cbClientId);
+ context.setVariable('request.queryparam.client_id', cbClientId);
+ context.setVariable('request.queryparam.redirect_uri', cbRedirectUri);
+ context.setVariable('request.queryparam.response_type', cbResponseType);
+ context.setVariable('request.queryparam.scope', cbScope);
+ context.setVariable('oauth.callback.redirect_uri', cbRedirectUri);
+ context.setVariable('oauth.callback.state1', cbState1);
+ context.setVariable('oauth.callback.encoded_state1', encodeURIComponent(cbState1));
+ context.setVariable('oauth.callback.code_challenge', cbChallenge);
+ context.setVariable('oauth.callback.code_challenge_method', cbChallengeMethod);
+ context.setVariable('oauth.callback.step', 'post_token');
+ return;
+ }
+
+ if (flowName === 'token') {
+ if (typeof context.removeVariable === 'function') {
+ context.removeVariable('request.header.Authorization');
+ }
+ var expectedSecret = context.getVariable('verifyapikey.oauth-verify-client-form.client_secret') || '';
+ var providedSecret = context.getVariable('request.formparam.client_secret') || '';
+ if (!constantTimeEquals(providedSecret, expectedSecret)) {
+ fail(401, 'invalid_client', 'Invalid client credentials.');
+ return;
+ }
+
+ var reqScope = context.getVariable('request.formparam.scope');
+ if (reqScope !== null && reqScope !== undefined && String(reqScope).trim() !== '') {
+ if (String(reqScope).trim() !== 'mcp') {
+ fail(400, 'invalid_scope', 'Only scope=mcp is supported.');
+ return;
+ }
+ }
+ if (typeof context.removeVariable === 'function') {
+ context.removeVariable('request.formparam.scope');
+ }
+
+ var grantType = context.getVariable('request.formparam.grant_type') || '';
+ var jwtBearerGrant = 'urn:ietf:params:oauth:grant-type:jwt-bearer';
+ if (grantType !== 'authorization_code' && grantType !== 'refresh_token' && grantType !== jwtBearerGrant) {
+ fail(400, 'unsupported_grant_type', 'Only authorization_code, refresh_token, and urn:ietf:params:oauth:grant-type:jwt-bearer grant types are supported.');
+ return;
+ }
+ if (grantType === 'refresh_token') {
+ var refreshToken = context.getVariable('request.formparam.refresh_token') || '';
+ if (!refreshToken) {
+ fail(400, 'invalid_grant', 'Missing refresh_token parameter.');
+ return;
+ }
+ context.setVariable('oauth.token.validated', 'true');
+ return;
+ }
+
+ if (grantType === jwtBearerGrant) {
+ var intent = context.getVariable('request.formparam.intent') || '';
+ if (intent !== 'check' && intent !== 'get' && intent !== 'create') {
+ fail(400, 'invalid_request', 'Invalid or missing intent parameter; must be check, get, or create.');
+ return;
+ }
+ var assertion = context.getVariable('request.formparam.assertion') || '';
+ if (!assertion) {
+ fail(400, 'invalid_grant', 'Missing assertion parameter.');
+ return;
+ }
+ var tokenAllowedCsv = context.getVariable('verifyapikey.oauth-verify-client-form.allowed_redirect_uris') || '';
+ var tokenSingleCallbackUri = context.getVariable('verifyapikey.oauth-verify-client-form.redirection_uris') || '';
+ var syntheticRedirectUri = '';
+ if (String(tokenSingleCallbackUri).trim() !== '') {
+ syntheticRedirectUri = String(tokenSingleCallbackUri).split(',')[0].trim();
+ } else if (String(tokenAllowedCsv).trim() !== '') {
+ syntheticRedirectUri = String(tokenAllowedCsv).split(',')[0].trim();
+ }
+ if (!syntheticRedirectUri) {
+ syntheticRedirectUri = 'https://' + host + '/oauth/callback';
+ }
+
+ var tokenClientId = context.getVariable('request.formparam.client_id') || '';
+ context.setVariable('private.oauth.google_id_token', assertion);
+ context.setVariable('oauth.jwt_bearer.intent', intent);
+ if (intent !== 'check') {
+ context.setVariable('request.queryparam.client_id', tokenClientId);
+ context.setVariable('request.queryparam.response_type', 'code');
+ context.setVariable('request.queryparam.redirect_uri', syntheticRedirectUri);
+ context.setVariable('request.queryparam.scope', 'mcp');
+ context.setVariable('oauth.callback.redirect_uri', syntheticRedirectUri);
+ context.setVariable('oauth.callback.code_challenge', '');
+ context.setVariable('oauth.callback.code_challenge_method', 'S256');
+ context.setVariable('oauth.grant_type', 'authorization_code');
+ context.setVariable('oauth.redirect_uri', syntheticRedirectUri);
+ }
+ // Set default error metadata in case sub claim check fails after VerifyJWT.
+ context.setVariable('oauth.status_code', '400');
+ context.setVariable('oauth.error', 'invalid_grant');
+ context.setVariable('oauth.error_description', 'Invalid Google ID token assertion.');
+ context.setVariable('oauth.token.validated', 'true');
+ return;
+ }
+
+ var codeAuthStep = context.getVariable('oauthv2authcode.oauth-get-code-attributes.auth_step') || '';
+ var uid = context.getVariable('oauthv2authcode.oauth-get-code-attributes.uid') || '';
+ if (codeAuthStep !== 'callback' || !uid) {
+ fail(400, 'invalid_grant', 'Invalid authorization code.');
+ return;
+ }
+
+ var expectedRedirectUri = context.getVariable('oauthv2authcode.oauth-get-code-attributes.redirect_uri') || '';
+ var providedRedirectUri = context.getVariable('request.formparam.redirect_uri') || '';
+ if (!expectedRedirectUri || providedRedirectUri !== expectedRedirectUri) {
+ fail(400, 'invalid_grant', 'Mismatched redirect_uri.');
+ return;
+ }
+
+ var storedChallenge = context.getVariable('oauthv2authcode.oauth-get-code-attributes.code_challenge') || '';
+ var storedMethod = context.getVariable('oauthv2authcode.oauth-get-code-attributes.code_challenge_method') || '';
+ var codeVerifier = context.getVariable('request.formparam.code_verifier') || '';
+ var verifierPattern = /^[A-Za-z0-9\-._~]{43,128}$/;
+ if (storedMethod !== 'S256' || !storedChallenge || !verifierPattern.test(codeVerifier)) {
+ fail(400, 'invalid_grant', 'Missing or invalid PKCE code_verifier.');
+ return;
+ }
+
+ var sha256 = crypto.getSHA256();
+ sha256.update(codeVerifier);
+ var computedChallenge = sha256.digest64()
+ .replace(/=+$/, '')
+ .replace(/\+/g, '-')
+ .replace(/\//g, '_');
+
+ if (!constantTimeEquals(computedChallenge, storedChallenge)) {
+ fail(400, 'invalid_grant', 'PKCE verification failed.');
+ return;
+ }
+ context.setVariable('oauth.grant_type', 'authorization_code');
+ context.setVariable('oauth.redirect_uri', providedRedirectUri);
+ context.setVariable('oauth.uid', uid);
+ context.setVariable('oauthv2authcode.oauth-generate-auth-code.code', context.getVariable('request.formparam.code') || '');
+ context.setVariable('oauth.token.validated', 'true');
+ return;
+ }
+
+ if (flowName === 'not-found') {
+ fail(404, 'not_found', 'The requested OAuth endpoint or resource does not exist.');
+ }
+})();
diff --git a/deploy/apigee/policies/oauth-call-google-token.xml b/deploy/apigee/policies/oauth-call-google-token.xml
new file mode 100644
index 000000000..f7bac9a11
--- /dev/null
+++ b/deploy/apigee/policies/oauth-call-google-token.xml
@@ -0,0 +1,25 @@
+
+
+ oauth-call-google-token
+
+
+ POST
+
+ {oauth.google_code}
+ {private.oauth.google_client_id}
+ {private.oauth.google_client_secret}
+ https://{oauth.public_hostname}/oauth/callback
+ authorization_code
+
+
+ false
+
+ googleTokenResponse
+ 10000
+
+ https://oauth2.googleapis.com/token
+
+ true
+
+
+
diff --git a/deploy/apigee/policies/oauth-compute-uid.xml b/deploy/apigee/policies/oauth-compute-uid.xml
new file mode 100644
index 000000000..0061729a6
--- /dev/null
+++ b/deploy/apigee/policies/oauth-compute-uid.xml
@@ -0,0 +1,10 @@
+
+
+ oauth-compute-uid
+
+ oauth.uid
+ {hmacSha256(private.oauth.uid_hmac_key,jwt.oauth-verify-google-id-token.decoded.claim.sub,'utf-8','base16')}
+
+ false
+
+
diff --git a/deploy/apigee/policies/oauth-decode-basic-auth.xml b/deploy/apigee/policies/oauth-decode-basic-auth.xml
new file mode 100644
index 000000000..a80494d55
--- /dev/null
+++ b/deploy/apigee/policies/oauth-decode-basic-auth.xml
@@ -0,0 +1,9 @@
+
+
+ oauth-decode-basic-auth
+ Decode
+ false
+
+
+ request.header.Authorization
+
diff --git a/deploy/apigee/policies/oauth-delete-state2-code.xml b/deploy/apigee/policies/oauth-delete-state2-code.xml
new file mode 100644
index 000000000..21246e8f1
--- /dev/null
+++ b/deploy/apigee/policies/oauth-delete-state2-code.xml
@@ -0,0 +1,5 @@
+
+
+ oauth-delete-state2-code
+
+
diff --git a/deploy/apigee/policies/oauth-enforce-user-quota.template.xml b/deploy/apigee/policies/oauth-enforce-user-quota.template.xml
new file mode 100644
index 000000000..2bae34ab7
--- /dev/null
+++ b/deploy/apigee/policies/oauth-enforce-user-quota.template.xml
@@ -0,0 +1,11 @@
+
+
+ oauth-enforce-user-quota
+
+
+ REPLACE_WITH_OAUTH_USER_QUOTA_INTERVAL_MINUTES
+ minute
+ true
+ false
+ Enforce
+
diff --git a/deploy/apigee/policies/oauth-generate-access-token.xml b/deploy/apigee/policies/oauth-generate-access-token.xml
new file mode 100644
index 000000000..0e196521c
--- /dev/null
+++ b/deploy/apigee/policies/oauth-generate-access-token.xml
@@ -0,0 +1,21 @@
+
+
+ oauth-generate-access-token
+ false
+ GenerateAccessToken
+ 3600000
+ 31536000000
+
+ authorization_code
+
+ oauth.grant_type
+ request.formparam.client_id
+ oauthv2authcode.oauth-generate-auth-code.code
+ oauth.redirect_uri
+ oauth.uid
+ true
+
+
+
+
+
diff --git a/deploy/apigee/policies/oauth-generate-auth-code.xml b/deploy/apigee/policies/oauth-generate-auth-code.xml
new file mode 100644
index 000000000..00a4026e0
--- /dev/null
+++ b/deploy/apigee/policies/oauth-generate-auth-code.xml
@@ -0,0 +1,19 @@
+
+
+ oauth-generate-auth-code
+ false
+ GenerateAuthorizationCode
+ 300000
+
+ request.formparam.client_id
+ request.queryparam.redirect_uri
+ request.queryparam.response_type
+ request.queryparam.scope
+
+ callback
+
+
+
+
+
+
diff --git a/deploy/apigee/policies/oauth-generate-state2-code.xml b/deploy/apigee/policies/oauth-generate-state2-code.xml
new file mode 100644
index 000000000..e2e314ae4
--- /dev/null
+++ b/deploy/apigee/policies/oauth-generate-state2-code.xml
@@ -0,0 +1,24 @@
+
+
+ oauth-generate-state2-code
+ false
+ GenerateAuthorizationCode
+ 600000
+
+ request.formparam.client_id
+ request.queryparam.redirect_uri
+ request.queryparam.response_type
+ request.queryparam.scope
+
+ authorize
+
+
+
+
+
+
+
+
+
+
+
diff --git a/deploy/apigee/policies/oauth-get-code-attributes.xml b/deploy/apigee/policies/oauth-get-code-attributes.xml
new file mode 100644
index 000000000..b3b9ff975
--- /dev/null
+++ b/deploy/apigee/policies/oauth-get-code-attributes.xml
@@ -0,0 +1,5 @@
+
+
+ oauth-get-code-attributes
+
+
diff --git a/deploy/apigee/policies/oauth-get-state2-attributes.xml b/deploy/apigee/policies/oauth-get-state2-attributes.xml
new file mode 100644
index 000000000..cad5f1514
--- /dev/null
+++ b/deploy/apigee/policies/oauth-get-state2-attributes.xml
@@ -0,0 +1,5 @@
+
+
+ oauth-get-state2-attributes
+
+
diff --git a/deploy/apigee/policies/oauth-raise-error.xml b/deploy/apigee/policies/oauth-raise-error.xml
new file mode 100644
index 000000000..d966a6b70
--- /dev/null
+++ b/deploy/apigee/policies/oauth-raise-error.xml
@@ -0,0 +1,14 @@
+
+
+ oauth-raise-error
+
+
+
+
+
+ {"error": "{oauth.error}", "error_description": "{oauth.error_description}"}
+ {oauth.status_code}
+
+
+ true
+
diff --git a/deploy/apigee/policies/oauth-refresh-access-token.xml b/deploy/apigee/policies/oauth-refresh-access-token.xml
new file mode 100644
index 000000000..cc7538d18
--- /dev/null
+++ b/deploy/apigee/policies/oauth-refresh-access-token.xml
@@ -0,0 +1,14 @@
+
+
+ oauth-refresh-access-token
+ false
+ RefreshAccessToken
+ 3600000
+ 31536000000
+ true
+ request.formparam.refresh_token
+ request.formparam.grant_type
+ request.formparam.client_id
+ true
+
+
diff --git a/deploy/apigee/policies/oauth-rewrite-error.xml b/deploy/apigee/policies/oauth-rewrite-error.xml
new file mode 100644
index 000000000..2df900572
--- /dev/null
+++ b/deploy/apigee/policies/oauth-rewrite-error.xml
@@ -0,0 +1,13 @@
+
+
+ oauth-rewrite-error
+
+
+
+
+ {"error": "{oauth.error}", "error_description": "{oauth.error_description}"}
+ {oauth.status_code}
+
+ true
+
+
diff --git a/deploy/apigee/policies/oauth-rewrite-unauthorized-mcp.template.xml b/deploy/apigee/policies/oauth-rewrite-unauthorized-mcp.template.xml
new file mode 100644
index 000000000..1ae0667f6
--- /dev/null
+++ b/deploy/apigee/policies/oauth-rewrite-unauthorized-mcp.template.xml
@@ -0,0 +1,15 @@
+
+
+ oauth-rewrite-unauthorized-mcp
+
+
+ Bearer resource_metadata="https://REPLACE_WITH_OAUTH_PUBLIC_HOSTNAME/oauth/mcp/.well-known/oauth-protected-resource", scope="mcp"
+
+
+ {"error": "unauthorized", "error_description": "Missing, invalid, or expired OAuth access token."}
+ 401
+ Unauthorized
+
+ true
+
+
diff --git a/deploy/apigee/policies/oauth-script.xml b/deploy/apigee/policies/oauth-script.xml
new file mode 100644
index 000000000..def0044ab
--- /dev/null
+++ b/deploy/apigee/policies/oauth-script.xml
@@ -0,0 +1,5 @@
+
+
+ oauth-script
+ jsc://oauth.js
+
diff --git a/deploy/apigee/policies/oauth-set-config.template.xml b/deploy/apigee/policies/oauth-set-config.template.xml
new file mode 100644
index 000000000..ef1582e14
--- /dev/null
+++ b/deploy/apigee/policies/oauth-set-config.template.xml
@@ -0,0 +1,22 @@
+
+
+ oauth-set-config
+
+ oauth.public_hostname
+ REPLACE_WITH_OAUTH_PUBLIC_HOSTNAME
+
+
+ private.oauth.google_client_id
+ REPLACE_WITH_GOOGLE_OAUTH_CLIENT_ID
+
+
+ private.oauth.google_client_secret
+ REPLACE_WITH_GOOGLE_OAUTH_CLIENT_SECRET
+
+
+ private.oauth.uid_hmac_key
+ REPLACE_WITH_OAUTH_UID_HMAC_KEY
+
+ false
+
+
diff --git a/deploy/apigee/policies/oauth-strip-auth-headers.xml b/deploy/apigee/policies/oauth-strip-auth-headers.xml
new file mode 100644
index 000000000..ffede57e2
--- /dev/null
+++ b/deploy/apigee/policies/oauth-strip-auth-headers.xml
@@ -0,0 +1,14 @@
+
+
+ oauth-strip-auth-headers
+
+
+
+
+
+
+
+
+
+
+
diff --git a/deploy/apigee/policies/oauth-verify-access-token.xml b/deploy/apigee/policies/oauth-verify-access-token.xml
new file mode 100644
index 000000000..4d41827ad
--- /dev/null
+++ b/deploy/apigee/policies/oauth-verify-access-token.xml
@@ -0,0 +1,9 @@
+
+
+ oauth-verify-access-token
+ false
+ VerifyAccessToken
+
+
+
+
diff --git a/deploy/apigee/policies/oauth-verify-client-form.xml b/deploy/apigee/policies/oauth-verify-client-form.xml
new file mode 100644
index 000000000..08c07ec38
--- /dev/null
+++ b/deploy/apigee/policies/oauth-verify-client-form.xml
@@ -0,0 +1,6 @@
+
+
+ oauth-verify-client-form
+
+
+
diff --git a/deploy/apigee/policies/oauth-verify-client-id.xml b/deploy/apigee/policies/oauth-verify-client-id.xml
new file mode 100644
index 000000000..3c0a7b2ee
--- /dev/null
+++ b/deploy/apigee/policies/oauth-verify-client-id.xml
@@ -0,0 +1,6 @@
+
+
+ oauth-verify-client-id
+
+
+
diff --git a/deploy/apigee/policies/oauth-verify-google-id-token.xml b/deploy/apigee/policies/oauth-verify-google-id-token.xml
new file mode 100644
index 000000000..6e3e5574c
--- /dev/null
+++ b/deploy/apigee/policies/oauth-verify-google-id-token.xml
@@ -0,0 +1,12 @@
+
+
+ oauth-verify-google-id-token
+ RS256
+ private.oauth.google_id_token
+
+
+
+ https://accounts.google.com
+
+ 60s
+
diff --git a/deploy/apigee/proxies/oauth.xml b/deploy/apigee/proxies/oauth.xml
new file mode 100644
index 000000000..fe0060abf
--- /dev/null
+++ b/deploy/apigee/proxies/oauth.xml
@@ -0,0 +1,41 @@
+
+
+ Data Commons OAuth & MCP Proxy
+ OAuth 2.0 Authorization Server facade and OAuth-protected MCP proxy
+ /api/oauth
+
+ add-cors
+ set-southbound-key
+ rewrite-quota-exceeded-message
+ oauth-set-config
+ oauth-script
+ oauth-verify-client-id
+ oauth-generate-state2-code
+ oauth-get-state2-attributes
+ oauth-delete-state2-code
+ oauth-call-google-token
+ oauth-verify-google-id-token
+ oauth-compute-uid
+ oauth-generate-auth-code
+ oauth-decode-basic-auth
+ oauth-verify-client-form
+ oauth-get-code-attributes
+ oauth-generate-access-token
+ oauth-refresh-access-token
+ oauth-verify-access-token
+ oauth-enforce-user-quota
+ oauth-strip-auth-headers
+ oauth-raise-error
+ oauth-rewrite-error
+ oauth-rewrite-unauthorized-mcp
+
+
+ oauth
+
+
+ oauth
+
+
+ jsc://oauth.js
+
+
diff --git a/deploy/apigee/proxy_endpoints/oauth.xml b/deploy/apigee/proxy_endpoints/oauth.xml
new file mode 100644
index 000000000..ad5ef2325
--- /dev/null
+++ b/deploy/apigee/proxy_endpoints/oauth.xml
@@ -0,0 +1,292 @@
+
+
+ OAuth 2.0 Authorization Server and OAuth-protected MCP Endpoint
+
+
+
+ oauth-rewrite-unauthorized-mcp
+
+
+ ((proxy.pathsuffix MatchesPath "/mcp") or (proxy.pathsuffix MatchesPath "/mcp/**")) and
+ (
+ (fault.name = "InvalidAccessToken") or
+ (fault.name = "access_token_expired") or
+ (fault.name = "invalid_access_token") or
+ (fault.name = "InsufficientScope") or
+ (fault.name = "InvalidAPICallAsNoApiProductMatchFound")
+ )
+
+
+
+
+ rewrite-quota-exceeded-message
+
+ (fault.name = "QuotaViolation")
+
+
+
+ oauth-script
+
+
+ oauth-rewrite-error
+
+
+ (fault.name = "InvalidApiKey") or
+ (fault.name = "FailedToResolveAPIKey") or
+ (fault.name = "InvalidApiKeyForGivenResource") or
+ (fault.name = "DeveloperStatusNotActive") or
+ (fault.name = "invalid_client") or
+ (fault.name = "InvalidClientIdentifier") or
+ (fault.name = "InvalidAuthorizationCode") or
+ (fault.name = "InvalidRefreshToken") or
+ (fault.name = "refresh_token_expired") or
+ (fault.name = "invalid_refresh_token") or
+ (fault.name = "InvalidScope") or
+ (fault.name = "ExecutionFailed") or
+ (fault.name = "InvalidClaim") or
+ (fault.name = "TokenExpired") or
+ (fault.name = "InvalidToken") or
+ (fault.name = "VerificationException") or
+ (fault.name = "FailedToDecode")
+
+
+
+
+
+
+ add-cors
+
+
+ oauth-set-config
+
+
+
+
+
+
+
+
+
+
+ OAuth 2.0 Authorization Server Metadata
+
+
+
+ oauth-script
+
+
+
+ ((request.verb = "GET") or (request.verb = "HEAD")) and
+ (
+ (proxy.pathsuffix MatchesPath "/.well-known/openid-configuration") or
+ (proxy.pathsuffix MatchesPath "/.well-known/oauth-authorization-server")
+ )
+
+
+
+ OAuth 2.0 Protected Resource Metadata (RFC 9728)
+
+
+
+ oauth-script
+
+
+
+ ((request.verb = "GET") or (request.verb = "HEAD")) and
+ (
+ (proxy.pathsuffix MatchesPath "/.well-known/oauth-protected-resource") or
+ (proxy.pathsuffix MatchesPath "/mcp/.well-known/oauth-protected-resource")
+ )
+
+
+
+ OAuth 2.0 Authorization Endpoint
+
+
+ oauth-verify-client-id
+
+
+ oauth-script
+
+
+ oauth.failed = "true"
+ oauth-raise-error
+
+
+ oauth-generate-state2-code
+
+
+
+
+ oauth-script
+
+
+ (request.verb = "GET") and (proxy.pathsuffix MatchesPath "/authorize")
+
+
+ OAuth 2.0 Callback from Google Sign-In
+
+
+ oauth-get-state2-attributes
+
+
+ oauth-script
+
+
+ oauth.failed = "true"
+ oauth-raise-error
+
+
+ oauth-delete-state2-code
+
+
+ oauth-call-google-token
+
+
+ oauth-script
+
+
+ oauth.failed = "true"
+ oauth-raise-error
+
+
+ oauth-verify-google-id-token
+
+
+
+ (jwt.oauth-verify-google-id-token.decoded.claim.sub = null) or
+ (jwt.oauth-verify-google-id-token.decoded.claim.sub = "")
+
+ oauth-raise-error
+
+
+ oauth-compute-uid
+
+
+ oauth-generate-auth-code
+
+
+
+
+ oauth-script
+
+
+ (request.verb = "GET") and (proxy.pathsuffix MatchesPath "/callback")
+
+
+ OAuth 2.0 Token Endpoint
+
+
+ request.header.Authorization ~~ "^[Bb]asic .+"
+ oauth-decode-basic-auth
+
+
+ oauth-verify-client-form
+
+
+ request.formparam.grant_type = "authorization_code"
+ oauth-get-code-attributes
+
+
+ oauth-script
+
+
+ oauth.failed = "true"
+ oauth-raise-error
+
+
+ request.formparam.grant_type = "urn:ietf:params:oauth:grant-type:jwt-bearer"
+ oauth-verify-google-id-token
+
+
+
+ (request.formparam.grant_type = "urn:ietf:params:oauth:grant-type:jwt-bearer") and
+ (
+ (jwt.oauth-verify-google-id-token.decoded.claim.sub = null) or
+ (jwt.oauth-verify-google-id-token.decoded.claim.sub = "")
+ )
+
+ oauth-raise-error
+
+
+ request.formparam.grant_type = "urn:ietf:params:oauth:grant-type:jwt-bearer"
+ oauth-compute-uid
+
+
+
+ (request.formparam.grant_type = "urn:ietf:params:oauth:grant-type:jwt-bearer") and
+ (oauth.jwt_bearer.intent != "check")
+
+ oauth-generate-auth-code
+
+
+ oauth.grant_type = "authorization_code"
+ oauth-generate-access-token
+
+
+ request.formparam.grant_type = "refresh_token"
+ oauth-refresh-access-token
+
+
+
+
+ oauth.jwt_bearer.intent = "check"
+ oauth-script
+
+
+ (request.verb = "POST") and (proxy.pathsuffix MatchesPath "/token")
+
+
+ OAuth-protected MCP Endpoint
+
+
+ oauth-verify-access-token
+
+
+ oauth-enforce-user-quota
+
+
+
+
+ ((proxy.pathsuffix MatchesPath "/mcp") or (proxy.pathsuffix MatchesPath "/mcp/**")) and
+ !(proxy.pathsuffix MatchesPath "/mcp/.well-known/**") and
+ (request.verb != "OPTIONS")
+
+
+
+ CORS Preflight
+
+
+ request.verb = "OPTIONS"
+
+
+ Reject all other paths under /oauth
+
+
+ oauth-script
+
+
+ oauth-raise-error
+
+
+
+
+
+
+ /api/oauth
+
+ true
+ 86400000
+
+ default
+
+
+
+ ((proxy.pathsuffix MatchesPath "/mcp") or (proxy.pathsuffix MatchesPath "/mcp/**")) and
+ !(proxy.pathsuffix MatchesPath "/mcp/.well-known/**") and
+ (request.verb != "OPTIONS")
+
+ oauth
+
+
+
diff --git a/deploy/apigee/sample.env b/deploy/apigee/sample.env
index 82233d15c..0dc378a45 100644
--- a/deploy/apigee/sample.env
+++ b/deploy/apigee/sample.env
@@ -6,3 +6,9 @@ TRIAL_KEY_QUOTA_REQUESTS_PER_INTERVAL=
TRIAL_KEY_QUOTA_INTERVAL_MINUTES=
API_PORTAL_URL=
DEPLOYMENT_SERVICE_ACCOUNT=
+OAUTH_PUBLIC_HOSTNAME=
+GOOGLE_OAUTH_CLIENT_ID=
+GOOGLE_OAUTH_CLIENT_SECRET=
+OAUTH_UID_HMAC_KEY=
+OAUTH_USER_QUOTA_REQUESTS_PER_INTERVAL=
+OAUTH_USER_QUOTA_INTERVAL_MINUTES=
diff --git a/deploy/apigee/target_endpoints/oauth.template.xml b/deploy/apigee/target_endpoints/oauth.template.xml
new file mode 100644
index 000000000..ecc9eab7f
--- /dev/null
+++ b/deploy/apigee/target_endpoints/oauth.template.xml
@@ -0,0 +1,32 @@
+
+
+ Southbound ESP Target for OAuth-protected MCP endpoint
+
+
+
+
+
+
+
+
+
+ oauth-strip-auth-headers
+
+
+ set-southbound-key
+
+
+
+
+
+
+ true
+
+ 300000
+
+ https://REPLACE_WITH_MIXER_API_ESP_HOSTNAME
+
+ true
+
+
+
diff --git a/deploy/apigee/terraform/nonprod/main.tf b/deploy/apigee/terraform/nonprod/main.tf
index 5d4436473..e140b5547 100644
--- a/deploy/apigee/terraform/nonprod/main.tf
+++ b/deploy/apigee/terraform/nonprod/main.tf
@@ -58,6 +58,12 @@ resource "apigee_proxy" "nl" {
bundle_hash = filebase64sha256(".tmp/nl.zip")
}
+resource "apigee_proxy" "oauth" {
+ name = "oauth"
+ bundle = ".tmp/oauth.zip"
+ bundle_hash = filebase64sha256(".tmp/oauth.zip")
+}
+
resource "apigee_proxy_deployment" "dev-api" {
proxy_name = apigee_proxy.api.name
environment_name = "dev"
@@ -71,6 +77,13 @@ resource "apigee_proxy_deployment" "dev-nl" {
revision = apigee_proxy.nl.revision # Deploy latest
}
+resource "apigee_proxy_deployment" "dev-oauth" {
+ proxy_name = apigee_proxy.oauth.name
+ environment_name = "dev"
+ revision = apigee_proxy.oauth.revision # Deploy latest
+ service_account = var.deployment_service_account
+}
+
resource "apigee_product" "datacommons-api-staging" {
name = "datacommons-api-staging"
display_name = "Data Commons API (Staging)"
@@ -106,3 +119,24 @@ resource "apigee_product" "datacommons-nl-api-staging" {
methods = [] # Accept all methods
}
}
+
+resource "apigee_product" "datacommons-oauth-staging" {
+ name = "datacommons-oauth-staging"
+ display_name = "Data Commons OAuth (Staging)"
+ auto_approval_type = false
+ description = "${var.api_hostname}/oauth"
+ environments = [
+ "dev",
+ ]
+ scopes = [
+ "mcp",
+ ]
+ attributes = {
+ access = "private"
+ }
+ operation {
+ api_source = apigee_proxy.oauth.name
+ path = "/"
+ methods = [] # Accept all methods
+ }
+}