From 1062d4dc8b09eaa28339db91089cd46c5c0594f6 Mon Sep 17 00:00:00 2001 From: Keyur Shah Date: Fri, 2 Oct 2026 08:58:33 -0700 Subject: [PATCH 1/4] Add Apigee OAuth proxy for MCP in nonprod --- deploy/apigee/deploy_apigee.sh | 21 ++ deploy/apigee/envs/nonprod.yaml | 49 +++ deploy/apigee/jsc/oauth-validate-authorize.js | 106 ++++++ deploy/apigee/jsc/oauth-validate-callback.js | 98 ++++++ .../jsc/oauth-verify-client-and-pkce.js | 99 ++++++ .../oauth-build-google-token-request.xml | 16 + .../policies/oauth-call-google-token.xml | 13 + deploy/apigee/policies/oauth-compute-uid.xml | 10 + .../policies/oauth-decode-basic-auth.xml | 9 + .../policies/oauth-delete-state2-code.xml | 5 + .../oauth-enforce-user-quota.template.xml | 11 + .../oauth-extract-google-id-token.xml | 12 + .../policies/oauth-generate-access-token.xml | 21 ++ .../policies/oauth-generate-auth-code.xml | 19 ++ .../policies/oauth-generate-state2-code.xml | 24 ++ .../policies/oauth-get-code-attributes.xml | 5 + .../policies/oauth-get-state2-attributes.xml | 5 + .../policies/oauth-normalize-basic-auth.xml | 17 + .../policies/oauth-raise-bad-request.xml | 15 + .../policies/oauth-raise-invalid-client.xml | 15 + .../policies/oauth-raise-invalid-grant.xml | 15 + .../apigee/policies/oauth-raise-not-found.xml | 12 + .../apigee/policies/oauth-redirect-to-app.xml | 15 + .../policies/oauth-redirect-to-google.xml | 15 + .../policies/oauth-refresh-access-token.xml | 14 + .../oauth-return-as-metadata.template.xml | 25 ++ ...auth-return-resource-metadata.template.xml | 25 ++ .../policies/oauth-return-revoke-ok.xml | 14 + deploy/apigee/policies/oauth-revoke-token.xml | 9 + .../policies/oauth-rewrite-invalid-client.xml | 14 + .../policies/oauth-rewrite-invalid-grant.xml | 14 + .../policies/oauth-rewrite-quota-exceeded.xml | 14 + ...auth-rewrite-unauthorized-mcp.template.xml | 15 + .../policies/oauth-set-config.template.xml | 22 ++ .../policies/oauth-strip-auth-headers.xml | 14 + .../policies/oauth-validate-authorize.xml | 5 + .../policies/oauth-validate-callback.xml | 5 + .../policies/oauth-verify-access-token.xml | 10 + .../policies/oauth-verify-client-and-pkce.xml | 5 + .../policies/oauth-verify-client-form.xml | 6 + .../policies/oauth-verify-client-id.xml | 6 + .../policies/oauth-verify-google-id-token.xml | 11 + deploy/apigee/proxies/oauth.xml | 58 ++++ deploy/apigee/proxy_endpoints/oauth.xml | 312 ++++++++++++++++++ deploy/apigee/sample.env | 6 + .../target_endpoints/oauth.template.xml | 32 ++ deploy/apigee/terraform/nonprod/main.tf | 31 ++ 47 files changed, 1294 insertions(+) create mode 100644 deploy/apigee/jsc/oauth-validate-authorize.js create mode 100644 deploy/apigee/jsc/oauth-validate-callback.js create mode 100644 deploy/apigee/jsc/oauth-verify-client-and-pkce.js create mode 100644 deploy/apigee/policies/oauth-build-google-token-request.xml create mode 100644 deploy/apigee/policies/oauth-call-google-token.xml create mode 100644 deploy/apigee/policies/oauth-compute-uid.xml create mode 100644 deploy/apigee/policies/oauth-decode-basic-auth.xml create mode 100644 deploy/apigee/policies/oauth-delete-state2-code.xml create mode 100644 deploy/apigee/policies/oauth-enforce-user-quota.template.xml create mode 100644 deploy/apigee/policies/oauth-extract-google-id-token.xml create mode 100644 deploy/apigee/policies/oauth-generate-access-token.xml create mode 100644 deploy/apigee/policies/oauth-generate-auth-code.xml create mode 100644 deploy/apigee/policies/oauth-generate-state2-code.xml create mode 100644 deploy/apigee/policies/oauth-get-code-attributes.xml create mode 100644 deploy/apigee/policies/oauth-get-state2-attributes.xml create mode 100644 deploy/apigee/policies/oauth-normalize-basic-auth.xml create mode 100644 deploy/apigee/policies/oauth-raise-bad-request.xml create mode 100644 deploy/apigee/policies/oauth-raise-invalid-client.xml create mode 100644 deploy/apigee/policies/oauth-raise-invalid-grant.xml create mode 100644 deploy/apigee/policies/oauth-raise-not-found.xml create mode 100644 deploy/apigee/policies/oauth-redirect-to-app.xml create mode 100644 deploy/apigee/policies/oauth-redirect-to-google.xml create mode 100644 deploy/apigee/policies/oauth-refresh-access-token.xml create mode 100644 deploy/apigee/policies/oauth-return-as-metadata.template.xml create mode 100644 deploy/apigee/policies/oauth-return-resource-metadata.template.xml create mode 100644 deploy/apigee/policies/oauth-return-revoke-ok.xml create mode 100644 deploy/apigee/policies/oauth-revoke-token.xml create mode 100644 deploy/apigee/policies/oauth-rewrite-invalid-client.xml create mode 100644 deploy/apigee/policies/oauth-rewrite-invalid-grant.xml create mode 100644 deploy/apigee/policies/oauth-rewrite-quota-exceeded.xml create mode 100644 deploy/apigee/policies/oauth-rewrite-unauthorized-mcp.template.xml create mode 100644 deploy/apigee/policies/oauth-set-config.template.xml create mode 100644 deploy/apigee/policies/oauth-strip-auth-headers.xml create mode 100644 deploy/apigee/policies/oauth-validate-authorize.xml create mode 100644 deploy/apigee/policies/oauth-validate-callback.xml create mode 100644 deploy/apigee/policies/oauth-verify-access-token.xml create mode 100644 deploy/apigee/policies/oauth-verify-client-and-pkce.xml create mode 100644 deploy/apigee/policies/oauth-verify-client-form.xml create mode 100644 deploy/apigee/policies/oauth-verify-client-id.xml create mode 100644 deploy/apigee/policies/oauth-verify-google-id-token.xml create mode 100644 deploy/apigee/proxies/oauth.xml create mode 100644 deploy/apigee/proxy_endpoints/oauth.xml create mode 100644 deploy/apigee/target_endpoints/oauth.template.xml diff --git a/deploy/apigee/deploy_apigee.sh b/deploy/apigee/deploy_apigee.sh index ab439634b..e4e187a43 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..c22739e3b 100644 --- a/deploy/apigee/envs/nonprod.yaml +++ b/deploy/apigee/envs/nonprod.yaml @@ -28,3 +28,52 @@ proxies: - nl target_endpoints: - nl + - name: oauth + policies: + - add-cors + - set-southbound-key + - oauth-set-config + - oauth-return-as-metadata + - oauth-return-resource-metadata + - oauth-verify-client-id + - oauth-validate-authorize + - oauth-generate-state2-code + - oauth-redirect-to-google + - oauth-get-state2-attributes + - oauth-validate-callback + - oauth-delete-state2-code + - oauth-build-google-token-request + - oauth-call-google-token + - oauth-extract-google-id-token + - oauth-verify-google-id-token + - oauth-compute-uid + - oauth-generate-auth-code + - oauth-redirect-to-app + - oauth-decode-basic-auth + - oauth-normalize-basic-auth + - oauth-verify-client-form + - oauth-get-code-attributes + - oauth-verify-client-and-pkce + - oauth-generate-access-token + - oauth-refresh-access-token + - oauth-revoke-token + - oauth-return-revoke-ok + - oauth-verify-access-token + - oauth-enforce-user-quota + - oauth-strip-auth-headers + - oauth-raise-bad-request + - oauth-raise-invalid-client + - oauth-raise-invalid-grant + - oauth-raise-not-found + - oauth-rewrite-unauthorized-mcp + - oauth-rewrite-quota-exceeded + - oauth-rewrite-invalid-client + - oauth-rewrite-invalid-grant + jsc: + - oauth-validate-authorize + - oauth-validate-callback + - oauth-verify-client-and-pkce + proxy_endpoints: + - oauth + target_endpoints: + - oauth diff --git a/deploy/apigee/jsc/oauth-validate-authorize.js b/deploy/apigee/jsc/oauth-validate-authorize.js new file mode 100644 index 000000000..420dfc9b6 --- /dev/null +++ b/deploy/apigee/jsc/oauth-validate-authorize.js @@ -0,0 +1,106 @@ +// 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. + +// Validates GET /oauth/authorize query parameters after VerifyAPIKey succeeds +// and prepares inputs for generating the state2 authorization code. +(function() { + function fail(error, description) { + context.setVariable('oauth.error', error); + context.setVariable('oauth.error_description', description); + } + + function isExactRedirectUriAllowed(redirectUri, allowedCsv, singleCallbackUri) { + if (!redirectUri) { + return false; + } + if (allowedCsv && String(allowedCsv).trim() !== '') { + var parts = String(allowedCsv).split(','); + for (var i = 0; i < parts.length; i++) { + if (parts[i].trim() === redirectUri) { + return true; + } + } + return false; + } + if (singleCallbackUri && String(singleCallbackUri).trim() !== '') { + return String(singleCallbackUri).trim() === redirectUri; + } + return false; + } + + function generateCsrfNonce() { + var chars = '0123456789abcdef'; + var out = ''; + for (var i = 0; i < 32; i++) { + out += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return out; + } + + 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('invalid_request', 'Missing or unauthorized redirect_uri.'); + return; + } + + if (responseType !== 'code') { + fail('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('invalid_scope', 'Only scope=mcp is supported.'); + return; + } + + if (codeChallengeMethod !== 'S256') { + fail('invalid_request', 'PKCE code_challenge_method=S256 is required.'); + return; + } + + var pkcePattern = /^[A-Za-z0-9\-._~]{43,128}$/; + if (!pkcePattern.test(codeChallenge)) { + fail('invalid_request', 'Missing or invalid PKCE code_challenge.'); + return; + } + + var csrfNonce = 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', csrfNonce); + context.setVariable('oauth.authorize.issued_at_ms', String(Date.now())); +})(); diff --git a/deploy/apigee/jsc/oauth-validate-callback.js b/deploy/apigee/jsc/oauth-validate-callback.js new file mode 100644 index 000000000..4f1ecf287 --- /dev/null +++ b/deploy/apigee/jsc/oauth-validate-callback.js @@ -0,0 +1,98 @@ +// 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. + +// Validates GET /oauth/callback state2 attributes and login-CSRF cookie, and +// prepares inputs for exchanging the Google code and issuing our auth code. +(function() { + function fail(error, description) { + context.setVariable('oauth.error', error); + context.setVariable('oauth.error_description', description); + } + + 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; + } + + 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; + } + + var googleCode = context.getVariable('request.queryparam.code') || ''; + var state2 = context.getVariable('request.queryparam.state') || ''; + if (!googleCode || !state2) { + fail('invalid_request', 'Missing code or state parameter.'); + 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('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('invalid_request', 'OAuth state cookie mismatch.'); + return; + } + + var clientId = context.getVariable('oauthv2authcode.oauth-get-state2-attributes.client_id') || ''; + var redirectUri = context.getVariable('oauthv2authcode.oauth-get-state2-attributes.redirect_uri') || ''; + var responseType = context.getVariable('oauthv2authcode.oauth-get-state2-attributes.response_type') || 'code'; + var scope = context.getVariable('oauthv2authcode.oauth-get-state2-attributes.scope') || 'mcp'; + var state1 = context.getVariable('oauthv2authcode.oauth-get-state2-attributes.state1') || ''; + var codeChallenge = context.getVariable('oauthv2authcode.oauth-get-state2-attributes.code_challenge') || ''; + var codeChallengeMethod = 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', clientId); + context.setVariable('request.queryparam.client_id', clientId); + context.setVariable('request.queryparam.redirect_uri', redirectUri); + context.setVariable('request.queryparam.response_type', responseType); + context.setVariable('request.queryparam.scope', scope); + + context.setVariable('oauth.callback.redirect_uri', redirectUri); + context.setVariable('oauth.callback.state1', state1); + context.setVariable('oauth.callback.encoded_state1', encodeURIComponent(state1)); + context.setVariable('oauth.callback.code_challenge', codeChallenge); + context.setVariable('oauth.callback.code_challenge_method', codeChallengeMethod); +})(); diff --git a/deploy/apigee/jsc/oauth-verify-client-and-pkce.js b/deploy/apigee/jsc/oauth-verify-client-and-pkce.js new file mode 100644 index 000000000..5085da45e --- /dev/null +++ b/deploy/apigee/jsc/oauth-verify-client-and-pkce.js @@ -0,0 +1,99 @@ +// 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. + +// Verifies client_secret and (for POST /oauth/token authorization_code grants) +// checks auth_step, redirect_uri, and PKCE S256 code_verifier. +(function() { + function fail(error, description) { + 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; + } + + var expectedSecret = context.getVariable('verifyapikey.oauth-verify-client-form.client_secret') || ''; + var providedSecret = context.getVariable('request.formparam.client_secret') || ''; + if (!constantTimeEquals(providedSecret, expectedSecret)) { + context.setVariable('oauth.client_invalid', 'true'); + return; + } + + var pathSuffix = context.getVariable('proxy.pathsuffix') || ''; + if (pathSuffix === '/revoke') { + var tokenToRevoke = context.getVariable('request.formparam.token') || ''; + if (!tokenToRevoke) { + fail('invalid_request', 'Missing token parameter.'); + } + return; + } + + var grantType = context.getVariable('request.formparam.grant_type') || ''; + if (grantType !== 'authorization_code' && grantType !== 'refresh_token') { + fail('unsupported_grant_type', 'Only authorization_code and refresh_token grant types are supported.'); + return; + } + + if (grantType === 'refresh_token') { + var refreshToken = context.getVariable('request.formparam.refresh_token') || ''; + if (!refreshToken) { + fail('invalid_grant', 'Missing refresh_token parameter.'); + } + return; + } + + // grant_type === 'authorization_code' + var authStep = context.getVariable('oauthv2authcode.oauth-get-code-attributes.auth_step') || ''; + var uid = context.getVariable('oauthv2authcode.oauth-get-code-attributes.uid') || ''; + if (authStep !== 'callback' || !uid) { + fail('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('invalid_grant', 'Mismatched redirect_uri.'); + return; + } + + var codeChallenge = context.getVariable('oauthv2authcode.oauth-get-code-attributes.code_challenge') || ''; + var codeChallengeMethod = 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 (codeChallengeMethod !== 'S256' || !codeChallenge || !verifierPattern.test(codeVerifier)) { + fail('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, codeChallenge)) { + fail('invalid_grant', 'PKCE verification failed.'); + return; + } +})(); diff --git a/deploy/apigee/policies/oauth-build-google-token-request.xml b/deploy/apigee/policies/oauth-build-google-token-request.xml new file mode 100644 index 000000000..f099e97e5 --- /dev/null +++ b/deploy/apigee/policies/oauth-build-google-token-request.xml @@ -0,0 +1,16 @@ + + + oauth-build-google-token-request + + POST + + {oauth.google_code} + {private.oauth.google_client_id} + {private.oauth.google_client_secret} + https://{oauth.public_hostname}/oauth/callback + authorization_code + + + false + googleTokenRequest + 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..28d4795e9 --- /dev/null +++ b/deploy/apigee/policies/oauth-call-google-token.xml @@ -0,0 +1,13 @@ + + + oauth-call-google-token + + 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 + + + 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..6e02dba10 --- /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-extract-google-id-token.xml b/deploy/apigee/policies/oauth-extract-google-id-token.xml new file mode 100644 index 000000000..16e54de55 --- /dev/null +++ b/deploy/apigee/policies/oauth-extract-google-id-token.xml @@ -0,0 +1,12 @@ + + + oauth-extract-google-id-token + googleTokenResponse + private.oauth + + + $.id_token + + + true + 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..014a08e1f --- /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 + + request.formparam.grant_type + request.formparam.client_id + request.formparam.code + request.formparam.redirect_uri + oauthv2authcode.oauth-get-code-attributes.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..f287da535 --- /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..d2f86ff81 --- /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-normalize-basic-auth.xml b/deploy/apigee/policies/oauth-normalize-basic-auth.xml new file mode 100644 index 000000000..fcaf8d0cb --- /dev/null +++ b/deploy/apigee/policies/oauth-normalize-basic-auth.xml @@ -0,0 +1,17 @@ + + + oauth-normalize-basic-auth + + +
+ + + + + {oauth.basic.client_id} + {oauth.basic.client_secret} + + + false + + diff --git a/deploy/apigee/policies/oauth-raise-bad-request.xml b/deploy/apigee/policies/oauth-raise-bad-request.xml new file mode 100644 index 000000000..ccc68d3b7 --- /dev/null +++ b/deploy/apigee/policies/oauth-raise-bad-request.xml @@ -0,0 +1,15 @@ + + + oauth-raise-bad-request + + + +
no-store
+
+ {"error": "{oauth.error}", "error_description": "{oauth.error_description}"} + 400 + Bad Request +
+
+ true +
diff --git a/deploy/apigee/policies/oauth-raise-invalid-client.xml b/deploy/apigee/policies/oauth-raise-invalid-client.xml new file mode 100644 index 000000000..c39c91daf --- /dev/null +++ b/deploy/apigee/policies/oauth-raise-invalid-client.xml @@ -0,0 +1,15 @@ + + + oauth-raise-invalid-client + + + +
no-store
+
+ {"error": "invalid_client", "error_description": "Invalid client credentials."} + 401 + Unauthorized +
+
+ true +
diff --git a/deploy/apigee/policies/oauth-raise-invalid-grant.xml b/deploy/apigee/policies/oauth-raise-invalid-grant.xml new file mode 100644 index 000000000..21568ab66 --- /dev/null +++ b/deploy/apigee/policies/oauth-raise-invalid-grant.xml @@ -0,0 +1,15 @@ + + + oauth-raise-invalid-grant + + + +
no-store
+
+ {"error": "invalid_grant", "error_description": "Invalid, expired, or already used authorization code or state."} + 400 + Bad Request +
+
+ true +
diff --git a/deploy/apigee/policies/oauth-raise-not-found.xml b/deploy/apigee/policies/oauth-raise-not-found.xml new file mode 100644 index 000000000..54dcda261 --- /dev/null +++ b/deploy/apigee/policies/oauth-raise-not-found.xml @@ -0,0 +1,12 @@ + + + oauth-raise-not-found + + + {"error": "not_found", "error_description": "The requested OAuth endpoint or resource does not exist."} + 404 + Not Found + + + true + diff --git a/deploy/apigee/policies/oauth-redirect-to-app.xml b/deploy/apigee/policies/oauth-redirect-to-app.xml new file mode 100644 index 000000000..31806dcb0 --- /dev/null +++ b/deploy/apigee/policies/oauth-redirect-to-app.xml @@ -0,0 +1,15 @@ + + + oauth-redirect-to-app + + +
{oauth.callback.redirect_uri}?code={oauthv2authcode.oauth-generate-auth-code.code}&state={oauth.callback.encoded_state1}
+
dc_oauth_state=; HttpOnly; Secure; SameSite=Lax; Path=/oauth/callback; Max-Age=0
+
no-store
+
+ 302 + Found +
+ false + +
diff --git a/deploy/apigee/policies/oauth-redirect-to-google.xml b/deploy/apigee/policies/oauth-redirect-to-google.xml new file mode 100644 index 000000000..572878957 --- /dev/null +++ b/deploy/apigee/policies/oauth-redirect-to-google.xml @@ -0,0 +1,15 @@ + + + oauth-redirect-to-google + + +
https://accounts.google.com/o/oauth2/v2/auth?client_id={private.oauth.google_client_id}&redirect_uri=https://{oauth.public_hostname}/oauth/callback&response_type=code&scope=openid&state={oauthv2authcode.oauth-generate-state2-code.code}
+
dc_oauth_state={oauth.authorize.csrf_nonce}; HttpOnly; Secure; SameSite=Lax; Path=/oauth/callback; Max-Age=600
+
no-store
+
+ 302 + Found +
+ false + +
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-return-as-metadata.template.xml b/deploy/apigee/policies/oauth-return-as-metadata.template.xml new file mode 100644 index 000000000..7043c7c7e --- /dev/null +++ b/deploy/apigee/policies/oauth-return-as-metadata.template.xml @@ -0,0 +1,25 @@ + + + oauth-return-as-metadata + + +
public, max-age=300
+
+ { + "issuer": "https://REPLACE_WITH_OAUTH_PUBLIC_HOSTNAME/oauth", + "authorization_endpoint": "https://REPLACE_WITH_OAUTH_PUBLIC_HOSTNAME/oauth/authorize", + "token_endpoint": "https://REPLACE_WITH_OAUTH_PUBLIC_HOSTNAME/oauth/token", + "revocation_endpoint": "https://REPLACE_WITH_OAUTH_PUBLIC_HOSTNAME/oauth/revoke", + "response_types_supported": ["code"], + "grant_types_supported": ["authorization_code", "refresh_token"], + "token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"], + "revocation_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"], + "code_challenge_methods_supported": ["S256"], + "scopes_supported": ["mcp"] +} + 200 + OK +
+ true + +
diff --git a/deploy/apigee/policies/oauth-return-resource-metadata.template.xml b/deploy/apigee/policies/oauth-return-resource-metadata.template.xml new file mode 100644 index 000000000..c47ef217b --- /dev/null +++ b/deploy/apigee/policies/oauth-return-resource-metadata.template.xml @@ -0,0 +1,25 @@ + + + oauth-return-resource-metadata + + +
public, max-age=300
+
+ { + "resource": "https://REPLACE_WITH_OAUTH_PUBLIC_HOSTNAME/oauth/mcp", + "authorization_servers": [ + "https://REPLACE_WITH_OAUTH_PUBLIC_HOSTNAME/oauth" + ], + "scopes_supported": [ + "mcp" + ], + "bearer_methods_supported": [ + "header" + ] +} + 200 + OK +
+ true + +
diff --git a/deploy/apigee/policies/oauth-return-revoke-ok.xml b/deploy/apigee/policies/oauth-return-revoke-ok.xml new file mode 100644 index 000000000..8ce4f7bdc --- /dev/null +++ b/deploy/apigee/policies/oauth-return-revoke-ok.xml @@ -0,0 +1,14 @@ + + + oauth-return-revoke-ok + + +
no-store
+
+ {} + 200 + OK +
+ true + +
diff --git a/deploy/apigee/policies/oauth-revoke-token.xml b/deploy/apigee/policies/oauth-revoke-token.xml new file mode 100644 index 000000000..e47445490 --- /dev/null +++ b/deploy/apigee/policies/oauth-revoke-token.xml @@ -0,0 +1,9 @@ + + + oauth-revoke-token + InvalidateToken + + request.formparam.token + request.formparam.token + + diff --git a/deploy/apigee/policies/oauth-rewrite-invalid-client.xml b/deploy/apigee/policies/oauth-rewrite-invalid-client.xml new file mode 100644 index 000000000..f488cfe3e --- /dev/null +++ b/deploy/apigee/policies/oauth-rewrite-invalid-client.xml @@ -0,0 +1,14 @@ + + + oauth-rewrite-invalid-client + + +
no-store
+
+ {"error": "invalid_client", "error_description": "Invalid client_id."} + 401 + Unauthorized +
+ true + +
diff --git a/deploy/apigee/policies/oauth-rewrite-invalid-grant.xml b/deploy/apigee/policies/oauth-rewrite-invalid-grant.xml new file mode 100644 index 000000000..b04841a56 --- /dev/null +++ b/deploy/apigee/policies/oauth-rewrite-invalid-grant.xml @@ -0,0 +1,14 @@ + + + oauth-rewrite-invalid-grant + + +
no-store
+
+ {"error": "invalid_grant", "error_description": "Invalid, expired, or already used authorization code or state."} + 400 + Bad Request +
+ true + +
diff --git a/deploy/apigee/policies/oauth-rewrite-quota-exceeded.xml b/deploy/apigee/policies/oauth-rewrite-quota-exceeded.xml new file mode 100644 index 000000000..5a63ce660 --- /dev/null +++ b/deploy/apigee/policies/oauth-rewrite-quota-exceeded.xml @@ -0,0 +1,14 @@ + + + oauth-rewrite-quota-exceeded + + +
no-store
+
+ {"error": "rate_limit_exceeded", "error_description": "Per-user OAuth quota limit exceeded. Please retry later.", "code": 429} + 429 + Too Many Requests +
+ 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"
+
no-store
+
+ {"error": "unauthorized", "error_description": "Missing, invalid, or expired OAuth access token."} + 401 + Unauthorized +
+ true + +
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-validate-authorize.xml b/deploy/apigee/policies/oauth-validate-authorize.xml new file mode 100644 index 000000000..a5edf637c --- /dev/null +++ b/deploy/apigee/policies/oauth-validate-authorize.xml @@ -0,0 +1,5 @@ + + + oauth-validate-authorize + jsc://oauth-validate-authorize.js + diff --git a/deploy/apigee/policies/oauth-validate-callback.xml b/deploy/apigee/policies/oauth-validate-callback.xml new file mode 100644 index 000000000..aa28f52d2 --- /dev/null +++ b/deploy/apigee/policies/oauth-validate-callback.xml @@ -0,0 +1,5 @@ + + + oauth-validate-callback + jsc://oauth-validate-callback.js + 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..7f521d98f --- /dev/null +++ b/deploy/apigee/policies/oauth-verify-access-token.xml @@ -0,0 +1,10 @@ + + + oauth-verify-access-token + false + VerifyAccessToken + mcp + + + + diff --git a/deploy/apigee/policies/oauth-verify-client-and-pkce.xml b/deploy/apigee/policies/oauth-verify-client-and-pkce.xml new file mode 100644 index 000000000..8b6f5527d --- /dev/null +++ b/deploy/apigee/policies/oauth-verify-client-and-pkce.xml @@ -0,0 +1,5 @@ + + + oauth-verify-client-and-pkce + jsc://oauth-verify-client-and-pkce.js + 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..553139e75 --- /dev/null +++ b/deploy/apigee/policies/oauth-verify-google-id-token.xml @@ -0,0 +1,11 @@ + + + oauth-verify-google-id-token + RS256 + private.oauth.google_id_token + + + + https://accounts.google.com + + diff --git a/deploy/apigee/proxies/oauth.xml b/deploy/apigee/proxies/oauth.xml new file mode 100644 index 000000000..4f9d90441 --- /dev/null +++ b/deploy/apigee/proxies/oauth.xml @@ -0,0 +1,58 @@ + + + Data Commons OAuth & MCP Proxy + OAuth 2.0 Authorization Server facade and OAuth-protected MCP proxy + /api/oauth + + add-cors + set-southbound-key + oauth-set-config + oauth-return-as-metadata + oauth-return-resource-metadata + oauth-verify-client-id + oauth-validate-authorize + oauth-generate-state2-code + oauth-redirect-to-google + oauth-get-state2-attributes + oauth-validate-callback + oauth-delete-state2-code + oauth-build-google-token-request + oauth-call-google-token + oauth-extract-google-id-token + oauth-verify-google-id-token + oauth-compute-uid + oauth-generate-auth-code + oauth-redirect-to-app + oauth-decode-basic-auth + oauth-normalize-basic-auth + oauth-verify-client-form + oauth-get-code-attributes + oauth-verify-client-and-pkce + oauth-generate-access-token + oauth-refresh-access-token + oauth-revoke-token + oauth-return-revoke-ok + oauth-verify-access-token + oauth-enforce-user-quota + oauth-strip-auth-headers + oauth-raise-bad-request + oauth-raise-invalid-client + oauth-raise-invalid-grant + oauth-raise-not-found + oauth-rewrite-unauthorized-mcp + oauth-rewrite-quota-exceeded + oauth-rewrite-invalid-client + oauth-rewrite-invalid-grant + + + oauth + + + oauth + + + jsc://oauth-validate-authorize.js + jsc://oauth-validate-callback.js + jsc://oauth-verify-client-and-pkce.js + + diff --git a/deploy/apigee/proxy_endpoints/oauth.xml b/deploy/apigee/proxy_endpoints/oauth.xml new file mode 100644 index 000000000..8a944148c --- /dev/null +++ b/deploy/apigee/proxy_endpoints/oauth.xml @@ -0,0 +1,312 @@ + + + OAuth 2.0 Authorization Server and OAuth-protected MCP Endpoint + + + + oauth-rewrite-invalid-client + + + (fault.name = "InvalidApiKey") or + (fault.name = "FailedToResolveAPIKey") or + (fault.name = "InvalidApiKeyForGivenResource") or + (fault.name = "DeveloperStatusNotActive") or + (fault.name = "invalid_client") or + (fault.name = "InvalidClientIdentifier") + + + + + 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") + ) + + + + + oauth-rewrite-quota-exceeded + + (fault.name = "QuotaViolation") + + + + oauth-rewrite-invalid-grant + + + (fault.name = "InvalidAuthorizationCode") or + (fault.name = "InvalidRefreshToken") or + (fault.name = "refresh_token_expired") or + (fault.name = "invalid_refresh_token") or + (fault.name = "ExecutionFailed") or + (fault.name = "InvalidClaim") or + (fault.name = "TokenExpired") or + (fault.name = "FailedToDecode") + + + + + + + add-cors + + + oauth-set-config + + + + + + + + + + + OAuth 2.0 Authorization Server Metadata + + + + oauth-return-as-metadata + + + + ((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-return-resource-metadata + + + + ((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-validate-authorize + + + (oauth.error != null) and (oauth.error != "") + oauth-raise-bad-request + + + oauth-generate-state2-code + + + + + oauth-redirect-to-google + + + (request.verb = "GET") and (proxy.pathsuffix MatchesPath "/authorize") + + + OAuth 2.0 Callback from Google Sign-In + + + + (request.queryparam.error != null) or + (request.queryparam.state = null) or + (request.queryparam.state = "") or + (request.queryparam.code = null) or + (request.queryparam.code = "") + + oauth-raise-invalid-grant + + + oauth-get-state2-attributes + + + oauth-validate-callback + + + (oauth.error != null) and (oauth.error != "") + oauth-raise-bad-request + + + oauth-delete-state2-code + + + oauth-build-google-token-request + + + oauth-call-google-token + + + oauth-extract-google-id-token + + + + (private.oauth.google_id_token = null) or + (private.oauth.google_id_token = "") + + oauth-raise-invalid-grant + + + 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-invalid-grant + + + oauth-compute-uid + + + oauth-generate-auth-code + + + + + oauth-redirect-to-app + + + (request.verb = "GET") and (proxy.pathsuffix MatchesPath "/callback") + + + OAuth 2.0 Token Endpoint + + + request.header.Authorization ~~ "^[Bb]asic .+" + oauth-decode-basic-auth + + + (oauth.basic.client_id != null) and (oauth.basic.client_id != "") + oauth-normalize-basic-auth + + + oauth-verify-client-form + + + request.formparam.grant_type = "authorization_code" + oauth-get-code-attributes + + + oauth-verify-client-and-pkce + + + oauth.client_invalid = "true" + oauth-raise-invalid-client + + + (oauth.error != null) and (oauth.error != "") + oauth-raise-bad-request + + + request.formparam.grant_type = "authorization_code" + oauth-generate-access-token + + + request.formparam.grant_type = "refresh_token" + oauth-refresh-access-token + + + + (request.verb = "POST") and (proxy.pathsuffix MatchesPath "/token") + + + OAuth 2.0 Token Revocation Endpoint (RFC 7009) + + + request.header.Authorization ~~ "^[Bb]asic .+" + oauth-decode-basic-auth + + + (oauth.basic.client_id != null) and (oauth.basic.client_id != "") + oauth-normalize-basic-auth + + + oauth-verify-client-form + + + oauth-verify-client-and-pkce + + + oauth.client_invalid = "true" + oauth-raise-invalid-client + + + (oauth.error != null) and (oauth.error != "") + oauth-raise-bad-request + + + oauth-revoke-token + + + + + oauth-return-revoke-ok + + + (request.verb = "POST") and (proxy.pathsuffix MatchesPath "/revoke") + + + 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/**") + + + + Reject all other paths under /oauth + + + oauth-raise-not-found + + + + + + + /api/oauth + + true + 86400000 + + default + + + + ((proxy.pathsuffix MatchesPath "/mcp") or (proxy.pathsuffix MatchesPath "/mcp/**")) and + !(proxy.pathsuffix MatchesPath "/mcp/.well-known/**") + + 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..11321ece2 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,21 @@ 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", + ] + attributes = { + access = "private" + } + operation { + api_source = apigee_proxy.oauth.name + path = "/" + methods = [] # Accept all methods + } +} From e58c873f85d598b7756689949905637d42fdac2f Mon Sep 17 00:00:00 2001 From: Keyur Shah Date: Fri, 2 Oct 2026 09:44:51 -0700 Subject: [PATCH 2/4] Consolidate Apigee OAuth policies and address review feedback --- deploy/apigee/deploy_apigee.sh | 2 +- deploy/apigee/envs/nonprod.yaml | 27 +- deploy/apigee/jsc/oauth-validate-authorize.js | 106 ----- deploy/apigee/jsc/oauth-validate-callback.js | 98 ----- .../jsc/oauth-verify-client-and-pkce.js | 99 ----- deploy/apigee/jsc/oauth.js | 363 ++++++++++++++++++ .../oauth-build-google-token-request.xml | 16 - .../policies/oauth-call-google-token.xml | 14 +- .../policies/oauth-decode-basic-auth.xml | 6 +- .../oauth-extract-google-id-token.xml | 12 - .../policies/oauth-get-code-attributes.xml | 2 +- .../policies/oauth-get-state2-attributes.xml | 2 +- .../policies/oauth-normalize-basic-auth.xml | 17 - ...-bad-request.xml => oauth-raise-error.xml} | 7 +- .../policies/oauth-raise-invalid-client.xml | 15 - .../policies/oauth-raise-invalid-grant.xml | 15 - .../apigee/policies/oauth-raise-not-found.xml | 12 - .../apigee/policies/oauth-redirect-to-app.xml | 15 - .../policies/oauth-redirect-to-google.xml | 15 - .../oauth-return-as-metadata.template.xml | 25 -- ...auth-return-resource-metadata.template.xml | 25 -- deploy/apigee/policies/oauth-revoke-token.xml | 9 - ...-revoke-ok.xml => oauth-rewrite-error.xml} | 9 +- .../policies/oauth-rewrite-invalid-client.xml | 14 - .../policies/oauth-rewrite-invalid-grant.xml | 14 - .../policies/oauth-rewrite-quota-exceeded.xml | 14 - deploy/apigee/policies/oauth-script.xml | 5 + .../policies/oauth-validate-authorize.xml | 5 - .../policies/oauth-validate-callback.xml | 5 - .../policies/oauth-verify-client-and-pkce.xml | 5 - .../policies/oauth-verify-google-id-token.xml | 1 + deploy/apigee/proxies/oauth.xml | 27 +- deploy/apigee/proxy_endpoints/oauth.xml | 139 +++---- 33 files changed, 448 insertions(+), 692 deletions(-) delete mode 100644 deploy/apigee/jsc/oauth-validate-authorize.js delete mode 100644 deploy/apigee/jsc/oauth-validate-callback.js delete mode 100644 deploy/apigee/jsc/oauth-verify-client-and-pkce.js create mode 100644 deploy/apigee/jsc/oauth.js delete mode 100644 deploy/apigee/policies/oauth-build-google-token-request.xml delete mode 100644 deploy/apigee/policies/oauth-extract-google-id-token.xml delete mode 100644 deploy/apigee/policies/oauth-normalize-basic-auth.xml rename deploy/apigee/policies/{oauth-raise-bad-request.xml => oauth-raise-error.xml} (75%) delete mode 100644 deploy/apigee/policies/oauth-raise-invalid-client.xml delete mode 100644 deploy/apigee/policies/oauth-raise-invalid-grant.xml delete mode 100644 deploy/apigee/policies/oauth-raise-not-found.xml delete mode 100644 deploy/apigee/policies/oauth-redirect-to-app.xml delete mode 100644 deploy/apigee/policies/oauth-redirect-to-google.xml delete mode 100644 deploy/apigee/policies/oauth-return-as-metadata.template.xml delete mode 100644 deploy/apigee/policies/oauth-return-resource-metadata.template.xml delete mode 100644 deploy/apigee/policies/oauth-revoke-token.xml rename deploy/apigee/policies/{oauth-return-revoke-ok.xml => oauth-rewrite-error.xml} (58%) delete mode 100644 deploy/apigee/policies/oauth-rewrite-invalid-client.xml delete mode 100644 deploy/apigee/policies/oauth-rewrite-invalid-grant.xml delete mode 100644 deploy/apigee/policies/oauth-rewrite-quota-exceeded.xml create mode 100644 deploy/apigee/policies/oauth-script.xml delete mode 100644 deploy/apigee/policies/oauth-validate-authorize.xml delete mode 100644 deploy/apigee/policies/oauth-validate-callback.xml delete mode 100644 deploy/apigee/policies/oauth-verify-client-and-pkce.xml diff --git a/deploy/apigee/deploy_apigee.sh b/deploy/apigee/deploy_apigee.sh index e4e187a43..f7bb2049b 100755 --- a/deploy/apigee/deploy_apigee.sh +++ b/deploy/apigee/deploy_apigee.sh @@ -125,7 +125,7 @@ function prep_proxies() { # proxy into apiproxy/resources/jsc/. function copy_js_resources() { proxy_name="$1" - resources=($(yq eval ".proxies[] | select(.name == \"$proxy_name\") | .jsc[] // \"\"" "$ENV_DATA")) + resources=($(yq eval ".proxies[] | select(.name == \"$proxy_name\") | .jsc // [] | .[]" "$ENV_DATA")) for resource in "${resources[@]}"; do if [[ "$resource" == "" ]]; then continue diff --git a/deploy/apigee/envs/nonprod.yaml b/deploy/apigee/envs/nonprod.yaml index c22739e3b..88478d13f 100644 --- a/deploy/apigee/envs/nonprod.yaml +++ b/deploy/apigee/envs/nonprod.yaml @@ -32,47 +32,30 @@ proxies: policies: - add-cors - set-southbound-key + - rewrite-quota-exceeded-message - oauth-set-config - - oauth-return-as-metadata - - oauth-return-resource-metadata + - oauth-script - oauth-verify-client-id - - oauth-validate-authorize - oauth-generate-state2-code - - oauth-redirect-to-google - oauth-get-state2-attributes - - oauth-validate-callback - oauth-delete-state2-code - - oauth-build-google-token-request - oauth-call-google-token - - oauth-extract-google-id-token - oauth-verify-google-id-token - oauth-compute-uid - oauth-generate-auth-code - - oauth-redirect-to-app - oauth-decode-basic-auth - - oauth-normalize-basic-auth - oauth-verify-client-form - oauth-get-code-attributes - - oauth-verify-client-and-pkce - oauth-generate-access-token - oauth-refresh-access-token - - oauth-revoke-token - - oauth-return-revoke-ok - oauth-verify-access-token - oauth-enforce-user-quota - oauth-strip-auth-headers - - oauth-raise-bad-request - - oauth-raise-invalid-client - - oauth-raise-invalid-grant - - oauth-raise-not-found + - oauth-raise-error + - oauth-rewrite-error - oauth-rewrite-unauthorized-mcp - - oauth-rewrite-quota-exceeded - - oauth-rewrite-invalid-client - - oauth-rewrite-invalid-grant jsc: - - oauth-validate-authorize - - oauth-validate-callback - - oauth-verify-client-and-pkce + - oauth proxy_endpoints: - oauth target_endpoints: diff --git a/deploy/apigee/jsc/oauth-validate-authorize.js b/deploy/apigee/jsc/oauth-validate-authorize.js deleted file mode 100644 index 420dfc9b6..000000000 --- a/deploy/apigee/jsc/oauth-validate-authorize.js +++ /dev/null @@ -1,106 +0,0 @@ -// 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. - -// Validates GET /oauth/authorize query parameters after VerifyAPIKey succeeds -// and prepares inputs for generating the state2 authorization code. -(function() { - function fail(error, description) { - context.setVariable('oauth.error', error); - context.setVariable('oauth.error_description', description); - } - - function isExactRedirectUriAllowed(redirectUri, allowedCsv, singleCallbackUri) { - if (!redirectUri) { - return false; - } - if (allowedCsv && String(allowedCsv).trim() !== '') { - var parts = String(allowedCsv).split(','); - for (var i = 0; i < parts.length; i++) { - if (parts[i].trim() === redirectUri) { - return true; - } - } - return false; - } - if (singleCallbackUri && String(singleCallbackUri).trim() !== '') { - return String(singleCallbackUri).trim() === redirectUri; - } - return false; - } - - function generateCsrfNonce() { - var chars = '0123456789abcdef'; - var out = ''; - for (var i = 0; i < 32; i++) { - out += chars.charAt(Math.floor(Math.random() * chars.length)); - } - return out; - } - - 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('invalid_request', 'Missing or unauthorized redirect_uri.'); - return; - } - - if (responseType !== 'code') { - fail('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('invalid_scope', 'Only scope=mcp is supported.'); - return; - } - - if (codeChallengeMethod !== 'S256') { - fail('invalid_request', 'PKCE code_challenge_method=S256 is required.'); - return; - } - - var pkcePattern = /^[A-Za-z0-9\-._~]{43,128}$/; - if (!pkcePattern.test(codeChallenge)) { - fail('invalid_request', 'Missing or invalid PKCE code_challenge.'); - return; - } - - var csrfNonce = 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', csrfNonce); - context.setVariable('oauth.authorize.issued_at_ms', String(Date.now())); -})(); diff --git a/deploy/apigee/jsc/oauth-validate-callback.js b/deploy/apigee/jsc/oauth-validate-callback.js deleted file mode 100644 index 4f1ecf287..000000000 --- a/deploy/apigee/jsc/oauth-validate-callback.js +++ /dev/null @@ -1,98 +0,0 @@ -// 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. - -// Validates GET /oauth/callback state2 attributes and login-CSRF cookie, and -// prepares inputs for exchanging the Google code and issuing our auth code. -(function() { - function fail(error, description) { - context.setVariable('oauth.error', error); - context.setVariable('oauth.error_description', description); - } - - 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; - } - - 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; - } - - var googleCode = context.getVariable('request.queryparam.code') || ''; - var state2 = context.getVariable('request.queryparam.state') || ''; - if (!googleCode || !state2) { - fail('invalid_request', 'Missing code or state parameter.'); - 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('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('invalid_request', 'OAuth state cookie mismatch.'); - return; - } - - var clientId = context.getVariable('oauthv2authcode.oauth-get-state2-attributes.client_id') || ''; - var redirectUri = context.getVariable('oauthv2authcode.oauth-get-state2-attributes.redirect_uri') || ''; - var responseType = context.getVariable('oauthv2authcode.oauth-get-state2-attributes.response_type') || 'code'; - var scope = context.getVariable('oauthv2authcode.oauth-get-state2-attributes.scope') || 'mcp'; - var state1 = context.getVariable('oauthv2authcode.oauth-get-state2-attributes.state1') || ''; - var codeChallenge = context.getVariable('oauthv2authcode.oauth-get-state2-attributes.code_challenge') || ''; - var codeChallengeMethod = 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', clientId); - context.setVariable('request.queryparam.client_id', clientId); - context.setVariable('request.queryparam.redirect_uri', redirectUri); - context.setVariable('request.queryparam.response_type', responseType); - context.setVariable('request.queryparam.scope', scope); - - context.setVariable('oauth.callback.redirect_uri', redirectUri); - context.setVariable('oauth.callback.state1', state1); - context.setVariable('oauth.callback.encoded_state1', encodeURIComponent(state1)); - context.setVariable('oauth.callback.code_challenge', codeChallenge); - context.setVariable('oauth.callback.code_challenge_method', codeChallengeMethod); -})(); diff --git a/deploy/apigee/jsc/oauth-verify-client-and-pkce.js b/deploy/apigee/jsc/oauth-verify-client-and-pkce.js deleted file mode 100644 index 5085da45e..000000000 --- a/deploy/apigee/jsc/oauth-verify-client-and-pkce.js +++ /dev/null @@ -1,99 +0,0 @@ -// 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. - -// Verifies client_secret and (for POST /oauth/token authorization_code grants) -// checks auth_step, redirect_uri, and PKCE S256 code_verifier. -(function() { - function fail(error, description) { - 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; - } - - var expectedSecret = context.getVariable('verifyapikey.oauth-verify-client-form.client_secret') || ''; - var providedSecret = context.getVariable('request.formparam.client_secret') || ''; - if (!constantTimeEquals(providedSecret, expectedSecret)) { - context.setVariable('oauth.client_invalid', 'true'); - return; - } - - var pathSuffix = context.getVariable('proxy.pathsuffix') || ''; - if (pathSuffix === '/revoke') { - var tokenToRevoke = context.getVariable('request.formparam.token') || ''; - if (!tokenToRevoke) { - fail('invalid_request', 'Missing token parameter.'); - } - return; - } - - var grantType = context.getVariable('request.formparam.grant_type') || ''; - if (grantType !== 'authorization_code' && grantType !== 'refresh_token') { - fail('unsupported_grant_type', 'Only authorization_code and refresh_token grant types are supported.'); - return; - } - - if (grantType === 'refresh_token') { - var refreshToken = context.getVariable('request.formparam.refresh_token') || ''; - if (!refreshToken) { - fail('invalid_grant', 'Missing refresh_token parameter.'); - } - return; - } - - // grant_type === 'authorization_code' - var authStep = context.getVariable('oauthv2authcode.oauth-get-code-attributes.auth_step') || ''; - var uid = context.getVariable('oauthv2authcode.oauth-get-code-attributes.uid') || ''; - if (authStep !== 'callback' || !uid) { - fail('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('invalid_grant', 'Mismatched redirect_uri.'); - return; - } - - var codeChallenge = context.getVariable('oauthv2authcode.oauth-get-code-attributes.code_challenge') || ''; - var codeChallengeMethod = 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 (codeChallengeMethod !== 'S256' || !codeChallenge || !verifierPattern.test(codeVerifier)) { - fail('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, codeChallenge)) { - fail('invalid_grant', 'PKCE verification failed.'); - return; - } -})(); diff --git a/deploy/apigee/jsc/oauth.js b/deploy/apigee/jsc/oauth.js new file mode 100644 index 000000000..6fc465b64 --- /dev/null +++ b/deploy/apigee/jsc/oauth.js @@ -0,0 +1,363 @@ +// 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() { + return String(java.util.UUID.randomUUID().toString().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. 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'); + 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; + } + + // 2. Proxy Response Flow: construct discovery JSON or 302 redirects. + 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'], + 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; + } + 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 grantType = context.getVariable('request.formparam.grant_type') || ''; + if (grantType !== 'authorization_code' && grantType !== 'refresh_token') { + fail(400, 'unsupported_grant_type', 'Only authorization_code and refresh_token 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; + } + + 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.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-build-google-token-request.xml b/deploy/apigee/policies/oauth-build-google-token-request.xml deleted file mode 100644 index f099e97e5..000000000 --- a/deploy/apigee/policies/oauth-build-google-token-request.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - oauth-build-google-token-request - - POST - - {oauth.google_code} - {private.oauth.google_client_id} - {private.oauth.google_client_secret} - https://{oauth.public_hostname}/oauth/callback - authorization_code - - - false - googleTokenRequest - diff --git a/deploy/apigee/policies/oauth-call-google-token.xml b/deploy/apigee/policies/oauth-call-google-token.xml index 28d4795e9..f7bac9a11 100644 --- a/deploy/apigee/policies/oauth-call-google-token.xml +++ b/deploy/apigee/policies/oauth-call-google-token.xml @@ -1,7 +1,19 @@ 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 diff --git a/deploy/apigee/policies/oauth-decode-basic-auth.xml b/deploy/apigee/policies/oauth-decode-basic-auth.xml index 6e02dba10..a80494d55 100644 --- a/deploy/apigee/policies/oauth-decode-basic-auth.xml +++ b/deploy/apigee/policies/oauth-decode-basic-auth.xml @@ -1,9 +1,9 @@ - + oauth-decode-basic-auth Decode false - - + + request.header.Authorization diff --git a/deploy/apigee/policies/oauth-extract-google-id-token.xml b/deploy/apigee/policies/oauth-extract-google-id-token.xml deleted file mode 100644 index 16e54de55..000000000 --- a/deploy/apigee/policies/oauth-extract-google-id-token.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - oauth-extract-google-id-token - googleTokenResponse - private.oauth - - - $.id_token - - - true - diff --git a/deploy/apigee/policies/oauth-get-code-attributes.xml b/deploy/apigee/policies/oauth-get-code-attributes.xml index f287da535..b3b9ff975 100644 --- a/deploy/apigee/policies/oauth-get-code-attributes.xml +++ b/deploy/apigee/policies/oauth-get-code-attributes.xml @@ -1,5 +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 index d2f86ff81..cad5f1514 100644 --- a/deploy/apigee/policies/oauth-get-state2-attributes.xml +++ b/deploy/apigee/policies/oauth-get-state2-attributes.xml @@ -1,5 +1,5 @@ - + oauth-get-state2-attributes diff --git a/deploy/apigee/policies/oauth-normalize-basic-auth.xml b/deploy/apigee/policies/oauth-normalize-basic-auth.xml deleted file mode 100644 index fcaf8d0cb..000000000 --- a/deploy/apigee/policies/oauth-normalize-basic-auth.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - oauth-normalize-basic-auth - - -
- - - - - {oauth.basic.client_id} - {oauth.basic.client_secret} - - - false - - diff --git a/deploy/apigee/policies/oauth-raise-bad-request.xml b/deploy/apigee/policies/oauth-raise-error.xml similarity index 75% rename from deploy/apigee/policies/oauth-raise-bad-request.xml rename to deploy/apigee/policies/oauth-raise-error.xml index ccc68d3b7..d966a6b70 100644 --- a/deploy/apigee/policies/oauth-raise-bad-request.xml +++ b/deploy/apigee/policies/oauth-raise-error.xml @@ -1,14 +1,13 @@ - - oauth-raise-bad-request + + oauth-raise-error
no-store
{"error": "{oauth.error}", "error_description": "{oauth.error_description}"} - 400 - Bad Request + {oauth.status_code}
true diff --git a/deploy/apigee/policies/oauth-raise-invalid-client.xml b/deploy/apigee/policies/oauth-raise-invalid-client.xml deleted file mode 100644 index c39c91daf..000000000 --- a/deploy/apigee/policies/oauth-raise-invalid-client.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - oauth-raise-invalid-client - - - -
no-store
-
- {"error": "invalid_client", "error_description": "Invalid client credentials."} - 401 - Unauthorized -
-
- true -
diff --git a/deploy/apigee/policies/oauth-raise-invalid-grant.xml b/deploy/apigee/policies/oauth-raise-invalid-grant.xml deleted file mode 100644 index 21568ab66..000000000 --- a/deploy/apigee/policies/oauth-raise-invalid-grant.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - oauth-raise-invalid-grant - - - -
no-store
-
- {"error": "invalid_grant", "error_description": "Invalid, expired, or already used authorization code or state."} - 400 - Bad Request -
-
- true -
diff --git a/deploy/apigee/policies/oauth-raise-not-found.xml b/deploy/apigee/policies/oauth-raise-not-found.xml deleted file mode 100644 index 54dcda261..000000000 --- a/deploy/apigee/policies/oauth-raise-not-found.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - oauth-raise-not-found - - - {"error": "not_found", "error_description": "The requested OAuth endpoint or resource does not exist."} - 404 - Not Found - - - true - diff --git a/deploy/apigee/policies/oauth-redirect-to-app.xml b/deploy/apigee/policies/oauth-redirect-to-app.xml deleted file mode 100644 index 31806dcb0..000000000 --- a/deploy/apigee/policies/oauth-redirect-to-app.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - oauth-redirect-to-app - - -
{oauth.callback.redirect_uri}?code={oauthv2authcode.oauth-generate-auth-code.code}&state={oauth.callback.encoded_state1}
-
dc_oauth_state=; HttpOnly; Secure; SameSite=Lax; Path=/oauth/callback; Max-Age=0
-
no-store
-
- 302 - Found -
- false - -
diff --git a/deploy/apigee/policies/oauth-redirect-to-google.xml b/deploy/apigee/policies/oauth-redirect-to-google.xml deleted file mode 100644 index 572878957..000000000 --- a/deploy/apigee/policies/oauth-redirect-to-google.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - oauth-redirect-to-google - - -
https://accounts.google.com/o/oauth2/v2/auth?client_id={private.oauth.google_client_id}&redirect_uri=https://{oauth.public_hostname}/oauth/callback&response_type=code&scope=openid&state={oauthv2authcode.oauth-generate-state2-code.code}
-
dc_oauth_state={oauth.authorize.csrf_nonce}; HttpOnly; Secure; SameSite=Lax; Path=/oauth/callback; Max-Age=600
-
no-store
-
- 302 - Found -
- false - -
diff --git a/deploy/apigee/policies/oauth-return-as-metadata.template.xml b/deploy/apigee/policies/oauth-return-as-metadata.template.xml deleted file mode 100644 index 7043c7c7e..000000000 --- a/deploy/apigee/policies/oauth-return-as-metadata.template.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - oauth-return-as-metadata - - -
public, max-age=300
-
- { - "issuer": "https://REPLACE_WITH_OAUTH_PUBLIC_HOSTNAME/oauth", - "authorization_endpoint": "https://REPLACE_WITH_OAUTH_PUBLIC_HOSTNAME/oauth/authorize", - "token_endpoint": "https://REPLACE_WITH_OAUTH_PUBLIC_HOSTNAME/oauth/token", - "revocation_endpoint": "https://REPLACE_WITH_OAUTH_PUBLIC_HOSTNAME/oauth/revoke", - "response_types_supported": ["code"], - "grant_types_supported": ["authorization_code", "refresh_token"], - "token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"], - "revocation_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"], - "code_challenge_methods_supported": ["S256"], - "scopes_supported": ["mcp"] -} - 200 - OK -
- true - -
diff --git a/deploy/apigee/policies/oauth-return-resource-metadata.template.xml b/deploy/apigee/policies/oauth-return-resource-metadata.template.xml deleted file mode 100644 index c47ef217b..000000000 --- a/deploy/apigee/policies/oauth-return-resource-metadata.template.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - oauth-return-resource-metadata - - -
public, max-age=300
-
- { - "resource": "https://REPLACE_WITH_OAUTH_PUBLIC_HOSTNAME/oauth/mcp", - "authorization_servers": [ - "https://REPLACE_WITH_OAUTH_PUBLIC_HOSTNAME/oauth" - ], - "scopes_supported": [ - "mcp" - ], - "bearer_methods_supported": [ - "header" - ] -} - 200 - OK -
- true - -
diff --git a/deploy/apigee/policies/oauth-revoke-token.xml b/deploy/apigee/policies/oauth-revoke-token.xml deleted file mode 100644 index e47445490..000000000 --- a/deploy/apigee/policies/oauth-revoke-token.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - oauth-revoke-token - InvalidateToken - - request.formparam.token - request.formparam.token - - diff --git a/deploy/apigee/policies/oauth-return-revoke-ok.xml b/deploy/apigee/policies/oauth-rewrite-error.xml similarity index 58% rename from deploy/apigee/policies/oauth-return-revoke-ok.xml rename to deploy/apigee/policies/oauth-rewrite-error.xml index 8ce4f7bdc..2df900572 100644 --- a/deploy/apigee/policies/oauth-return-revoke-ok.xml +++ b/deploy/apigee/policies/oauth-rewrite-error.xml @@ -1,13 +1,12 @@ - - oauth-return-revoke-ok + + oauth-rewrite-error
no-store
- {} - 200 - OK + {"error": "{oauth.error}", "error_description": "{oauth.error_description}"} + {oauth.status_code}
true diff --git a/deploy/apigee/policies/oauth-rewrite-invalid-client.xml b/deploy/apigee/policies/oauth-rewrite-invalid-client.xml deleted file mode 100644 index f488cfe3e..000000000 --- a/deploy/apigee/policies/oauth-rewrite-invalid-client.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - oauth-rewrite-invalid-client - - -
no-store
-
- {"error": "invalid_client", "error_description": "Invalid client_id."} - 401 - Unauthorized -
- true - -
diff --git a/deploy/apigee/policies/oauth-rewrite-invalid-grant.xml b/deploy/apigee/policies/oauth-rewrite-invalid-grant.xml deleted file mode 100644 index b04841a56..000000000 --- a/deploy/apigee/policies/oauth-rewrite-invalid-grant.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - oauth-rewrite-invalid-grant - - -
no-store
-
- {"error": "invalid_grant", "error_description": "Invalid, expired, or already used authorization code or state."} - 400 - Bad Request -
- true - -
diff --git a/deploy/apigee/policies/oauth-rewrite-quota-exceeded.xml b/deploy/apigee/policies/oauth-rewrite-quota-exceeded.xml deleted file mode 100644 index 5a63ce660..000000000 --- a/deploy/apigee/policies/oauth-rewrite-quota-exceeded.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - oauth-rewrite-quota-exceeded - - -
no-store
-
- {"error": "rate_limit_exceeded", "error_description": "Per-user OAuth quota limit exceeded. Please retry later.", "code": 429} - 429 - Too Many Requests -
- 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-validate-authorize.xml b/deploy/apigee/policies/oauth-validate-authorize.xml deleted file mode 100644 index a5edf637c..000000000 --- a/deploy/apigee/policies/oauth-validate-authorize.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - oauth-validate-authorize - jsc://oauth-validate-authorize.js - diff --git a/deploy/apigee/policies/oauth-validate-callback.xml b/deploy/apigee/policies/oauth-validate-callback.xml deleted file mode 100644 index aa28f52d2..000000000 --- a/deploy/apigee/policies/oauth-validate-callback.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - oauth-validate-callback - jsc://oauth-validate-callback.js - diff --git a/deploy/apigee/policies/oauth-verify-client-and-pkce.xml b/deploy/apigee/policies/oauth-verify-client-and-pkce.xml deleted file mode 100644 index 8b6f5527d..000000000 --- a/deploy/apigee/policies/oauth-verify-client-and-pkce.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - oauth-verify-client-and-pkce - jsc://oauth-verify-client-and-pkce.js - diff --git a/deploy/apigee/policies/oauth-verify-google-id-token.xml b/deploy/apigee/policies/oauth-verify-google-id-token.xml index 553139e75..6e3e5574c 100644 --- a/deploy/apigee/policies/oauth-verify-google-id-token.xml +++ b/deploy/apigee/policies/oauth-verify-google-id-token.xml @@ -8,4 +8,5 @@ https://accounts.google.com + 60s diff --git a/deploy/apigee/proxies/oauth.xml b/deploy/apigee/proxies/oauth.xml index 4f9d90441..fe0060abf 100644 --- a/deploy/apigee/proxies/oauth.xml +++ b/deploy/apigee/proxies/oauth.xml @@ -6,43 +6,28 @@ add-cors set-southbound-key + rewrite-quota-exceeded-message oauth-set-config - oauth-return-as-metadata - oauth-return-resource-metadata + oauth-script oauth-verify-client-id - oauth-validate-authorize oauth-generate-state2-code - oauth-redirect-to-google oauth-get-state2-attributes - oauth-validate-callback oauth-delete-state2-code - oauth-build-google-token-request oauth-call-google-token - oauth-extract-google-id-token oauth-verify-google-id-token oauth-compute-uid oauth-generate-auth-code - oauth-redirect-to-app oauth-decode-basic-auth - oauth-normalize-basic-auth oauth-verify-client-form oauth-get-code-attributes - oauth-verify-client-and-pkce oauth-generate-access-token oauth-refresh-access-token - oauth-revoke-token - oauth-return-revoke-ok oauth-verify-access-token oauth-enforce-user-quota oauth-strip-auth-headers - oauth-raise-bad-request - oauth-raise-invalid-client - oauth-raise-invalid-grant - oauth-raise-not-found + oauth-raise-error + oauth-rewrite-error oauth-rewrite-unauthorized-mcp - oauth-rewrite-quota-exceeded - oauth-rewrite-invalid-client - oauth-rewrite-invalid-grant oauth @@ -51,8 +36,6 @@ oauth - jsc://oauth-validate-authorize.js - jsc://oauth-validate-callback.js - jsc://oauth-verify-client-and-pkce.js + jsc://oauth.js diff --git a/deploy/apigee/proxy_endpoints/oauth.xml b/deploy/apigee/proxy_endpoints/oauth.xml index 8a944148c..1b8614145 100644 --- a/deploy/apigee/proxy_endpoints/oauth.xml +++ b/deploy/apigee/proxy_endpoints/oauth.xml @@ -2,19 +2,6 @@ OAuth 2.0 Authorization Server and OAuth-protected MCP Endpoint - - - oauth-rewrite-invalid-client - - - (fault.name = "InvalidApiKey") or - (fault.name = "FailedToResolveAPIKey") or - (fault.name = "InvalidApiKeyForGivenResource") or - (fault.name = "DeveloperStatusNotActive") or - (fault.name = "invalid_client") or - (fault.name = "InvalidClientIdentifier") - - oauth-rewrite-unauthorized-mcp @@ -32,15 +19,24 @@ - oauth-rewrite-quota-exceeded + rewrite-quota-exceeded-message (fault.name = "QuotaViolation") - + + + oauth-script + - oauth-rewrite-invalid-grant + 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 @@ -73,7 +69,7 @@ - oauth-return-as-metadata + oauth-script @@ -89,7 +85,7 @@ - oauth-return-resource-metadata + oauth-script @@ -107,11 +103,11 @@ oauth-verify-client-id - oauth-validate-authorize + oauth-script - (oauth.error != null) and (oauth.error != "") - oauth-raise-bad-request + oauth.failed = "true" + oauth-raise-error oauth-generate-state2-code @@ -119,7 +115,7 @@ - oauth-redirect-to-google + oauth-script (request.verb = "GET") and (proxy.pathsuffix MatchesPath "/authorize") @@ -127,44 +123,28 @@ OAuth 2.0 Callback from Google Sign-In - - - (request.queryparam.error != null) or - (request.queryparam.state = null) or - (request.queryparam.state = "") or - (request.queryparam.code = null) or - (request.queryparam.code = "") - - oauth-raise-invalid-grant - oauth-get-state2-attributes - oauth-validate-callback + oauth-script - (oauth.error != null) and (oauth.error != "") - oauth-raise-bad-request + oauth.failed = "true" + oauth-raise-error oauth-delete-state2-code - - oauth-build-google-token-request - oauth-call-google-token - oauth-extract-google-id-token + oauth-script - - (private.oauth.google_id_token = null) or - (private.oauth.google_id_token = "") - - oauth-raise-invalid-grant + oauth.failed = "true" + oauth-raise-error oauth-verify-google-id-token @@ -174,7 +154,7 @@ (jwt.oauth-verify-google-id-token.decoded.claim.sub = null) or (jwt.oauth-verify-google-id-token.decoded.claim.sub = "") - oauth-raise-invalid-grant + oauth-raise-error oauth-compute-uid @@ -185,7 +165,7 @@ - oauth-redirect-to-app + oauth-script (request.verb = "GET") and (proxy.pathsuffix MatchesPath "/callback") @@ -197,10 +177,6 @@ request.header.Authorization ~~ "^[Bb]asic .+" oauth-decode-basic-auth - - (oauth.basic.client_id != null) and (oauth.basic.client_id != "") - oauth-normalize-basic-auth - oauth-verify-client-form @@ -209,15 +185,11 @@ oauth-get-code-attributes - oauth-verify-client-and-pkce - - - oauth.client_invalid = "true" - oauth-raise-invalid-client + oauth-script - (oauth.error != null) and (oauth.error != "") - oauth-raise-bad-request + oauth.failed = "true" + oauth-raise-error request.formparam.grant_type = "authorization_code" @@ -231,42 +203,6 @@ (request.verb = "POST") and (proxy.pathsuffix MatchesPath "/token") - - OAuth 2.0 Token Revocation Endpoint (RFC 7009) - - - request.header.Authorization ~~ "^[Bb]asic .+" - oauth-decode-basic-auth - - - (oauth.basic.client_id != null) and (oauth.basic.client_id != "") - oauth-normalize-basic-auth - - - oauth-verify-client-form - - - oauth-verify-client-and-pkce - - - oauth.client_invalid = "true" - oauth-raise-invalid-client - - - (oauth.error != null) and (oauth.error != "") - oauth-raise-bad-request - - - oauth-revoke-token - - - - - oauth-return-revoke-ok - - - (request.verb = "POST") and (proxy.pathsuffix MatchesPath "/revoke") - OAuth-protected MCP Endpoint @@ -280,14 +216,24 @@ ((proxy.pathsuffix MatchesPath "/mcp") or (proxy.pathsuffix MatchesPath "/mcp/**")) and - !(proxy.pathsuffix MatchesPath "/mcp/.well-known/**") + !(proxy.pathsuffix MatchesPath "/mcp/.well-known/**") and + (request.verb != "OPTIONS") + + CORS Preflight + + + request.verb = "OPTIONS" + Reject all other paths under /oauth - oauth-raise-not-found + oauth-script + + + oauth-raise-error @@ -304,7 +250,8 @@ ((proxy.pathsuffix MatchesPath "/mcp") or (proxy.pathsuffix MatchesPath "/mcp/**")) and - !(proxy.pathsuffix MatchesPath "/mcp/.well-known/**") + !(proxy.pathsuffix MatchesPath "/mcp/.well-known/**") and + (request.verb != "OPTIONS") oauth From 9dd2dc640473c1e4b0a9a1f3f439226ac64c2bda Mon Sep 17 00:00:00 2001 From: Keyur Shah Date: Fri, 2 Oct 2026 12:30:59 -0700 Subject: [PATCH 3/4] Fix Apigee Rhino CSRF nonce generation and product scope from staging verification --- deploy/apigee/jsc/oauth.js | 14 +++++++++++++- .../apigee/policies/oauth-verify-access-token.xml | 1 - deploy/apigee/terraform/nonprod/main.tf | 3 +++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/deploy/apigee/jsc/oauth.js b/deploy/apigee/jsc/oauth.js index 6fc465b64..bc7b2efe2 100644 --- a/deploy/apigee/jsc/oauth.js +++ b/deploy/apigee/jsc/oauth.js @@ -53,7 +53,19 @@ } function generateCsrfNonce() { - return String(java.util.UUID.randomUUID().toString().replace(/-/g, '')); + 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) { diff --git a/deploy/apigee/policies/oauth-verify-access-token.xml b/deploy/apigee/policies/oauth-verify-access-token.xml index 7f521d98f..4d41827ad 100644 --- a/deploy/apigee/policies/oauth-verify-access-token.xml +++ b/deploy/apigee/policies/oauth-verify-access-token.xml @@ -3,7 +3,6 @@ oauth-verify-access-token false VerifyAccessToken - mcp diff --git a/deploy/apigee/terraform/nonprod/main.tf b/deploy/apigee/terraform/nonprod/main.tf index 11321ece2..e140b5547 100644 --- a/deploy/apigee/terraform/nonprod/main.tf +++ b/deploy/apigee/terraform/nonprod/main.tf @@ -128,6 +128,9 @@ resource "apigee_product" "datacommons-oauth-staging" { environments = [ "dev", ] + scopes = [ + "mcp", + ] attributes = { access = "private" } From b4c4af7d0a68d83852c06a9a143acee8b037869c Mon Sep 17 00:00:00 2001 From: Keyur Shah Date: Fri, 2 Oct 2026 23:15:59 -0700 Subject: [PATCH 4/4] Support OAuth JWT bearer grant type for streamlined account linking --- deploy/apigee/jsc/oauth.js | 136 ++++++++++++++---- .../policies/oauth-generate-access-token.xml | 10 +- deploy/apigee/proxy_endpoints/oauth.xml | 37 ++++- 3 files changed, 147 insertions(+), 36 deletions(-) diff --git a/deploy/apigee/jsc/oauth.js b/deploy/apigee/jsc/oauth.js index bc7b2efe2..9e0b2a97a 100644 --- a/deploy/apigee/jsc/oauth.js +++ b/deploy/apigee/jsc/oauth.js @@ -91,32 +91,7 @@ var flowName = context.getVariable('current.flow.name') || ''; var host = context.getVariable('oauth.public_hostname') || ''; - // 1. 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'); - 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; - } - - // 2. Proxy Response Flow: construct discovery JSON or 302 redirects. + // 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); @@ -127,7 +102,11 @@ authorization_endpoint: 'https://' + host + '/oauth/authorize', token_endpoint: 'https://' + host + '/oauth/token', response_types_supported: ['code'], - grant_types_supported: ['authorization_code', 'refresh_token'], + 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'] @@ -176,6 +155,44 @@ 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; } @@ -316,9 +333,21 @@ 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') || ''; - if (grantType !== 'authorization_code' && grantType !== 'refresh_token') { - fail(400, 'unsupported_grant_type', 'Only authorization_code and refresh_token grant types are supported.'); + 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') { @@ -331,6 +360,51 @@ 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) { @@ -365,6 +439,10 @@ 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; } diff --git a/deploy/apigee/policies/oauth-generate-access-token.xml b/deploy/apigee/policies/oauth-generate-access-token.xml index 014a08e1f..0e196521c 100644 --- a/deploy/apigee/policies/oauth-generate-access-token.xml +++ b/deploy/apigee/policies/oauth-generate-access-token.xml @@ -8,14 +8,14 @@ authorization_code - request.formparam.grant_type + oauth.grant_type request.formparam.client_id - request.formparam.code - request.formparam.redirect_uri - oauthv2authcode.oauth-get-code-attributes.uid + oauthv2authcode.oauth-generate-auth-code.code + oauth.redirect_uri + oauth.uid true - + diff --git a/deploy/apigee/proxy_endpoints/oauth.xml b/deploy/apigee/proxy_endpoints/oauth.xml index 1b8614145..ad5ef2325 100644 --- a/deploy/apigee/proxy_endpoints/oauth.xml +++ b/deploy/apigee/proxy_endpoints/oauth.xml @@ -41,9 +41,12 @@ (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") @@ -192,7 +195,32 @@ oauth-raise-error - request.formparam.grant_type = "authorization_code" + 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 @@ -200,7 +228,12 @@ oauth-refresh-access-token - + + + oauth.jwt_bearer.intent = "check" + oauth-script + + (request.verb = "POST") and (proxy.pathsuffix MatchesPath "/token")