749 lines
30 KiB
Python
749 lines
30 KiB
Python
"""OAuth 2.1 authorization server for MCP clients, proxying sign-in to Gitea.
|
|
|
|
Implements the SDK's ``OAuthAuthorizationServerProvider``. The SDK mounts
|
|
``/authorize``, ``/token``, ``/register``, ``/revoke`` and the metadata, and
|
|
does PKCE, redirect URI matching and client authentication; this module holds
|
|
the state behind them.
|
|
|
|
Sign-in flow (each step single use, every value 256-bit random):
|
|
|
|
1. ``/authorize`` (SDK) calls :meth:`GiteaOAuthProvider.authorize`, which parks
|
|
the client's request under a random request id and sends the browser to our
|
|
consent page.
|
|
2. The consent page (``callback.py``) shows which client asks for access and
|
|
where the result goes. Approving needs a CSRF token that is bound to the
|
|
browser by a ``SameSite=Strict`` cookie. On approval a fresh Gitea ``state``
|
|
and a browser-binding secret are created; the binding secret goes into a
|
|
cookie and the browser is sent to Gitea with our own PKCE challenge.
|
|
3. ``/oauth/callback`` pops the state, checks the binding cookie (so a Gitea
|
|
redirect URL obtained by someone else cannot be completed in a victim's
|
|
browser), exchanges Gitea's code, looks up the user and mints our
|
|
authorization code for the client's registered redirect URI.
|
|
4. ``/token`` (SDK, PKCE checked) calls :meth:`exchange_authorization_code`,
|
|
which issues opaque ``agm_`` access and refresh tokens. Only their SHA-256
|
|
hashes are stored.
|
|
|
|
The consent step exists because Gitea remembers consent for our (single,
|
|
static) Gitea application: without it, anyone could register a client with
|
|
their own redirect URI and turn a victim's click on a link into a token for
|
|
the victim's account (the confused-deputy problem of OAuth proxies).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import secrets
|
|
import time
|
|
import uuid
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, Generic, TypeVar
|
|
from urllib.parse import urlencode, urlsplit
|
|
|
|
from mcp.server.auth.provider import (
|
|
AccessToken,
|
|
AuthorizationCode,
|
|
AuthorizationParams,
|
|
AuthorizeError,
|
|
OAuthAuthorizationServerProvider,
|
|
RefreshToken,
|
|
RegistrationError,
|
|
TokenError,
|
|
construct_redirect_uri,
|
|
)
|
|
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
|
|
from pydantic import AnyHttpUrl, ValidationError
|
|
|
|
from aegis_gitea_mcp.audit import AuditLogger, get_audit_logger
|
|
from aegis_gitea_mcp.auth import MCP_SCOPE
|
|
from aegis_gitea_mcp.config import Settings, redirect_matches_allowlist
|
|
from aegis_gitea_mcp.oauth.store import (
|
|
ClientRecord,
|
|
OAuthStore,
|
|
OAuthStoreError,
|
|
SessionRecord,
|
|
TokenRecord,
|
|
token_hash,
|
|
)
|
|
from aegis_gitea_mcp.oauth.upstream import GiteaUpstream, UpstreamError
|
|
from aegis_gitea_mcp.request_context import get_client_address
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
TOKEN_PREFIX = "agm_" # noqa: S105 (public token prefix, not a secret)
|
|
ACCESS_TOKEN_TTL = 3600
|
|
REFRESH_TOKEN_TTL = 30 * 24 * 3600
|
|
AUTHORIZATION_CODE_TTL = 300
|
|
PENDING_TTL = 600
|
|
MAX_PENDING = 1024
|
|
MAX_CODES = 1024
|
|
MAX_CLIENTS = 500
|
|
MAX_CLIENT_METADATA_BYTES = 8192
|
|
MAX_REDIRECT_URIS = 10
|
|
MAX_SESSIONS_PER_LOGIN = 50
|
|
#: Per client address: sign-ins waiting for consent or Gitea, and registrations per hour.
|
|
MAX_PENDING_PER_SOURCE = 20
|
|
MAX_REGISTRATIONS_PER_SOURCE = 20
|
|
REGISTRATION_WINDOW = 3600
|
|
CONSENT_PATH = "/oauth/consent"
|
|
|
|
_LOOPBACK_REDIRECT_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
|
|
_CLIENT_AUTH_METHODS = frozenset({"none", "client_secret_post", "client_secret_basic"})
|
|
_NOT_ALLOWED = "redirect URI is not on this server's allowlist (OAUTH_REDIRECT_ALLOWLIST)"
|
|
_REDIRECT_RULE = (
|
|
"redirect URIs must use https://, or http:// on localhost, 127.0.0.1 or [::1], "
|
|
"without credentials or a fragment"
|
|
)
|
|
|
|
V = TypeVar("V")
|
|
Clock = Callable[[], float]
|
|
|
|
|
|
def redirect_uri_problem(uri: str, allowlist: list[str] | None = None) -> str | None:
|
|
"""Return why ``uri`` is not an acceptable redirect URI, or None when it is.
|
|
|
|
With a non-empty ``allowlist`` an https URI must also match it; http loopback
|
|
URIs (native clients on the user's own machine) stay allowed.
|
|
"""
|
|
try:
|
|
parts = urlsplit(uri)
|
|
parts.port # noqa: B018 (accessing .port validates it)
|
|
except ValueError:
|
|
return _REDIRECT_RULE
|
|
if parts.fragment or "#" in uri or parts.username is not None or parts.password is not None:
|
|
return _REDIRECT_RULE
|
|
host = (parts.hostname or "").lower()
|
|
if not host:
|
|
return _REDIRECT_RULE
|
|
if parts.scheme == "http" and host in _LOOPBACK_REDIRECT_HOSTS:
|
|
return None
|
|
if parts.scheme == "https":
|
|
return None if redirect_matches_allowlist(uri, allowlist or []) else _NOT_ALLOWED
|
|
return _REDIRECT_RULE
|
|
|
|
|
|
def same_resource(left: str, right: str) -> bool:
|
|
"""Compare two resource URLs the way the SDK's bearer middleware does."""
|
|
try:
|
|
normalized = [str(AnyHttpUrl(value)).removesuffix("/") for value in (left, right)]
|
|
except ValidationError:
|
|
return False
|
|
return normalized[0] == normalized[1]
|
|
|
|
|
|
def flow_tag(state: str) -> str:
|
|
"""Eight hex characters derived from a Gitea ``state`` (names the browser-binding cookie).
|
|
|
|
Concurrent sign-ins in one browser each get their own cookie, so a second flow
|
|
cannot overwrite the binding of the first. The tag is public (it is a hash
|
|
prefix of a value that is in the callback URL anyway) and grants nothing.
|
|
"""
|
|
return token_hash(state)[:8]
|
|
|
|
|
|
def s256(verifier: str) -> str:
|
|
"""PKCE S256 challenge for ``verifier``."""
|
|
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
|
return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
|
|
|
|
|
|
def _matches(value: str | None, expected_hash: str | None) -> bool:
|
|
if not value or not expected_hash:
|
|
return False
|
|
return secrets.compare_digest(token_hash(value), expected_hash)
|
|
|
|
|
|
class _ExpiringMap(Generic[V]):
|
|
"""Bounded in-memory map with per-entry expiry; the oldest entry is evicted when full."""
|
|
|
|
def __init__(self, capacity: int, clock: Clock) -> None:
|
|
self._capacity = capacity
|
|
self._clock = clock
|
|
self._entries: dict[str, tuple[float, V]] = {}
|
|
|
|
def __len__(self) -> int:
|
|
return len(self._entries)
|
|
|
|
def _purge(self) -> None:
|
|
now = self._clock()
|
|
for key in [key for key, (expires, _) in self._entries.items() if expires <= now]:
|
|
del self._entries[key]
|
|
|
|
def put(self, key: str, value: V, ttl: float) -> None:
|
|
self._purge()
|
|
while len(self._entries) >= self._capacity:
|
|
del self._entries[next(iter(self._entries))]
|
|
self._entries[key] = (self._clock() + ttl, value)
|
|
|
|
def count_where(self, predicate: Callable[[V], bool]) -> int:
|
|
"""Number of live entries whose value satisfies ``predicate``."""
|
|
self._purge()
|
|
return sum(1 for _, value in self._entries.values() if predicate(value))
|
|
|
|
def get(self, key: str) -> V | None:
|
|
entry = self._entries.get(key)
|
|
if entry is None:
|
|
return None
|
|
if entry[0] <= self._clock():
|
|
del self._entries[key]
|
|
return None
|
|
return entry[1]
|
|
|
|
def pop(self, key: str) -> V | None:
|
|
value = self.get(key)
|
|
self._entries.pop(key, None)
|
|
return value
|
|
|
|
|
|
@dataclass
|
|
class PendingAuthorization:
|
|
"""A client's authorization request waiting for consent and the Gitea sign-in."""
|
|
|
|
client_id: str
|
|
client_name: str | None
|
|
redirect_uri: str
|
|
redirect_uri_provided_explicitly: bool
|
|
code_challenge: str
|
|
client_state: str | None = field(repr=False)
|
|
scopes: list[str]
|
|
resource: str
|
|
code_verifier: str = field(repr=False)
|
|
csrf_hash: str | None = field(default=None, repr=False)
|
|
binding_hash: str | None = field(default=None, repr=False)
|
|
#: Rate-limit bucket of the client address that started the request (quota key).
|
|
source: str | None = None
|
|
|
|
|
|
class StoredAuthorizationCode(AuthorizationCode):
|
|
"""Our authorization code plus the session it grants (``code`` holds only its hash)."""
|
|
|
|
session_id: str
|
|
|
|
|
|
class StoredRefreshToken(RefreshToken):
|
|
"""A loaded refresh token (``token`` holds only its hash)."""
|
|
|
|
session_id: str
|
|
token_hash: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ConsentDecision:
|
|
"""Where to send the browser after the consent form, plus the binding cookie value."""
|
|
|
|
redirect: str = field(repr=False)
|
|
binding_token: str | None = field(default=None, repr=False)
|
|
#: Short public tag of the Gitea state, used to name this flow's binding cookie.
|
|
flow_tag: str | None = None
|
|
|
|
|
|
class GiteaOAuthProvider(
|
|
OAuthAuthorizationServerProvider[StoredAuthorizationCode, StoredRefreshToken, AccessToken]
|
|
):
|
|
"""The SDK authorization server provider backed by Gitea sign-in.
|
|
|
|
Args:
|
|
settings: Application settings (``PUBLIC_BASE_URL``, ``MCP_PATH``, scopes).
|
|
store: Persistent store for clients, sessions and token hashes.
|
|
upstream: Gitea OAuth client.
|
|
clock: Wall clock (epoch seconds).
|
|
audit: Audit logger for sign-in and revocation events (defaults to the
|
|
process-wide one).
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
settings: Settings,
|
|
store: OAuthStore,
|
|
upstream: GiteaUpstream,
|
|
*,
|
|
clock: Clock = time.time,
|
|
audit: AuditLogger | None = None,
|
|
) -> None:
|
|
self._settings = settings
|
|
self._store = store
|
|
self._upstream = upstream
|
|
self._clock = clock
|
|
self._audit_logger = audit
|
|
base = settings.public_base_url or ""
|
|
self.base_url = base
|
|
self.resource = f"{base}{settings.mcp_path}"
|
|
self._requests: _ExpiringMap[PendingAuthorization] = _ExpiringMap(MAX_PENDING, clock)
|
|
self._states: _ExpiringMap[PendingAuthorization] = _ExpiringMap(MAX_PENDING, clock)
|
|
self._codes: _ExpiringMap[StoredAuthorizationCode] = _ExpiringMap(MAX_CODES, clock)
|
|
# Hashes of exchanged codes -> session id, to detect replays (RFC 6749 4.1.2).
|
|
self._used_codes: _ExpiringMap[str] = _ExpiringMap(MAX_CODES, clock)
|
|
# Registration times per client address (in memory), for the registration quota.
|
|
self._registrations: dict[str, list[float]] = {}
|
|
|
|
# Helpers
|
|
|
|
def _audit(self, event: str, description: str, severity: str, **metadata: Any) -> None:
|
|
try:
|
|
audit = self._audit_logger or get_audit_logger()
|
|
audit.log_security_event(event, description, severity=severity, metadata=metadata)
|
|
except Exception as exc:
|
|
logger.error("audit log write failed: %s", type(exc).__name__)
|
|
|
|
def _end_session(self, session_id: str, event: str, description: str) -> None:
|
|
session = self._store.sessions.get(session_id)
|
|
if self._store.end_session(session_id) and session is not None:
|
|
self._audit(
|
|
event, description, "medium", login=session.login, client_id=session.client_id
|
|
)
|
|
|
|
async def _issue_tokens(
|
|
self, session: SessionRecord, access_scopes: list[str], refresh_scopes: list[str]
|
|
) -> OAuthToken:
|
|
now = self._clock()
|
|
access = TOKEN_PREFIX + secrets.token_urlsafe(32)
|
|
refresh = TOKEN_PREFIX + secrets.token_urlsafe(32)
|
|
self._store.tokens[token_hash(access)] = TokenRecord(
|
|
kind="access",
|
|
session_id=session.session_id,
|
|
client_id=session.client_id,
|
|
scopes=list(access_scopes),
|
|
expires_at=now + ACCESS_TOKEN_TTL,
|
|
)
|
|
self._store.tokens[token_hash(refresh)] = TokenRecord(
|
|
kind="refresh",
|
|
session_id=session.session_id,
|
|
client_id=session.client_id,
|
|
scopes=list(refresh_scopes),
|
|
expires_at=now + REFRESH_TOKEN_TTL,
|
|
)
|
|
await self._store.save()
|
|
return OAuthToken(
|
|
access_token=access,
|
|
token_type="Bearer", # noqa: S106 (token type name)
|
|
expires_in=ACCESS_TOKEN_TTL,
|
|
refresh_token=refresh,
|
|
scope=" ".join(access_scopes),
|
|
)
|
|
|
|
# Client registration
|
|
|
|
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
|
|
"""Return a registered client (and note that it was used)."""
|
|
record = self._store.clients.get(client_id)
|
|
if record is None:
|
|
return None
|
|
record.last_used_at = self._clock()
|
|
return OAuthClientInformationFull.model_validate(record.info)
|
|
|
|
async def register_client(self, client_info: OAuthClientInformationFull) -> None:
|
|
"""Validate and persist a dynamically registered client.
|
|
|
|
Redirect URIs must be https, or http on a loopback host, and match
|
|
``OAUTH_REDIRECT_ALLOWLIST`` when that is set. When
|
|
:data:`MAX_CLIENTS` is reached, the least recently used client without
|
|
any session is evicted; when every client has a session, registration
|
|
is refused.
|
|
|
|
Raises:
|
|
RegistrationError: Invalid redirect URIs, metadata or the client cap.
|
|
"""
|
|
uris = [str(uri) for uri in client_info.redirect_uris or []]
|
|
if not uris or len(uris) > MAX_REDIRECT_URIS:
|
|
raise RegistrationError(
|
|
"invalid_redirect_uri", f"between 1 and {MAX_REDIRECT_URIS} redirect URIs required"
|
|
)
|
|
for uri in uris:
|
|
problem = redirect_uri_problem(uri, self._settings.oauth_redirect_allowlist)
|
|
if problem is not None:
|
|
raise RegistrationError("invalid_redirect_uri", problem)
|
|
if client_info.token_endpoint_auth_method not in _CLIENT_AUTH_METHODS:
|
|
raise RegistrationError(
|
|
"invalid_client_metadata", "unsupported token_endpoint_auth_method"
|
|
)
|
|
info = client_info.model_dump(mode="json", exclude_none=True)
|
|
if len(json.dumps(info)) > MAX_CLIENT_METADATA_BYTES:
|
|
raise RegistrationError("invalid_client_metadata", "client metadata is too large")
|
|
self._check_registration_quota()
|
|
if len(self._store.clients) >= MAX_CLIENTS and not self._evict_one_client():
|
|
logger.warning("oauth client registration refused: client limit reached")
|
|
raise RegistrationError(
|
|
"invalid_client_metadata", "client registration limit reached; try again later"
|
|
)
|
|
now = self._clock()
|
|
self._store.clients[client_info.client_id] = ClientRecord(
|
|
info=info, registered_at=now, last_used_at=now
|
|
)
|
|
await self._store.save()
|
|
logger.info("oauth client registered: %s", client_info.client_id)
|
|
|
|
def _check_registration_quota(self) -> None:
|
|
"""Refuse a registration when this address registered too many clients recently."""
|
|
source = get_client_address()
|
|
if source is None:
|
|
return
|
|
now = self._clock()
|
|
recent = [t for t in self._registrations.get(source, []) if t > now - REGISTRATION_WINDOW]
|
|
if len(recent) >= MAX_REGISTRATIONS_PER_SOURCE:
|
|
self._registrations[source] = recent
|
|
logger.warning("oauth client registration refused: quota of one address reached")
|
|
raise RegistrationError(
|
|
"invalid_client_metadata", "too many registrations from this address; try later"
|
|
)
|
|
recent.append(now)
|
|
self._registrations[source] = recent
|
|
if len(self._registrations) > MAX_CLIENTS * 4:
|
|
for key in [
|
|
k
|
|
for k, times in self._registrations.items()
|
|
if times[-1] <= now - REGISTRATION_WINDOW
|
|
]:
|
|
del self._registrations[key]
|
|
|
|
def _evict_one_client(self) -> bool:
|
|
"""Evict the least recently used client without sessions, never a young one.
|
|
|
|
A client younger than :data:`PENDING_TTL` may still be on its way to its first
|
|
sign-in; evicting it would let a registration flood cancel those sign-ins.
|
|
"""
|
|
now = self._clock()
|
|
candidates = [
|
|
(record.last_used_at, client_id)
|
|
for client_id, record in self._store.clients.items()
|
|
if not self._store.client_has_sessions(client_id)
|
|
and record.registered_at + PENDING_TTL <= now
|
|
]
|
|
if not candidates:
|
|
return False
|
|
_, client_id = min(candidates)
|
|
del self._store.clients[client_id]
|
|
logger.info("oauth client evicted (least recently used, no sessions): %s", client_id)
|
|
return True
|
|
|
|
# Authorization
|
|
|
|
async def authorize(
|
|
self, client: OAuthClientInformationFull, params: AuthorizationParams
|
|
) -> str:
|
|
"""Park the request and send the browser to our consent page.
|
|
|
|
Raises:
|
|
AuthorizeError: ``invalid_target`` when the client asks for a token
|
|
for another resource than this server's MCP endpoint.
|
|
"""
|
|
if params.resource is not None and not same_resource(params.resource, self.resource):
|
|
raise AuthorizeError("invalid_target", "unknown resource")
|
|
# A client registered before the allowlist was set must not slip through it.
|
|
if (
|
|
redirect_uri_problem(str(params.redirect_uri), self._settings.oauth_redirect_allowlist)
|
|
is not None
|
|
):
|
|
raise AuthorizeError("invalid_request", "redirect URI is not allowed")
|
|
source = get_client_address()
|
|
if (
|
|
source is not None
|
|
and self._requests.count_where(lambda item: item.source == source)
|
|
>= MAX_PENDING_PER_SOURCE
|
|
):
|
|
logger.warning("oauth authorize refused: pending sign-in quota of one address reached")
|
|
raise AuthorizeError("temporarily_unavailable", "too many pending sign-ins")
|
|
request_id = secrets.token_urlsafe(32)
|
|
pending = PendingAuthorization(
|
|
client_id=client.client_id,
|
|
client_name=client.client_name,
|
|
redirect_uri=str(params.redirect_uri),
|
|
redirect_uri_provided_explicitly=params.redirect_uri_provided_explicitly,
|
|
code_challenge=params.code_challenge,
|
|
client_state=params.state,
|
|
scopes=list(params.scopes or [MCP_SCOPE]),
|
|
resource=self.resource,
|
|
code_verifier=secrets.token_urlsafe(64),
|
|
source=source,
|
|
)
|
|
self._requests.put(token_hash(request_id), pending, PENDING_TTL)
|
|
return f"{self.base_url}{CONSENT_PATH}?{urlencode({'request': request_id})}"
|
|
|
|
def consent_request(self, request_id: str) -> PendingAuthorization | None:
|
|
"""The pending request shown on the consent page, or None when unknown or expired."""
|
|
return self._requests.get(token_hash(request_id)) if request_id else None
|
|
|
|
def begin_consent(self, request_id: str) -> str | None:
|
|
"""Mint the CSRF token for the consent form (the newest page wins)."""
|
|
pending = self.consent_request(request_id)
|
|
if pending is None:
|
|
return None
|
|
csrf = secrets.token_urlsafe(32)
|
|
pending.csrf_hash = token_hash(csrf)
|
|
return csrf
|
|
|
|
def finish_consent(
|
|
self, request_id: str, csrf_form: str, csrf_cookie: str, *, approve: bool
|
|
) -> ConsentDecision | None:
|
|
"""Apply the consent decision; None when the request or CSRF proof is invalid."""
|
|
key = token_hash(request_id) if request_id else ""
|
|
pending = self._requests.get(key) if key else None
|
|
if pending is None:
|
|
return None
|
|
# Double submit: the form value and the SameSite=Strict cookie must both
|
|
# carry the token minted for this request.
|
|
if not (
|
|
_matches(csrf_form, pending.csrf_hash) and _matches(csrf_cookie, pending.csrf_hash)
|
|
):
|
|
logger.warning("oauth consent rejected: CSRF check failed")
|
|
return None
|
|
self._requests.pop(key)
|
|
if not approve:
|
|
logger.info("oauth consent denied for client %s", pending.client_id)
|
|
return ConsentDecision(
|
|
redirect=construct_redirect_uri(
|
|
pending.redirect_uri, error="access_denied", state=pending.client_state
|
|
)
|
|
)
|
|
state = secrets.token_urlsafe(32)
|
|
binding = secrets.token_urlsafe(32)
|
|
pending.binding_hash = token_hash(binding)
|
|
self._states.put(token_hash(state), pending, PENDING_TTL)
|
|
location = self._upstream.authorize_url(
|
|
state=state, code_challenge=s256(pending.code_verifier)
|
|
)
|
|
return ConsentDecision(redirect=location, binding_token=binding, flow_tag=flow_tag(state))
|
|
|
|
async def complete_callback(
|
|
self, state: str, binding: str, code: str, error: str
|
|
) -> str | None:
|
|
"""Finish the Gitea sign-in; return the client redirect, or None for a bad request.
|
|
|
|
A bad request (unknown, expired or replayed state, or a browser that
|
|
did not start the flow) never redirects anywhere.
|
|
"""
|
|
pending = self._states.pop(token_hash(state)) if state else None
|
|
if pending is None:
|
|
logger.warning("oauth callback rejected: unknown, expired or replayed state")
|
|
return None
|
|
if not _matches(binding, pending.binding_hash):
|
|
logger.warning("oauth callback rejected: the browser did not start this sign-in")
|
|
self._audit(
|
|
"oauth_callback_binding_mismatch",
|
|
"OAuth callback from a browser that did not start the sign-in",
|
|
"high",
|
|
client_id=pending.client_id,
|
|
)
|
|
return None
|
|
if pending.client_id not in self._store.clients:
|
|
logger.warning("oauth callback rejected: client no longer registered")
|
|
return None
|
|
|
|
def back(**params: str | None) -> str:
|
|
return construct_redirect_uri(
|
|
pending.redirect_uri, **params, state=pending.client_state
|
|
)
|
|
|
|
if error:
|
|
logger.info("oauth sign-in cancelled or refused at Gitea")
|
|
return back(error="access_denied")
|
|
if not code:
|
|
logger.warning("oauth sign-in failed: Gitea sent no code")
|
|
return back(error="server_error")
|
|
try:
|
|
tokens = await self._upstream.exchange_code(code, pending.code_verifier)
|
|
user = await self._upstream.fetch_user(tokens.access_token)
|
|
except UpstreamError as exc:
|
|
logger.warning("oauth sign-in failed: %s", exc)
|
|
return back(error="server_error")
|
|
|
|
now = self._clock()
|
|
session = SessionRecord(
|
|
session_id=uuid.uuid4().hex,
|
|
client_id=pending.client_id,
|
|
login=user.login,
|
|
is_admin=user.is_admin,
|
|
gitea_access_token=tokens.access_token,
|
|
gitea_refresh_token=tokens.refresh_token,
|
|
gitea_expires_at=tokens.expires_at,
|
|
scopes=list(pending.scopes),
|
|
resource=pending.resource,
|
|
created_at=now,
|
|
)
|
|
existing = sorted(self._store.sessions_of(user.login), key=lambda item: item.created_at)
|
|
for old in existing[: max(0, len(existing) - MAX_SESSIONS_PER_LOGIN + 1)]:
|
|
self._end_session(
|
|
old.session_id, "oauth_session_evicted", "Oldest OAuth session of the user ended"
|
|
)
|
|
self._store.sessions[session.session_id] = session
|
|
try:
|
|
await self._store.save()
|
|
except (OSError, OAuthStoreError) as exc:
|
|
self._store.sessions.pop(session.session_id, None)
|
|
logger.error(
|
|
"oauth sign-in failed: could not save the session (%s)", type(exc).__name__
|
|
)
|
|
return back(error="server_error")
|
|
|
|
our_code = secrets.token_urlsafe(32)
|
|
self._codes.put(
|
|
token_hash(our_code),
|
|
StoredAuthorizationCode(
|
|
code=token_hash(our_code),
|
|
scopes=list(pending.scopes),
|
|
expires_at=now + AUTHORIZATION_CODE_TTL,
|
|
client_id=pending.client_id,
|
|
code_challenge=pending.code_challenge,
|
|
redirect_uri=pending.redirect_uri, # type: ignore[arg-type]
|
|
redirect_uri_provided_explicitly=pending.redirect_uri_provided_explicitly,
|
|
resource=pending.resource,
|
|
subject=user.login,
|
|
session_id=session.session_id,
|
|
),
|
|
AUTHORIZATION_CODE_TTL,
|
|
)
|
|
logger.info("oauth sign-in: Gitea user %s via client %s", user.login, pending.client_id)
|
|
self._audit(
|
|
"oauth_login",
|
|
"User signed in with Gitea OAuth",
|
|
"low",
|
|
login=user.login,
|
|
client_id=pending.client_id,
|
|
gitea_admin=user.is_admin,
|
|
)
|
|
return back(code=our_code)
|
|
|
|
# Authorization codes
|
|
|
|
async def load_authorization_code(
|
|
self, client: OAuthClientInformationFull, authorization_code: str
|
|
) -> StoredAuthorizationCode | None:
|
|
"""Look up our code; a replayed code revokes the grant it produced."""
|
|
key = token_hash(authorization_code)
|
|
used_by = self._used_codes.get(key)
|
|
if used_by is not None:
|
|
logger.warning("oauth authorization code replayed; revoking the grant")
|
|
self._end_session(
|
|
used_by, "oauth_code_replay", "Authorization code replayed; grant revoked"
|
|
)
|
|
await self._store.save()
|
|
return None
|
|
code = self._codes.get(key)
|
|
if code is None or code.client_id != client.client_id:
|
|
return None
|
|
return code
|
|
|
|
async def exchange_authorization_code(
|
|
self, client: OAuthClientInformationFull, authorization_code: StoredAuthorizationCode
|
|
) -> OAuthToken:
|
|
"""Consume the code (single use) and issue our access and refresh tokens."""
|
|
code = self._codes.pop(authorization_code.code)
|
|
if code is None or code.client_id != client.client_id:
|
|
raise TokenError("invalid_grant", "authorization code does not exist")
|
|
self._used_codes.put(authorization_code.code, code.session_id, AUTHORIZATION_CODE_TTL)
|
|
session = self._store.sessions.get(code.session_id)
|
|
if session is None or session.client_id != client.client_id:
|
|
raise TokenError("invalid_grant", "authorization code does not exist")
|
|
return await self._issue_tokens(session, code.scopes, code.scopes)
|
|
|
|
# Refresh tokens
|
|
|
|
async def load_refresh_token(
|
|
self, client: OAuthClientInformationFull, refresh_token: str
|
|
) -> StoredRefreshToken | None:
|
|
"""Look up a refresh token; reuse of a rotated-out token revokes the grant."""
|
|
if not refresh_token.startswith(TOKEN_PREFIX):
|
|
return None
|
|
key = token_hash(refresh_token)
|
|
record = self._store.tokens.get(key)
|
|
if record is None:
|
|
reused = self._store.session_by_previous_refresh(key)
|
|
if reused is not None:
|
|
logger.warning("oauth refresh token reused; revoking the grant")
|
|
self._end_session(
|
|
reused.session_id,
|
|
"oauth_refresh_reuse",
|
|
"Rotated refresh token reused; grant revoked",
|
|
)
|
|
await self._store.save()
|
|
return None
|
|
session = self._store.sessions.get(record.session_id)
|
|
if record.kind != "refresh" or session is None or record.client_id != client.client_id:
|
|
return None
|
|
if record.expires_at <= self._clock():
|
|
return None
|
|
return StoredRefreshToken(
|
|
token=f"sha256:{key}",
|
|
client_id=record.client_id,
|
|
scopes=list(record.scopes),
|
|
expires_at=int(record.expires_at),
|
|
resource=session.resource,
|
|
subject=session.login,
|
|
session_id=record.session_id,
|
|
token_hash=key,
|
|
)
|
|
|
|
async def exchange_refresh_token(
|
|
self,
|
|
client: OAuthClientInformationFull,
|
|
refresh_token: StoredRefreshToken,
|
|
scopes: list[str],
|
|
) -> OAuthToken:
|
|
"""Rotate: revoke the old pair and issue a new pair for the same session."""
|
|
record = self._store.tokens.get(refresh_token.token_hash)
|
|
session = self._store.sessions.get(refresh_token.session_id)
|
|
if (
|
|
record is None
|
|
or record.kind != "refresh"
|
|
or record.client_id != client.client_id
|
|
or session is None
|
|
):
|
|
raise TokenError("invalid_grant", "refresh token does not exist")
|
|
for key in self._store.session_token_hashes(session.session_id):
|
|
del self._store.tokens[key]
|
|
session.previous_refresh_hash = refresh_token.token_hash
|
|
logger.info("oauth tokens refreshed for %s", session.login)
|
|
return await self._issue_tokens(session, scopes, record.scopes)
|
|
|
|
# Access tokens and revocation
|
|
|
|
async def load_access_token(self, token: str) -> AccessToken | None:
|
|
"""Verify one of our access tokens (``token`` on the result is only a hash)."""
|
|
if not token.startswith(TOKEN_PREFIX):
|
|
return None
|
|
key = token_hash(token)
|
|
record = self._store.tokens.get(key)
|
|
if record is None or record.kind != "access" or record.expires_at <= self._clock():
|
|
return None
|
|
session = self._store.sessions.get(record.session_id)
|
|
if session is None:
|
|
return None
|
|
return AccessToken(
|
|
token=f"sha256:{key}",
|
|
client_id=record.client_id,
|
|
scopes=list(record.scopes),
|
|
expires_at=int(record.expires_at),
|
|
resource=session.resource,
|
|
subject=session.login,
|
|
claims={"auth": "oauth", "session_id": session.session_id},
|
|
)
|
|
|
|
async def revoke_token(self, token: AccessToken | RefreshToken) -> None:
|
|
"""Revoke the whole grant (access and refresh tokens and the Gitea session)."""
|
|
if isinstance(token, StoredRefreshToken):
|
|
session_id: Any = token.session_id
|
|
elif isinstance(token, AccessToken):
|
|
session_id = (token.claims or {}).get("session_id")
|
|
else:
|
|
return
|
|
if not isinstance(session_id, str):
|
|
return
|
|
self._end_session(session_id, "oauth_revoke", "OAuth grant revoked by the client")
|
|
await self._store.save()
|
|
logger.info("oauth grant revoked")
|
|
|
|
|
|
class OAuthTokenVerifier:
|
|
"""``TokenVerifier`` for our issued access tokens (anything else is not ours)."""
|
|
|
|
def __init__(self, provider: GiteaOAuthProvider) -> None:
|
|
self._provider = provider
|
|
|
|
async def verify_token(self, token: str) -> AccessToken | None:
|
|
"""Return the access token for an issued ``agm_`` token, else None."""
|
|
if not token.startswith(TOKEN_PREFIX):
|
|
return None
|
|
return await self._provider.load_access_token(token)
|