Files
AegisGitea-MCP/src/aegis_gitea_mcp/oauth/provider.py
T

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)