๐Ÿ“ฆ EqualifyEverything / equalify-reflow

๐Ÿ“„ base.py ยท 86 lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86"""Shared auth types: AuthMode enum, Identity model, AuthProvider Protocol."""

from __future__ import annotations

from datetime import datetime
from enum import Enum
from typing import Protocol, runtime_checkable

from fastapi import Request
from pydantic import BaseModel, Field


class AuthMode(str, Enum):
    """How identities are established for incoming requests.

    ``NONE`` keeps the open default โ€” no login, no cookies, no identity.
    ``BASIC`` checks a username/password against operator-provisioned env
    entries. ``OIDC`` runs a generic authorisation-code-with-PKCE flow against
    any IdP that publishes an OpenID Connect discovery document (Entra,
    Google, Okta, Auth0, Keycloak, ...).
    """

    NONE = "none"
    BASIC = "basic"
    OIDC = "oidc"


class Identity(BaseModel):
    """Authenticated user identity carried on ``request.state.identity``.

    Deliberately minimal: ``sub`` (stable IdP-issued user id), email, display
    name, originating provider, and the issuance/expiry pair we use for
    sliding-window cookie re-issue. Roles/groups/scopes are explicitly out of
    scope for the first cut โ€” adding them is an additive change to this model
    plus a ``RequireGroups`` dependency.
    """

    sub: str = Field(description="Stable user identifier from the auth provider")
    email: str | None = Field(default=None, description="Verified email address, when the IdP supplies one")
    name: str | None = Field(default=None, description="Display name, when the IdP supplies one")
    provider_id: str = Field(description="Identifier of the provider that authenticated this session")
    issued_at: datetime = Field(description="When the session was minted")
    expires_at: datetime = Field(description="When the session expires absent re-issue")


@runtime_checkable
class AuthProvider(Protocol):
    """Strategy interface for authenticating a request.

    Concrete implementations live under ``src/auth/providers/``. The provider
    selected by ``settings.auth_mode`` is the one wired into routes and
    middleware at app startup.

    SSO providers serve all of ``login_url`` and ``handle_callback`` to drive
    the authorisation-code round trip. The basic provider short-circuits:
    ``login_url`` is unused (the SPA POSTs to ``/auth/login`` directly) and
    ``handle_callback`` raises. ``logout_url`` is optional โ€” when an OIDC IdP
    advertises an ``end_session_endpoint`` we surface it so the SPA can
    optionally navigate the user there.
    """

    id: str
    """Stable identifier used in URLs (e.g. ``basic``, ``entra``, ``google``)."""

    display_name: str
    """Human-readable label rendered on the SPA login page."""

    async def login_url(self, *, request: Request, next_path: str) -> str:
        """Return the URL the SPA should navigate the user to in order to begin
        login. For OIDC this is the IdP authorisation endpoint with PKCE
        parameters. Not used by the basic provider.
        """
        ...

    async def handle_callback(self, request: Request) -> Identity:
        """Validate the IdP's redirect-back, derive an :class:`Identity`. OIDC
        only โ€” basic raises ``NotImplementedError``.
        """
        ...

    async def logout_url(self, identity: Identity) -> str | None:
        """Optional IdP-side logout URL. Returns ``None`` for providers that
        don't advertise one (basic, IdPs without ``end_session_endpoint``).
        """
        ...