File indexing completed on 2026-09-28 09:37:31
0001 """TeamComms proxy and current devcloud authentication attestations.
0002
0003 The monitor obtains identity through authenticated introspection. Incoming
0004 identity assertions and user credentials are never forwarded to the monitor.
0005 See docs/teamcomms.md for the cross-host contract.
0006 """
0007
0008 from datetime import timedelta
0009 import hashlib
0010 from importlib import import_module
0011 import json
0012 import logging
0013 import secrets
0014 from types import SimpleNamespace
0015 from urllib.parse import urlsplit
0016
0017 import httpx
0018 from django.conf import settings
0019 from django.contrib.auth import get_user
0020 from django.contrib.auth.views import redirect_to_login
0021 from django.core.exceptions import RequestDataTooBig
0022 from django.http import HttpResponse, JsonResponse, StreamingHttpResponse
0023 from django.middleware.csrf import CsrfViewMiddleware, get_token
0024 from django.utils import timezone
0025 from django.views.decorators.csrf import csrf_exempt
0026
0027 from .models import ApiToken, TeamCommsAuthReference
0028 from .token_auth import TOKEN_PREFIX, _hash
0029
0030 logger = logging.getLogger(__name__)
0031 PUBLIC_PREFIX = '/prod/teamcomms'
0032 REFERENCE_SECONDS = 60
0033 BODY_LIMIT = 65536
0034 TIMEOUT = httpx.Timeout(connect=10, read=35, write=10, pool=10)
0035
0036
0037 def _json(data, status=200):
0038 response = JsonResponse(data, status=status)
0039 response['Cache-Control'] = 'no-store'
0040 return response
0041
0042
0043 def _current_user(reference):
0044 if reference.token_id is not None:
0045 token = reference.token
0046 if token.revoked is not None or token.user_id != reference.user_id:
0047 return None
0048 user = token.user
0049 else:
0050 store = import_module(settings.SESSION_ENGINE).SessionStore(reference.session_key)
0051 user = get_user(SimpleNamespace(session=store))
0052 if not user.is_authenticated or not user.is_active or user.pk != reference.user_id:
0053 return None
0054 return user
0055
0056
0057 @csrf_exempt
0058 def introspect(request):
0059 """Return current identity to the monitor's dedicated service credential."""
0060 expected = settings.SWF_TEAMCOMMS_SERVICE_TOKEN
0061 if not expected:
0062 return _json({'error': 'TeamComms authentication unavailable'}, 503)
0063 auth = request.META.get('HTTP_AUTHORIZATION', '')
0064 if not secrets.compare_digest(auth, 'Bearer ' + expected):
0065 return _json({'error': 'Service authentication required'}, 401)
0066 if request.method != 'POST':
0067 return _json({'error': 'POST required'}, 405)
0068 try:
0069 if int(request.META.get('CONTENT_LENGTH') or 0) > 1024:
0070 return _json({'error': 'Request too large'}, 413)
0071 body = request.body
0072 if len(body) > 1024:
0073 return _json({'error': 'Request too large'}, 413)
0074 data = json.loads(body)
0075 raw = data.get('reference') if isinstance(data, dict) else None
0076 if not isinstance(raw, str) or len(raw) != 64:
0077 return _json({'error': 'Invalid authentication reference'}, 401)
0078 reference = TeamCommsAuthReference.objects.select_related('token__user').filter(
0079 key_hash=_hash(raw), expires_at__gt=timezone.now(),
0080 ).first()
0081 if reference is None:
0082 return _json({'error': 'Expired or invalid authentication reference'}, 401)
0083 user = _current_user(reference)
0084 if user is None:
0085 return _json({'error': 'Authentication revoked or expired'}, 401)
0086 human = {'subject': str(user.pk), 'username': user.username,
0087 'name': user.get_full_name() or user.username}
0088 identity = dict(human, kind='human')
0089 if reference.token_id is not None and reference.token.teamcomms_ai:
0090 identity.update(subject='ai:' + str(user.pk), kind='ai',
0091 name=human['name'] + ' AI', operator=human)
0092 elif reference.token_id is not None and reference.token.teamcomms_service_kind:
0093 kind = reference.token.teamcomms_service_kind
0094 if kind not in {'program', 'connector'}:
0095 return _json({'error': 'Invalid service identity'}, 401)
0096 identity.update(subject=kind + ':' + str(user.pk), kind=kind,
0097 name=human['name'] + ' ' + kind, account_subject=str(user.pk))
0098 return _json(dict(identity,
0099 auth_method='token' if reference.token_id is not None else 'session',
0100 csrf_verified=reference.csrf_verified,
0101 method=reference.method, path=reference.path,
0102 query_string=reference.query_string, body_sha256=reference.body_sha256,
0103 expires_at=reference.expires_at.isoformat()))
0104 except (ValueError, RequestDataTooBig):
0105 return _json({'error': 'Invalid introspection request'}, 400)
0106 except Exception as error:
0107 logger.error('TeamComms introspection unavailable (%s)', type(error).__name__)
0108 return _json({'error': 'Authentication authority unavailable'}, 503)
0109
0110
0111 def _authenticated_request(request):
0112 """Resolve a token explicitly, or require a current browser session and CSRF."""
0113 auth = request.META.get('HTTP_AUTHORIZATION', '')
0114 if auth:
0115 if not auth.startswith('Bearer ' + TOKEN_PREFIX):
0116 return None, _json({'error': 'A devcloud bearer token is required'}, 401)
0117 token = ApiToken.objects.select_related('user').filter(
0118 key_hash=_hash(auth[7:].strip()), revoked__isnull=True,
0119 user__is_active=True,
0120 ).first()
0121 if token is None:
0122 return None, _json({'error': 'Invalid or revoked token'}, 401)
0123 request.user = token.user
0124 return token, None
0125 if not request.user.is_authenticated or not request.user.is_active:
0126 return None, _json({'error': 'Sign in or supply a devcloud bearer token'}, 401)
0127
0128
0129 csrf = CsrfViewMiddleware(lambda req: HttpResponse())
0130 rejection = csrf.process_view(request, lambda req: HttpResponse(), (), {})
0131 if rejection is not None:
0132 return None, _json({'error': 'CSRF validation failed'}, 403)
0133 return None, None
0134
0135
0136 def _response_headers(upstream, response):
0137 for key in ('Content-Type', 'MCP-Session-Id', 'MCP-Protocol-Version', 'Allow',
0138 'Retry-After', 'WWW-Authenticate', 'Content-Security-Policy',
0139 'X-Content-Type-Options', 'Referrer-Policy'):
0140 if key in upstream.headers:
0141 response[key] = upstream.headers[key]
0142 response['Cache-Control'] = 'no-store'
0143 if 'location' in upstream.headers:
0144 target = urlsplit(upstream.headers['location'])
0145 prefix = '/swf-monitor/teamcomms'
0146 if target.path.startswith(prefix + '/') or target.path == prefix:
0147 response['Location'] = PUBLIC_PREFIX + target.path[len(prefix):]
0148 if target.query:
0149 response['Location'] += '?' + target.query
0150 elif target.path.startswith(PUBLIC_PREFIX + '/') or target.path == PUBLIC_PREFIX:
0151 response['Location'] = target.path + ('?' + target.query if target.query else '')
0152 return response
0153
0154
0155 def browser_csrf(request):
0156 """Supply this origin's masked CSRF token using its existing browser session."""
0157 if request.method != 'GET':
0158 return _json({'error': 'GET required'}, 405)
0159 if (request.META.get('HTTP_AUTHORIZATION') or not request.user.is_authenticated
0160 or not request.user.is_active):
0161 return _json({'error': 'An active devcloud browser session is required'}, 401)
0162
0163 return _json({'header_name': 'X-CSRFToken', 'token': get_token(request)})
0164
0165
0166 @csrf_exempt
0167 def proxy(request, subpath=''):
0168 """Relay authenticated TC HTTP/MCP and bounded SSE through the existing tunnel."""
0169 if not settings.SWF_TEAMCOMMS_SERVICE_TOKEN:
0170 return _json({'error': 'TeamComms authentication unavailable'}, 503)
0171 client = None
0172 upstream = None
0173 try:
0174 token, rejection = _authenticated_request(request)
0175 if rejection is not None:
0176 if (rejection.status_code == 401 and request.method == 'GET'
0177 and not request.META.get('HTTP_AUTHORIZATION')
0178 and 'text/html' in request.headers.get('Accept', '')
0179 and (subpath in {'', 'entries', 'sessions', 'dialog'}
0180 or subpath.startswith('entries/'))):
0181 return redirect_to_login(request.get_full_path())
0182 return rejection
0183 if int(request.META.get('CONTENT_LENGTH') or 0) > BODY_LIMIT:
0184 return _json({'error': 'Request too large'}, 413)
0185 body = request.body
0186 if len(body) > BODY_LIMIT:
0187 return _json({'error': 'Request too large'}, 413)
0188
0189
0190 if any(part in {'.', '..'} for part in subpath.split('/')) or any(c in subpath for c in '%\\?#'):
0191 return _json({'error': 'Invalid TeamComms path'}, 400)
0192 raw = secrets.token_hex(32)
0193 now = timezone.now()
0194 TeamCommsAuthReference.objects.create(
0195 key_hash=_hash(raw), user=request.user, token=token,
0196 session_key='' if token else request.session.session_key,
0197 method=request.method, path='/' + subpath,
0198 query_string=request.META.get('QUERY_STRING', ''),
0199 body_sha256=hashlib.sha256(body).hexdigest(),
0200 csrf_verified=token is None,
0201 expires_at=now + timedelta(seconds=REFERENCE_SECONDS),
0202 )
0203
0204 TeamCommsAuthReference.objects.filter(expires_at__lte=now).delete()
0205 headers = {
0206 'Host': 'epic-devcloud.org',
0207 'X-TeamComms-Auth-Ref': raw,
0208 'X-Forwarded-Host': 'epic-devcloud.org',
0209 'X-Forwarded-Proto': 'https',
0210 'Accept-Encoding': 'identity',
0211 }
0212 for key in ('Accept', 'Content-Type', 'Origin', 'Last-Event-ID',
0213 'MCP-Protocol-Version', 'MCP-Session-Id'):
0214 if key in request.headers:
0215 headers[key] = request.headers[key]
0216 url = settings.SWF_MONITOR_URL.rstrip('/') + '/teamcomms/' + subpath
0217 query = request.META.get('QUERY_STRING', '')
0218 if query:
0219 url += '?' + query
0220 client = httpx.Client(timeout=TIMEOUT, verify=False, follow_redirects=False)
0221 upstream = client.send(client.build_request(request.method, url, headers=headers, content=body), stream=True)
0222 if upstream.headers.get('content-type', '').split(';', 1)[0] == 'text/event-stream':
0223 source, connection = upstream, client
0224
0225 def chunks():
0226 try:
0227 yield from source.iter_raw()
0228 except httpx.HTTPError as error:
0229 logger.error('TeamComms stream failed (%s)', type(error).__name__)
0230 yield b'event: error\ndata: {"error":"TeamComms tunnel unavailable","status":502}\n\n'
0231 finally:
0232 source.close()
0233 connection.close()
0234
0235 response = StreamingHttpResponse(chunks(), status=upstream.status_code,
0236 content_type='text/event-stream')
0237
0238 response._resource_closers.extend([source.close, connection.close])
0239 response['X-Accel-Buffering'] = 'no'
0240 _response_headers(upstream, response)
0241 upstream = client = None
0242 return response
0243 content = upstream.read()
0244 return _response_headers(upstream, HttpResponse(content, status=upstream.status_code))
0245 except (ValueError, RequestDataTooBig):
0246 return _json({'error': 'Invalid TeamComms request'}, 400)
0247 except httpx.HTTPError as error:
0248 logger.error('TeamComms proxy failed (%s)', type(error).__name__)
0249 return _json({'error': 'TeamComms tunnel unavailable'}, 502)
0250 except Exception as error:
0251 logger.error('TeamComms proxy unavailable (%s)', type(error).__name__)
0252 return _json({'error': 'TeamComms authentication unavailable'}, 503)
0253 finally:
0254 if upstream is not None:
0255 upstream.close()
0256 if client is not None:
0257 client.close()