mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-09-30 07:43:13 +00:00
Google Workspace federation stores email_verified=false, which 403'd the portal Admin probe while the public menu still loaded.
139 lines
4.8 KiB
Python
139 lines
4.8 KiB
Python
"""API Gateway (HTTP API) Lambda authorizer for the meal-order-manager admin API.
|
|
|
|
Gates every ``/api/admin/*`` route at the gateway. The authorizer accepts the
|
|
existing Google ID token or a portal Cognito ID token in the Authorization
|
|
header, then confirms the caller is in the ``admin_emails`` allow-list
|
|
(DynamoDB CONFIG/SETTINGS).
|
|
|
|
Returns the HTTP API v2 *simple response* shape (``{"isAuthorized": bool}``);
|
|
a False result causes API Gateway to return 403 before the integration runs.
|
|
The in-handler ``_verify_admin`` check stays in place as defense-in-depth.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import sys
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
|
|
from shared.cognito import (
|
|
CognitoVerificationUnavailable,
|
|
looks_like_cognito_token,
|
|
verify_cognito_id_token,
|
|
)
|
|
from shared.db import get_settings
|
|
from shared.secrets import get_parameter
|
|
|
|
logger = logging.getLogger(__name__)
|
|
logger.setLevel(logging.INFO)
|
|
if not logger.handlers:
|
|
logger.addHandler(logging.StreamHandler(sys.stderr))
|
|
|
|
ALLOWED_DOMAINS = {"seahavenind.com", "seahaven.com"}
|
|
|
|
_DENY = {"isAuthorized": False}
|
|
_ALLOW = {"isAuthorized": True}
|
|
|
|
|
|
def _get_google_client_id() -> str:
|
|
param = os.environ.get("GOOGLE_CLIENT_ID_PARAM", "")
|
|
if not param:
|
|
return ""
|
|
try:
|
|
return get_parameter(param, decrypt=False) or ""
|
|
except Exception as exc: # ParameterNotFound or transient — fail closed
|
|
logger.error("Failed to read Google client ID param: %s", exc)
|
|
return ""
|
|
|
|
|
|
def _verify_google_token(token: str, client_id: str) -> dict | None:
|
|
"""Verify a Google ID token via the tokeninfo endpoint.
|
|
|
|
Returns the decoded {name, email} on success, or None on any failure
|
|
(bad token, wrong audience/domain, or service unavailable). The authorizer
|
|
fails closed: any verification problem denies access.
|
|
"""
|
|
try:
|
|
qs = urllib.parse.urlencode({"id_token": token})
|
|
req = urllib.request.Request(f"https://oauth2.googleapis.com/tokeninfo?{qs}")
|
|
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
data = json.loads(resp.read())
|
|
except urllib.error.HTTPError as exc:
|
|
logger.warning("Google token rejected (HTTP %s)", exc.code)
|
|
return None
|
|
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
|
logger.error("Google token verification service unavailable: %s", exc)
|
|
return None
|
|
except Exception as exc:
|
|
logger.error("Google token verification failed: %s", exc)
|
|
return None
|
|
|
|
if data.get("aud") != client_id:
|
|
logger.warning("Google token audience mismatch")
|
|
return None
|
|
if data.get("hd") not in ALLOWED_DOMAINS:
|
|
logger.warning("Google token domain mismatch: %s", data.get("hd"))
|
|
return None
|
|
return {"name": data.get("name", ""), "email": data.get("email", "")}
|
|
|
|
|
|
def _extract_token(event) -> str:
|
|
"""Pull the bearer token from the Authorization header.
|
|
|
|
HTTP API lowercases header names; check both for safety.
|
|
"""
|
|
headers = event.get("headers") or {}
|
|
raw = headers.get("authorization") or headers.get("Authorization") or ""
|
|
if raw.lower().startswith("bearer "):
|
|
return raw[7:].strip()
|
|
return ""
|
|
|
|
|
|
def _verify_portal_token(token: str) -> dict | None:
|
|
return verify_cognito_id_token(
|
|
token,
|
|
os.environ.get("PORTAL_COGNITO_ISSUER_PARAM", ""),
|
|
os.environ.get("PORTAL_COGNITO_AUDIENCE_PARAM", ""),
|
|
os.environ.get("PORTAL_COGNITO_TRUST_PARAM", ""),
|
|
)
|
|
|
|
|
|
def lambda_handler(event, context):
|
|
token = _extract_token(event)
|
|
if not token:
|
|
return _DENY
|
|
|
|
if looks_like_cognito_token(token):
|
|
try:
|
|
user_info = _verify_portal_token(token)
|
|
except CognitoVerificationUnavailable:
|
|
logger.error("Cognito verification service unavailable; denying")
|
|
return _DENY
|
|
else:
|
|
if not os.environ.get("GOOGLE_CLIENT_ID_PARAM", ""):
|
|
logger.error("Authorizer misconfigured: GOOGLE_CLIENT_ID_PARAM unset")
|
|
return _DENY
|
|
client_id = _get_google_client_id()
|
|
if not client_id:
|
|
logger.error("Google client ID unavailable; denying")
|
|
return _DENY
|
|
user_info = _verify_google_token(token, client_id)
|
|
if user_info is None:
|
|
return _DENY
|
|
|
|
try:
|
|
admin_emails = {e.lower() for e in get_settings().get("admin_emails", [])}
|
|
except Exception as exc:
|
|
# DynamoDB unavailable / missing item: fail closed with DENY rather than
|
|
# letting the unhandled exception surface as a 500 from the gateway.
|
|
logger.error("Failed to load admin_emails from DynamoDB: %s", exc)
|
|
return _DENY
|
|
|
|
if user_info["email"].lower() not in admin_emails:
|
|
logger.warning("Non-admin %s denied at gateway", user_info["email"])
|
|
return _DENY
|
|
|
|
logger.info("Admin %s authorized at gateway", user_info["email"])
|
|
return _ALLOW
|