feat: add per-user OAuth mode proxying sign-in to Gitea
Authorization server for MCP clients (SDK provider with dynamic client registration, PKCE, refresh rotation and revocation) that signs users in through Gitea and runs tool calls with the user's own Gitea token. - consent page with CSRF protection and a browser-binding cookie on the Gitea callback (confused-deputy protection for the shared Gitea app) - encrypted token store (Fernet, HKDF-derived key, 0600, atomic writes, symlink and corrupt-file refusal); our tokens stored only as hashes - tokens bound to the MCP endpoint resource and the gitea scope; the static bearer keeps working next to OAuth - code replay and refresh token reuse revoke the grant - automatic upstream refresh with per-session locking; invalid_grant ends the session and never falls back to the admin token - OAuth-only deployments (no GITEA_TOKEN, no bearer) may start - rate limit on the OAuth endpoints, query strings dropped from access logs
This commit is contained in:
1 parent
5e144772b2
commit
566d0b65dd
10 files changed
+1813
-31
No files matched your search
@@ -0,0 +1,670 @@
|
||||
"""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
|
||||
from aegis_gitea_mcp.oauth.store import (
|
||||
ClientRecord,
|
||||
OAuthStore,
|
||||
OAuthStoreError,
|
||||
SessionRecord,
|
||||
TokenRecord,
|
||||
token_hash,
|
||||
)
|
||||
from aegis_gitea_mcp.oauth.upstream import GiteaUpstream, UpstreamError
|
||||
|
||||
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
|
||||
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"})
|
||||
_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) -> str | None:
|
||||
"""Return why ``uri`` is not an acceptable redirect URI, or None when it is."""
|
||||
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 == "https":
|
||||
return None
|
||||
if parts.scheme == "http" and host in _LOOPBACK_REDIRECT_HOSTS:
|
||||
return None
|
||||
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 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 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)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
# 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. 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)
|
||||
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")
|
||||
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 _evict_one_client(self) -> bool:
|
||||
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)
|
||||
]
|
||||
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")
|
||||
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),
|
||||
)
|
||||
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)
|
||||
|
||||
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)
|
||||
Reference in new issue
Block a user