227 lines
7.9 KiB
Python
227 lines
7.9 KiB
Python
"""Gateway-brokered RFC 8252 (OAuth 2.0 for Native Apps) authorization store.
|
|
|
|
The desktop cannot be a direct OAuth client of the upstream IDP (the Portal
|
|
``client_id`` is per gateway instance and only accepts the gateway's own
|
|
``/auth/callback`` redirect), so the gateway brokers: it is the authorization
|
|
server *to the desktop* and an OAuth client *to the Portal* — still textbook
|
|
RFC 8252: system browser, loopback redirect, PKCE, tokens returned to the app
|
|
and never as cookies.
|
|
|
|
1. Desktop generates its own PKCE pair (cv_d, cc_d) + ``state``, opens a
|
|
loopback listener, and opens the system browser at
|
|
``/auth/native/authorize`` with cc_d, state and its loopback redirect_uri.
|
|
2. The gateway stashes a *pending authorization* (:func:`register_pending`)
|
|
keyed by an opaque ``broker_state`` and runs the EXISTING upstream flow;
|
|
broker_state rides inside the gateway's own PKCE cookie, so no desktop
|
|
secret reaches the Portal.
|
|
3. On the upstream callback (or a successful password login) the gateway
|
|
mints a one-time gateway code bound to cc_d (:func:`complete_pending`) and
|
|
302s the browser to ``redirect_uri?code=<gw_code>&state=<state>``.
|
|
4. The desktop POSTs ``/auth/native/token`` with gw_code + cv_d; the gateway
|
|
checks ``S256(cv_d) == cc_d`` (:func:`redeem_code`), consumes the code and
|
|
returns the upstream tokens in the JSON body.
|
|
5. The desktop keeps them in the OS keychain and uses ``Authorization: Bearer``.
|
|
|
|
Security properties: PKCE binding (an intercepted gw_code is useless without
|
|
cv_d), single use (redemption pops the entry), short TTLs, 256-bit opaque
|
|
handles compared in constant time, no secret logging. In-memory and
|
|
process-local (single dashboard process); functional API keeps ``time.time``
|
|
patchable in tests.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import hashlib
|
|
import hmac
|
|
import secrets
|
|
import threading
|
|
import time
|
|
from dataclasses import dataclass
|
|
from typing import Dict, Optional
|
|
|
|
from hermes_cli.dashboard_auth.base import Session
|
|
|
|
# Pending authorization: the whole interactive login (mirrors the PKCE cookie).
|
|
_PENDING_TTL_SECONDS = 600
|
|
# Minted code: only the loopback redirect + immediate token POST.
|
|
_CODE_TTL_SECONDS = 120
|
|
# Global cap so a misbehaving client cannot grow the store unbounded.
|
|
_MAX_ENTRIES = 256
|
|
# Per-IP cap on PENDING entries: /auth/native/authorize is a public pre-auth
|
|
# route, so one spammer must not fill the global store (600 s each) and lock
|
|
# out legitimate native logins.
|
|
_MAX_PENDING_PER_IP = 8
|
|
|
|
_lock = threading.Lock()
|
|
|
|
|
|
@dataclass
|
|
class _Pending:
|
|
"""In-flight native authorization awaiting the upstream callback."""
|
|
|
|
code_challenge: str # the DESKTOP's S256 challenge (cc_d), base64url no-pad
|
|
redirect_uri: str # the desktop's loopback redirect (127.0.0.1:<port>/...)
|
|
client_state: str # the desktop's own ``state`` (echoed back on redirect)
|
|
client_ip: str # requester IP at authorize time (per-IP pending cap)
|
|
expires_at: int
|
|
|
|
|
|
@dataclass
|
|
class _IssuedCode:
|
|
"""A minted one-time gateway authorization code bound to a Session."""
|
|
|
|
code_challenge: str # cc_d — verified against cv_d at redemption
|
|
session: Session
|
|
expires_at: int
|
|
|
|
|
|
_pending: Dict[str, _Pending] = {} # broker_state -> _Pending
|
|
_issued: Dict[str, _IssuedCode] = {} # gw_code -> _IssuedCode
|
|
|
|
|
|
class NativeFlowError(Exception):
|
|
"""Base for native-flow failures (bad/expired/replayed handle, PKCE fail)."""
|
|
|
|
|
|
class PendingNotFound(NativeFlowError):
|
|
"""The broker_state is unknown or expired (login window lapsed)."""
|
|
|
|
|
|
class CodeInvalid(NativeFlowError):
|
|
"""The gateway code is unknown, expired, already redeemed, or PKCE-mismatched."""
|
|
|
|
|
|
def _b64url_no_pad(raw: bytes) -> str:
|
|
"""Base64url without ``=`` padding (RFC 7636 §4)."""
|
|
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
|
|
|
|
|
def _s256(verifier: str) -> str:
|
|
"""RFC 7636 S256 transform: base64url(sha256(ascii(verifier)))."""
|
|
return _b64url_no_pad(hashlib.sha256(verifier.encode("ascii")).digest())
|
|
|
|
|
|
def _gc_locked(now: int) -> None:
|
|
"""Drop expired pending + issued entries. Caller holds ``_lock``."""
|
|
for store in (_pending, _issued):
|
|
for k in [k for k, v in store.items() if v.expires_at < now]:
|
|
store.pop(k, None)
|
|
|
|
|
|
def _capacity_ok_locked() -> bool:
|
|
return (len(_pending) + len(_issued)) < _MAX_ENTRIES
|
|
|
|
|
|
def _now(now: Optional[int]) -> int:
|
|
return int(time.time()) if now is None else now
|
|
|
|
|
|
def register_pending(
|
|
*,
|
|
code_challenge: str,
|
|
redirect_uri: str,
|
|
client_state: str,
|
|
client_ip: str = "",
|
|
now: Optional[int] = None,
|
|
) -> str:
|
|
"""Stash a pending native authorization; return an opaque ``broker_state``.
|
|
|
|
``code_challenge`` is the DESKTOP's cc_d (the verifier is never seen until
|
|
redemption). Raises ``NativeFlowError`` (fail closed) when the store is at
|
|
capacity or ``client_ip`` already holds ``_MAX_PENDING_PER_IP`` entries.
|
|
"""
|
|
now = _now(now)
|
|
broker_state = secrets.token_urlsafe(32)
|
|
with _lock:
|
|
_gc_locked(now)
|
|
if not _capacity_ok_locked():
|
|
raise NativeFlowError("native-flow authorization store at capacity")
|
|
if client_ip and (
|
|
sum(1 for v in _pending.values() if v.client_ip == client_ip)
|
|
>= _MAX_PENDING_PER_IP
|
|
):
|
|
raise NativeFlowError(
|
|
"too many pending native authorizations from this address"
|
|
)
|
|
_pending[broker_state] = _Pending(
|
|
code_challenge=code_challenge,
|
|
redirect_uri=redirect_uri,
|
|
client_state=client_state,
|
|
client_ip=client_ip,
|
|
expires_at=now + _PENDING_TTL_SECONDS,
|
|
)
|
|
return broker_state
|
|
|
|
|
|
def get_pending(broker_state: str, *, now: Optional[int] = None) -> _Pending:
|
|
"""Peek (without consuming) the pending authorization; raises
|
|
:class:`PendingNotFound` if unknown or expired."""
|
|
now = _now(now)
|
|
with _lock:
|
|
_gc_locked(now)
|
|
entry = _pending.get(broker_state)
|
|
if entry is None:
|
|
raise PendingNotFound("unknown or expired native authorization")
|
|
return entry
|
|
|
|
|
|
def complete_pending(
|
|
broker_state: str,
|
|
*,
|
|
session: Session,
|
|
now: Optional[int] = None,
|
|
) -> str:
|
|
"""Consume a pending authorization (single use) and mint a one-time gateway
|
|
code bound to the desktop's challenge + the verified ``session``.
|
|
|
|
Raises :class:`PendingNotFound` if the broker_state is unknown/expired.
|
|
"""
|
|
now = _now(now)
|
|
with _lock:
|
|
_gc_locked(now)
|
|
pending = _pending.pop(broker_state, None)
|
|
if pending is None:
|
|
raise PendingNotFound("unknown or expired native authorization")
|
|
if not _capacity_ok_locked():
|
|
raise NativeFlowError("native-flow code store at capacity")
|
|
gw_code = secrets.token_urlsafe(32)
|
|
_issued[gw_code] = _IssuedCode(
|
|
code_challenge=pending.code_challenge,
|
|
session=session,
|
|
expires_at=now + _CODE_TTL_SECONDS,
|
|
)
|
|
return gw_code
|
|
|
|
|
|
def redeem_code(
|
|
*,
|
|
code: str,
|
|
code_verifier: str,
|
|
now: Optional[int] = None,
|
|
) -> Session:
|
|
"""Verify PKCE + consume a gateway code; return the bound :class:`Session`.
|
|
|
|
The entry is popped BEFORE the PKCE check so a wrong verifier cannot be
|
|
retried against the same code: on any failure the code is already consumed
|
|
(no oracle, no replay). Raises :class:`CodeInvalid`.
|
|
"""
|
|
now = _now(now)
|
|
with _lock:
|
|
_gc_locked(now)
|
|
issued = _issued.pop(code, None)
|
|
if issued is None:
|
|
raise CodeInvalid("unknown, expired, or already-redeemed code")
|
|
if issued.expires_at < now:
|
|
raise CodeInvalid("code expired")
|
|
if not hmac.compare_digest(issued.code_challenge, _s256(code_verifier)):
|
|
raise CodeInvalid("PKCE verification failed")
|
|
return issued.session
|
|
|
|
|
|
def _reset_for_tests() -> None:
|
|
"""Test-only: drop all pending + issued state."""
|
|
with _lock:
|
|
_pending.clear()
|
|
_issued.clear()
|