multi-tenant Phase 2: per-user Google OAuth
- workspace-mcp: add proxy.py (port 8080) that reads X-Actor-Id header,
fetches per-user Google credentials from Secrets Manager, writes creds
file, sets USER_GOOGLE_EMAIL, proxies to workspace-mcp on port 8081
- workspace-mcp: update bootstrap to start workspace-mcp on 8081 + proxy on 8080
- workspace-mcp: update Dockerfile to include proxy.py
- oauth-handler Lambda: new Lambda with /oauth/start + /oauth/callback
routes; exchanges Google auth code, stores tokens in Secrets Manager
at agent-claw/google-credentials/{actor_id_safe}, updates DynamoDB
- CDK: add OAuthHandler Lambda + GET /oauth/start + /oauth/callback routes
- CDK: remove shared google-workspace-credentials secret; add per-user
secret IAM grants (agent-claw/google-credentials/*) for workspace-mcp
role, runtime1 role, and oauth-handler role
- CDK: output OAuthStartUrl + OAuthRedirectUri
- agent-runner: pass google_email in user_profile payload
- main.py: pass actor_id as X-Actor-Id header in workspace-mcp MCP calls;
skip workspace-mcp if user has no google_email; add connect_google_account
tool that generates OAuth URL for the current user
- main.py: include google_email in user_context for system prompt
- agentcore.json: add OAUTH_START_URL env var for agent runtime
This commit is contained in:
@@ -161,6 +161,7 @@ def handler(event, context):
|
||||
'user_profile': {
|
||||
'display_name': user_profile.get('display_name', actor_id),
|
||||
'telegram_username': user_profile.get('telegram_username', ''),
|
||||
'google_email': user_profile.get('google_email', ''),
|
||||
'allowed': user_profile.get('allowed', True),
|
||||
},
|
||||
'channel_adapter': {
|
||||
|
||||
212
src/lambdas/oauth-handler/handler.py
Normal file
212
src/lambdas/oauth-handler/handler.py
Normal file
@@ -0,0 +1,212 @@
|
||||
"""
|
||||
Google OAuth handler Lambda.
|
||||
|
||||
Routes:
|
||||
GET /oauth/start?actor_id=telegram:123 → redirect to Google OAuth consent
|
||||
GET /oauth/callback?code=...&state=... → exchange code, store tokens, update DynamoDB
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
import boto3
|
||||
|
||||
_sm = None
|
||||
_ddb = None
|
||||
|
||||
SCOPES = ' '.join([
|
||||
'https://www.googleapis.com/auth/gmail.modify',
|
||||
'https://www.googleapis.com/auth/calendar',
|
||||
'https://www.googleapis.com/auth/drive',
|
||||
'https://www.googleapis.com/auth/spreadsheets',
|
||||
'https://www.googleapis.com/auth/documents',
|
||||
'openid',
|
||||
'email',
|
||||
'profile',
|
||||
])
|
||||
|
||||
|
||||
def get_sm():
|
||||
global _sm
|
||||
if _sm is None:
|
||||
_sm = boto3.client('secretsmanager', region_name=os.environ.get('AWS_REGION', 'us-east-1'))
|
||||
return _sm
|
||||
|
||||
|
||||
def get_ddb():
|
||||
global _ddb
|
||||
if _ddb is None:
|
||||
_ddb = boto3.resource('dynamodb')
|
||||
return _ddb
|
||||
|
||||
|
||||
def get_oauth_client() -> tuple[str, str]:
|
||||
"""Return (client_id, client_secret) from Secrets Manager."""
|
||||
arn = os.environ['GOOGLE_OAUTH_CLIENT_SECRET_ARN']
|
||||
secret = json.loads(get_sm().get_secret_value(SecretId=arn)['SecretString'])
|
||||
return secret['client_id'], secret['client_secret']
|
||||
|
||||
|
||||
def actor_id_to_secret_name(actor_id: str) -> str:
|
||||
safe = actor_id.replace(':', '-').replace('/', '-')
|
||||
return f'agent-claw/google-credentials/{safe}'
|
||||
|
||||
|
||||
def _redirect(url: str) -> dict:
|
||||
return {'statusCode': 302, 'headers': {'Location': url}, 'body': ''}
|
||||
|
||||
|
||||
def _html(body: str, status: int = 200) -> dict:
|
||||
return {'statusCode': status, 'headers': {'Content-Type': 'text/html'}, 'body': body}
|
||||
|
||||
|
||||
def handler(event, context):
|
||||
path = event.get('rawPath') or event.get('path', '')
|
||||
params = event.get('queryStringParameters') or {}
|
||||
|
||||
if path.endswith('/oauth/start'):
|
||||
return handle_start(params)
|
||||
elif path.endswith('/oauth/callback'):
|
||||
return handle_callback(params)
|
||||
else:
|
||||
return {'statusCode': 404, 'body': 'Not found'}
|
||||
|
||||
|
||||
def handle_start(params: dict) -> dict:
|
||||
actor_id = params.get('actor_id', '')
|
||||
if not actor_id:
|
||||
return _html('<h1>Missing actor_id</h1>', 400)
|
||||
|
||||
client_id, _ = get_oauth_client()
|
||||
redirect_uri = os.environ['OAUTH_REDIRECT_URI']
|
||||
|
||||
# Encode actor_id in state (base64 to keep URL-safe)
|
||||
state = base64.urlsafe_b64encode(actor_id.encode()).decode().rstrip('=')
|
||||
|
||||
auth_url = (
|
||||
'https://accounts.google.com/o/oauth2/v2/auth?'
|
||||
+ urllib.parse.urlencode({
|
||||
'client_id': client_id,
|
||||
'redirect_uri': redirect_uri,
|
||||
'response_type': 'code',
|
||||
'scope': SCOPES,
|
||||
'access_type': 'offline',
|
||||
'prompt': 'consent',
|
||||
'state': state,
|
||||
})
|
||||
)
|
||||
return _redirect(auth_url)
|
||||
|
||||
|
||||
def handle_callback(params: dict) -> dict:
|
||||
code = params.get('code', '')
|
||||
state = params.get('state', '')
|
||||
error = params.get('error', '')
|
||||
|
||||
if error:
|
||||
return _html(f'<h1>OAuth error: {error}</h1>', 400)
|
||||
if not code or not state:
|
||||
return _html('<h1>Missing code or state</h1>', 400)
|
||||
|
||||
# Decode actor_id from state
|
||||
try:
|
||||
padding = 4 - len(state) % 4
|
||||
actor_id = base64.urlsafe_b64decode(state + '=' * padding).decode()
|
||||
except Exception:
|
||||
return _html('<h1>Invalid state</h1>', 400)
|
||||
|
||||
client_id, client_secret = get_oauth_client()
|
||||
redirect_uri = os.environ['OAUTH_REDIRECT_URI']
|
||||
|
||||
# Exchange code for tokens
|
||||
token_data = urllib.parse.urlencode({
|
||||
'code': code,
|
||||
'client_id': client_id,
|
||||
'client_secret': client_secret,
|
||||
'redirect_uri': redirect_uri,
|
||||
'grant_type': 'authorization_code',
|
||||
}).encode()
|
||||
|
||||
req = urllib.request.Request(
|
||||
'https://oauth2.googleapis.com/token',
|
||||
data=token_data,
|
||||
headers={'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
tokens = json.loads(resp.read())
|
||||
except Exception as e:
|
||||
print(f'[oauth] Token exchange failed: {e}')
|
||||
return _html(f'<h1>Token exchange failed: {e}</h1>', 500)
|
||||
|
||||
# Fetch user email from Google
|
||||
user_email = ''
|
||||
try:
|
||||
id_token_payload = tokens.get('id_token', '').split('.')[1]
|
||||
padding = 4 - len(id_token_payload) % 4
|
||||
claims = json.loads(base64.urlsafe_b64decode(id_token_payload + '=' * padding))
|
||||
user_email = claims.get('email', '')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not user_email:
|
||||
# Fallback: call userinfo endpoint
|
||||
try:
|
||||
access_token = tokens.get('access_token', '')
|
||||
req2 = urllib.request.Request(
|
||||
'https://www.googleapis.com/oauth2/v3/userinfo',
|
||||
headers={'Authorization': f'Bearer {access_token}'},
|
||||
)
|
||||
with urllib.request.urlopen(req2, timeout=10) as resp2:
|
||||
user_email = json.loads(resp2.read()).get('email', '')
|
||||
except Exception as e:
|
||||
print(f'[oauth] userinfo fetch failed: {e}')
|
||||
|
||||
# Build credentials dict (google-auth format)
|
||||
creds = {
|
||||
'token': tokens.get('access_token'),
|
||||
'refresh_token': tokens.get('refresh_token'),
|
||||
'token_uri': 'https://oauth2.googleapis.com/token',
|
||||
'client_id': client_id,
|
||||
'client_secret': client_secret,
|
||||
'scopes': SCOPES.split(),
|
||||
'email': user_email,
|
||||
'user_email': user_email,
|
||||
}
|
||||
if tokens.get('expires_in'):
|
||||
creds['expiry'] = time.strftime(
|
||||
'%Y-%m-%dT%H:%M:%SZ',
|
||||
time.gmtime(time.time() + int(tokens['expires_in']))
|
||||
)
|
||||
|
||||
# Store in Secrets Manager
|
||||
secret_name = actor_id_to_secret_name(actor_id)
|
||||
sm = get_sm()
|
||||
try:
|
||||
sm.create_secret(Name=secret_name, SecretString=json.dumps(creds))
|
||||
except sm.exceptions.ResourceExistsException:
|
||||
sm.put_secret_value(SecretId=secret_name, SecretString=json.dumps(creds))
|
||||
print(f'[oauth] Stored credentials for actor={actor_id} email={user_email}')
|
||||
|
||||
# Update DynamoDB users table with google_email
|
||||
table_name = os.environ.get('USERS_TABLE_NAME', '')
|
||||
if table_name and actor_id:
|
||||
try:
|
||||
get_ddb().Table(table_name).update_item(
|
||||
Key={'actor_id': actor_id},
|
||||
UpdateExpression='SET google_email = :e',
|
||||
ExpressionAttributeValues={':e': user_email},
|
||||
)
|
||||
except Exception as e:
|
||||
print(f'[oauth] DynamoDB update failed: {e}')
|
||||
|
||||
return _html(
|
||||
f'<h1>✅ Google account connected!</h1>'
|
||||
f'<p>Connected <b>{user_email}</b> to your agent account.</p>'
|
||||
f'<p>You can close this window and return to Telegram.</p>'
|
||||
)
|
||||
1
src/lambdas/oauth-handler/requirements.txt
Normal file
1
src/lambdas/oauth-handler/requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
boto3>=1.34.0
|
||||
@@ -9,11 +9,13 @@ RUN pip install workspace-mcp==1.20.3 boto3 --quiet
|
||||
# Copy bootstrap and helper scripts
|
||||
COPY bootstrap /var/task/bootstrap
|
||||
COPY fetch_credentials.py /var/task/fetch_credentials.py
|
||||
COPY proxy.py /var/task/proxy.py
|
||||
RUN chmod +x /var/task/bootstrap
|
||||
|
||||
# Lambda Web Adapter config
|
||||
# Lambda Web Adapter config — proxy listens on 8080, workspace-mcp on 8081
|
||||
ENV AWS_LAMBDA_EXEC_WRAPPER=/opt/bootstrap
|
||||
ENV PORT=8080
|
||||
ENV PROXY_PORT=8080
|
||||
ENV READINESS_CHECK_PATH=/health
|
||||
|
||||
CMD ["/var/task/bootstrap"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
# Lambda Web Adapter bootstrap for workspace-mcp
|
||||
# Dependencies are in /opt/python (Lambda layer)
|
||||
# Lambda Web Adapter bootstrap for workspace-mcp (multi-tenant)
|
||||
# Proxy on port 8080 (Lambda Web Adapter entry), workspace-mcp on port 8081
|
||||
|
||||
set -e
|
||||
|
||||
@@ -14,14 +14,27 @@ export HOME=/tmp
|
||||
export WORKSPACE_MCP_LOG_DIR=/tmp
|
||||
export GOOGLE_WORKSPACE_MCP_CREDENTIALS_DIR=/tmp/workspace_mcp_credentials
|
||||
|
||||
echo "[workspace-mcp] Fetching Google credentials..." >&2
|
||||
$PYTHON /var/task/fetch_credentials.py
|
||||
echo "[workspace-mcp] Fetching default Google credentials..." >&2
|
||||
$PYTHON /var/task/fetch_credentials.py || true # non-fatal: per-user creds loaded by proxy
|
||||
|
||||
if [ -f /tmp/workspace-mcp-env ]; then
|
||||
source /tmp/workspace-mcp-env
|
||||
fi
|
||||
|
||||
echo "[workspace-mcp] Starting on port $PORT..." >&2
|
||||
exec $PYTHON /opt/python/bin/workspace-mcp \
|
||||
echo "[workspace-mcp] Starting workspace-mcp on port 8081..." >&2
|
||||
PORT=8081 $PYTHON /opt/python/bin/workspace-mcp \
|
||||
--transport streamable-http \
|
||||
--tool-tier extended
|
||||
--tool-tier extended &
|
||||
WMCP_PID=$!
|
||||
|
||||
# Wait for workspace-mcp to be ready
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf http://127.0.0.1:8081/health > /dev/null 2>&1; then
|
||||
echo "[workspace-mcp] Ready on port 8081" >&2
|
||||
break
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
|
||||
echo "[workspace-mcp] Starting credential proxy on port $PORT..." >&2
|
||||
exec $PYTHON /var/task/proxy.py
|
||||
|
||||
143
src/lambdas/workspace-mcp/proxy.py
Normal file
143
src/lambdas/workspace-mcp/proxy.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
Multi-tenant credential proxy for workspace-mcp.
|
||||
|
||||
Sits on port 8080 (Lambda Web Adapter entry point).
|
||||
Reads X-Actor-Id header, fetches per-user Google credentials from Secrets Manager,
|
||||
writes them to /tmp/workspace_mcp_credentials/{email}.json, sets USER_GOOGLE_EMAIL,
|
||||
then proxies the request to workspace-mcp on port 8081.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
import boto3
|
||||
|
||||
_sm = None
|
||||
_sm_lock = threading.Lock()
|
||||
|
||||
# Cache: actor_id -> (email, creds_json, fetched_at)
|
||||
_creds_cache: dict = {}
|
||||
_cache_ttl = 300 # 5 minutes
|
||||
|
||||
|
||||
def _get_sm():
|
||||
global _sm
|
||||
if _sm is None:
|
||||
with _sm_lock:
|
||||
if _sm is None:
|
||||
_sm = boto3.client('secretsmanager', region_name=os.environ.get('AWS_REGION', 'us-east-1'))
|
||||
return _sm
|
||||
|
||||
|
||||
def _actor_id_to_secret_name(actor_id: str) -> str:
|
||||
"""Convert actor_id to a valid Secrets Manager secret name."""
|
||||
# telegram:123456789 -> agent-claw/google-credentials/telegram-123456789
|
||||
safe = actor_id.replace(':', '-').replace('/', '-')
|
||||
return f'agent-claw/google-credentials/{safe}'
|
||||
|
||||
|
||||
def _fetch_credentials(actor_id: str) -> tuple[str, dict] | None:
|
||||
"""Fetch Google credentials for actor_id from Secrets Manager. Returns (email, creds_dict) or None."""
|
||||
now = time.time()
|
||||
cached = _creds_cache.get(actor_id)
|
||||
if cached and now - cached[2] < _cache_ttl:
|
||||
return cached[0], cached[1]
|
||||
|
||||
secret_name = _actor_id_to_secret_name(actor_id)
|
||||
try:
|
||||
secret = _get_sm().get_secret_value(SecretId=secret_name)['SecretString']
|
||||
creds = json.loads(secret)
|
||||
email = creds.get('client_email') or creds.get('email') or creds.get('user_email', '')
|
||||
_creds_cache[actor_id] = (email, creds, now)
|
||||
print(f'[proxy] Loaded credentials for actor={actor_id} email={email}', flush=True)
|
||||
return email, creds
|
||||
except Exception as e:
|
||||
print(f'[proxy] No credentials for actor={actor_id}: {e}', flush=True)
|
||||
return None
|
||||
|
||||
|
||||
def _write_credentials_file(email: str, creds: dict) -> str:
|
||||
"""Write credentials to /tmp/workspace_mcp_credentials/{email}.json. Returns path."""
|
||||
creds_dir = '/tmp/workspace_mcp_credentials'
|
||||
os.makedirs(creds_dir, exist_ok=True)
|
||||
path = f'{creds_dir}/{email}.json'
|
||||
with open(path, 'w') as f:
|
||||
json.dump(creds, f)
|
||||
return path
|
||||
|
||||
|
||||
class ProxyHandler(BaseHTTPRequestHandler):
|
||||
def log_message(self, format, *args):
|
||||
pass # suppress default access log
|
||||
|
||||
def _proxy(self):
|
||||
actor_id = self.headers.get('x-actor-id', '')
|
||||
|
||||
if actor_id:
|
||||
result = _fetch_credentials(actor_id)
|
||||
if result:
|
||||
email, creds = result
|
||||
_write_credentials_file(email, creds)
|
||||
os.environ['USER_GOOGLE_EMAIL'] = email
|
||||
os.environ['GOOGLE_WORKSPACE_MCP_CREDENTIALS_DIR'] = '/tmp/workspace_mcp_credentials'
|
||||
else:
|
||||
# No credentials found — proceed without setting email (workspace-mcp will use default or fail)
|
||||
print(f'[proxy] No Google credentials for actor={actor_id}, proceeding without', flush=True)
|
||||
|
||||
# Read request body
|
||||
content_length = int(self.headers.get('Content-Length', 0))
|
||||
body = self.rfile.read(content_length) if content_length > 0 else b''
|
||||
|
||||
# Build upstream request
|
||||
upstream_url = f'http://127.0.0.1:8081{self.path}'
|
||||
upstream_headers = {k: v for k, v in self.headers.items()
|
||||
if k.lower() not in ('host', 'content-length')}
|
||||
if body:
|
||||
upstream_headers['Content-Length'] = str(len(body))
|
||||
|
||||
req = urllib.request.Request(
|
||||
upstream_url,
|
||||
data=body or None,
|
||||
headers=upstream_headers,
|
||||
method=self.command,
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
self.send_response(resp.status)
|
||||
for k, v in resp.headers.items():
|
||||
if k.lower() not in ('transfer-encoding',):
|
||||
self.send_header(k, v)
|
||||
self.end_headers()
|
||||
self.wfile.write(resp.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
self.send_response(e.code)
|
||||
for k, v in e.headers.items():
|
||||
if k.lower() not in ('transfer-encoding',):
|
||||
self.send_header(k, v)
|
||||
self.end_headers()
|
||||
self.wfile.write(e.read())
|
||||
except Exception as e:
|
||||
print(f'[proxy] Upstream error: {e}', flush=True)
|
||||
self.send_response(502)
|
||||
self.end_headers()
|
||||
self.wfile.write(b'Bad Gateway')
|
||||
|
||||
do_GET = _proxy
|
||||
do_POST = _proxy
|
||||
do_PUT = _proxy
|
||||
do_DELETE = _proxy
|
||||
do_OPTIONS = _proxy
|
||||
do_HEAD = _proxy
|
||||
do_PATCH = _proxy
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
port = int(os.environ.get('PROXY_PORT', 8080))
|
||||
server = HTTPServer(('0.0.0.0', port), ProxyHandler)
|
||||
print(f'[proxy] Listening on port {port}', flush=True)
|
||||
server.serve_forever()
|
||||
Reference in New Issue
Block a user