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