fastapi-startkit-auth 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. fastapi_startkit_auth/__init__.py +67 -0
  2. fastapi_startkit_auth/apitokens/__init__.py +13 -0
  3. fastapi_startkit_auth/apitokens/manager.py +122 -0
  4. fastapi_startkit_auth/apitokens/models.py +29 -0
  5. fastapi_startkit_auth/apitokens/repository.py +120 -0
  6. fastapi_startkit_auth/apitokens/sql.py +152 -0
  7. fastapi_startkit_auth/application.py +38 -0
  8. fastapi_startkit_auth/clients/__init__.py +4 -0
  9. fastapi_startkit_auth/clients/models.py +29 -0
  10. fastapi_startkit_auth/clients/repository.py +69 -0
  11. fastapi_startkit_auth/config.py +103 -0
  12. fastapi_startkit_auth/dependencies.py +93 -0
  13. fastapi_startkit_auth/exceptions.py +90 -0
  14. fastapi_startkit_auth/facade.py +107 -0
  15. fastapi_startkit_auth/grants/__init__.py +13 -0
  16. fastapi_startkit_auth/grants/authorization_code.py +83 -0
  17. fastapi_startkit_auth/grants/client_credentials.py +19 -0
  18. fastapi_startkit_auth/grants/password.py +32 -0
  19. fastapi_startkit_auth/grants/pkce.py +27 -0
  20. fastapi_startkit_auth/grants/refresh.py +13 -0
  21. fastapi_startkit_auth/guards/__init__.py +5 -0
  22. fastapi_startkit_auth/guards/guard.py +85 -0
  23. fastapi_startkit_auth/guards/session.py +81 -0
  24. fastapi_startkit_auth/guards/token.py +58 -0
  25. fastapi_startkit_auth/manager.py +320 -0
  26. fastapi_startkit_auth/middleware/__init__.py +4 -0
  27. fastapi_startkit_auth/middleware/csrf.py +160 -0
  28. fastapi_startkit_auth/middleware/session.py +117 -0
  29. fastapi_startkit_auth/passwords/__init__.py +4 -0
  30. fastapi_startkit_auth/passwords/broker.py +69 -0
  31. fastapi_startkit_auth/passwords/repository.py +44 -0
  32. fastapi_startkit_auth/provider.py +69 -0
  33. fastapi_startkit_auth/providers/__init__.py +4 -0
  34. fastapi_startkit_auth/providers/base.py +38 -0
  35. fastapi_startkit_auth/providers/memory.py +69 -0
  36. fastapi_startkit_auth/providers/model.py +70 -0
  37. fastapi_startkit_auth/publishable/cors.py +44 -0
  38. fastapi_startkit_auth/py.typed +0 -0
  39. fastapi_startkit_auth/routes.py +362 -0
  40. fastapi_startkit_auth/routes_spa.py +34 -0
  41. fastapi_startkit_auth/security/__init__.py +4 -0
  42. fastapi_startkit_auth/security/hashing.py +43 -0
  43. fastapi_startkit_auth/security/jwt.py +49 -0
  44. fastapi_startkit_auth/sessions/__init__.py +5 -0
  45. fastapi_startkit_auth/sessions/models.py +29 -0
  46. fastapi_startkit_auth/sessions/sql.py +158 -0
  47. fastapi_startkit_auth/sessions/state.py +11 -0
  48. fastapi_startkit_auth/sessions/store.py +100 -0
  49. fastapi_startkit_auth/startkit.py +38 -0
  50. fastapi_startkit_auth/tokens/__init__.py +9 -0
  51. fastapi_startkit_auth/tokens/models.py +68 -0
  52. fastapi_startkit_auth/tokens/repository.py +129 -0
  53. fastapi_startkit_auth/tokens/service.py +189 -0
  54. fastapi_startkit_auth-0.3.0.dist-info/METADATA +243 -0
  55. fastapi_startkit_auth-0.3.0.dist-info/RECORD +57 -0
  56. fastapi_startkit_auth-0.3.0.dist-info/WHEEL +4 -0
  57. fastapi_startkit_auth-0.3.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,67 @@
1
+ """Passport-style OAuth2 + JWT authentication for FastAPI.
2
+
3
+ Public API mirrors the config-driven guard/provider/passwords model of Laravel
4
+ Passport while staying idiomatic FastAPI:
5
+
6
+ from fastapi_startkit_auth import Application, AuthProvider, AuthConfig
7
+
8
+ Attributes are resolved lazily so importing a single submodule never forces the
9
+ whole package to load.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import importlib
14
+ from typing import Any
15
+
16
+ __version__ = "0.2.0"
17
+
18
+ _EXPORTS = {
19
+ "AuthConfig": ("fastapi_startkit_auth.config", "AuthConfig"),
20
+ "AuthProvider": ("fastapi_startkit_auth.provider", "AuthProvider"),
21
+ "Application": ("fastapi_startkit_auth.application", "Application"),
22
+ "AuthManager": ("fastapi_startkit_auth.manager", "AuthManager"),
23
+ "current_user": ("fastapi_startkit_auth.dependencies", "current_user"),
24
+ "optional_user": ("fastapi_startkit_auth.dependencies", "optional_user"),
25
+ "require_scopes": ("fastapi_startkit_auth.dependencies", "require_scopes"),
26
+ "require_abilities": ("fastapi_startkit_auth.dependencies", "require_abilities"),
27
+ "AuthError": ("fastapi_startkit_auth.exceptions", "AuthError"),
28
+ "InvalidGrant": ("fastapi_startkit_auth.exceptions", "InvalidGrant"),
29
+ "InvalidClient": ("fastapi_startkit_auth.exceptions", "InvalidClient"),
30
+ "InvalidToken": ("fastapi_startkit_auth.exceptions", "InvalidToken"),
31
+ "InsufficientScope": ("fastapi_startkit_auth.exceptions", "InsufficientScope"),
32
+ "InvalidSession": ("fastapi_startkit_auth.exceptions", "InvalidSession"),
33
+ "CsrfTokenMismatch": ("fastapi_startkit_auth.exceptions", "CsrfTokenMismatch"),
34
+ "Auth": ("fastapi_startkit_auth.facade", "Auth"),
35
+ "SessionGuard": ("fastapi_startkit_auth.guards.session", "SessionGuard"),
36
+ "SessionMiddleware": ("fastapi_startkit_auth.middleware.session", "SessionMiddleware"),
37
+ "CsrfMiddleware": ("fastapi_startkit_auth.middleware.csrf", "CsrfMiddleware"),
38
+ "AuthServiceProvider": ("fastapi_startkit_auth.startkit", "AuthServiceProvider"),
39
+ "SessionRecord": ("fastapi_startkit_auth.sessions.models", "SessionRecord"),
40
+ "SessionStore": ("fastapi_startkit_auth.sessions.store", "SessionStore"),
41
+ "InMemorySessionStore": ("fastapi_startkit_auth.sessions.store", "InMemorySessionStore"),
42
+ "SqlSessionStore": ("fastapi_startkit_auth.sessions.sql", "SqlSessionStore"),
43
+ "TokenGuard": ("fastapi_startkit_auth.guards.token", "TokenGuard"),
44
+ "ApiTokenManager": ("fastapi_startkit_auth.apitokens.manager", "ApiTokenManager"),
45
+ "NewApiToken": ("fastapi_startkit_auth.apitokens.manager", "NewApiToken"),
46
+ "ApiTokenRecord": ("fastapi_startkit_auth.apitokens.models", "ApiTokenRecord"),
47
+ "ApiTokenRepository": ("fastapi_startkit_auth.apitokens.repository", "ApiTokenRepository"),
48
+ "InMemoryApiTokenRepository": (
49
+ "fastapi_startkit_auth.apitokens.repository",
50
+ "InMemoryApiTokenRepository",
51
+ ),
52
+ "SqlApiTokenRepository": ("fastapi_startkit_auth.apitokens.sql", "SqlApiTokenRepository"),
53
+ }
54
+
55
+ __all__ = list(_EXPORTS)
56
+
57
+
58
+ def __getattr__(name: str) -> Any:
59
+ try:
60
+ module_name, attr = _EXPORTS[name]
61
+ except KeyError:
62
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None
63
+ return getattr(importlib.import_module(module_name), attr)
64
+
65
+
66
+ def __dir__() -> list[str]:
67
+ return sorted(__all__)
@@ -0,0 +1,13 @@
1
+ from .manager import ApiTokenManager, NewApiToken
2
+ from .models import ApiTokenRecord
3
+ from .repository import ApiTokenRepository, InMemoryApiTokenRepository
4
+ from .sql import SqlApiTokenRepository
5
+
6
+ __all__ = [
7
+ "ApiTokenManager",
8
+ "ApiTokenRecord",
9
+ "ApiTokenRepository",
10
+ "InMemoryApiTokenRepository",
11
+ "NewApiToken",
12
+ "SqlApiTokenRepository",
13
+ ]
@@ -0,0 +1,122 @@
1
+ from __future__ import annotations
2
+
3
+ import secrets
4
+ import threading
5
+ import time
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ from ..exceptions import InvalidToken
10
+ from .models import ApiTokenRecord
11
+ from .repository import ApiTokenRepository, generate_token_secret, hash_token_secret
12
+
13
+ # Every verification failure raises this exact message so responses (and their
14
+ # timing, via the dummy compare below) never reveal whether the id exists.
15
+ _GENERIC_FAILURE = "The API token is invalid."
16
+
17
+ _DUMMY_HASH = hash_token_secret("!" + generate_token_secret())
18
+
19
+
20
+ @dataclass(repr=False)
21
+ class NewApiToken:
22
+ """The one and only carrier of a token's plaintext.
23
+
24
+ ``plain_text`` (``"{id}|{secret}"``) exists here and nowhere else — the
25
+ repository stores only the secret's hash, so it cannot be shown again.
26
+ """
27
+
28
+ record: ApiTokenRecord
29
+ plain_text: str
30
+
31
+ def __repr__(self) -> str:
32
+ # The dataclass auto-repr would leak the live secret into logs,
33
+ # error-tracker locals, and test output; show only the id half.
34
+ return (
35
+ f"NewApiToken(record={self.record!r}, plain_text='{self.record.id}|***redacted***')"
36
+ )
37
+
38
+
39
+ class ApiTokenManager:
40
+ """Issues, verifies, revokes, and lists opaque API tokens.
41
+
42
+ Exposed as ``AuthManager.api_tokens``; apps wire their own management
43
+ routes around it (the package ships none, per the app-owns-routes
44
+ decision).
45
+ """
46
+
47
+ def __init__(
48
+ self,
49
+ repository: ApiTokenRepository,
50
+ default_ttl: float | None = None,
51
+ purge_interval: float = 300,
52
+ ) -> None:
53
+ self.repository = repository
54
+ self._default_ttl = default_ttl
55
+ self._purge_interval = purge_interval
56
+ self._purge_lock = threading.Lock()
57
+ self._last_purge = time.time()
58
+
59
+ def _maybe_purge(self) -> None:
60
+ """Drop expired records periodically so stores stay bounded without a
61
+ scheduler (mirrors the purge-on-issue pattern of ``TokenService``)."""
62
+ now = time.time()
63
+ with self._purge_lock:
64
+ if now - self._last_purge < self._purge_interval:
65
+ return
66
+ self._last_purge = now
67
+ self.repository.purge_expired()
68
+
69
+ def create(
70
+ self,
71
+ user_id: Any,
72
+ name: str | None = None,
73
+ abilities: list[str] | None = None,
74
+ expires_at: float | None = None,
75
+ ) -> NewApiToken:
76
+ """Issue a token for ``user_id``; the plaintext is returned exactly once.
77
+
78
+ ``expires_at`` overrides the configured default ttl; ``None`` with no
79
+ configured ttl means the token never expires. ``abilities`` default to
80
+ the unrestricted ``["*"]``.
81
+ """
82
+ self._maybe_purge()
83
+ if expires_at is None and self._default_ttl is not None:
84
+ expires_at = time.time() + self._default_ttl
85
+ secret = generate_token_secret()
86
+ record = self.repository.create(
87
+ user_id=user_id,
88
+ token_hash=hash_token_secret(secret),
89
+ name=name,
90
+ abilities=list(abilities) if abilities else ["*"],
91
+ expires_at=expires_at,
92
+ )
93
+ return NewApiToken(record=record, plain_text=f"{record.id}|{secret}")
94
+
95
+ def verify(self, token: str) -> ApiTokenRecord:
96
+ """Resolve ``"{id}|{secret}"`` to its live record or raise 401.
97
+
98
+ Lookup is by id (O(1), no scan); the secret comparison is constant-time
99
+ against the stored hash — or against a dummy hash when the id is
100
+ unknown, so the miss path costs the same as a mismatch.
101
+ """
102
+ token_id, sep, secret = token.partition("|")
103
+ expected = _DUMMY_HASH
104
+ record = None
105
+ if sep and token_id and secret:
106
+ record = self.repository.find(token_id)
107
+ if record is not None:
108
+ expected = record.token_hash
109
+ matched = secrets.compare_digest(hash_token_secret(secret), expected)
110
+ if record is None or not matched or record.expired:
111
+ raise InvalidToken(_GENERIC_FAILURE)
112
+ self.repository.touch(record.id)
113
+ return record
114
+
115
+ def revoke(self, token_id: str) -> bool:
116
+ return self.repository.revoke(token_id)
117
+
118
+ def revoke_all(self, user_id: Any) -> int:
119
+ return self.repository.revoke_all_for_user(user_id)
120
+
121
+ def tokens_for(self, user_id: Any) -> list[ApiTokenRecord]:
122
+ return self.repository.list_for_user(user_id)
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from dataclasses import dataclass, field
5
+ from typing import Any
6
+
7
+
8
+ @dataclass
9
+ class ApiTokenRecord:
10
+ """Server-side record backing one opaque API token.
11
+
12
+ The caller holds ``"{id}|{secret}"``; this record stores only the SHA-256
13
+ hex digest of the secret, so a leaked store cannot mint valid tokens.
14
+ ``abilities`` reuse the scope vocabulary (``["*"]`` = unrestricted), so
15
+ ``require_scopes`` works unchanged on token-authenticated requests.
16
+ """
17
+
18
+ id: str
19
+ user_id: Any
20
+ token_hash: str
21
+ name: str | None = None
22
+ abilities: list[str] = field(default_factory=lambda: ["*"])
23
+ last_used_at: float | None = None
24
+ expires_at: float | None = None
25
+ created_at: float = field(default_factory=lambda: time.time())
26
+
27
+ @property
28
+ def expired(self) -> bool:
29
+ return self.expires_at is not None and self.expires_at < time.time()
@@ -0,0 +1,120 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import secrets
5
+ import time
6
+ from typing import Any, Protocol, runtime_checkable
7
+
8
+ from .models import ApiTokenRecord
9
+
10
+
11
+ def generate_token_id() -> str:
12
+ return secrets.token_urlsafe(16)
13
+
14
+
15
+ def generate_token_secret() -> str:
16
+ return secrets.token_urlsafe(40)
17
+
18
+
19
+ def hash_token_secret(secret: str) -> str:
20
+ """SHA-256 at rest: correct for high-entropy random secrets (bcrypt would
21
+ add per-request latency without adding security for non-guessable input)."""
22
+ return hashlib.sha256(secret.encode()).hexdigest()
23
+
24
+
25
+ @runtime_checkable
26
+ class ApiTokenRepository(Protocol):
27
+ """Contract every API-token backend satisfies.
28
+
29
+ Mirrors the ``SessionStore`` pattern: the package ships an in-memory
30
+ default and a SQL implementation, and apps may implement the same surface
31
+ against any other store. Implementations must never see or persist the
32
+ plaintext secret — only its hash arrives here.
33
+ """
34
+
35
+ def create(
36
+ self,
37
+ *,
38
+ user_id: Any,
39
+ token_hash: str,
40
+ name: str | None,
41
+ abilities: list[str],
42
+ expires_at: float | None,
43
+ ) -> ApiTokenRecord:
44
+ """Persist and return a new token record with a fresh id."""
45
+
46
+ def find(self, token_id: str) -> ApiTokenRecord | None:
47
+ """Return the live record for this id, or ``None`` if unknown/expired."""
48
+
49
+ def touch(self, token_id: str) -> None:
50
+ """Record usage (updates ``last_used_at``)."""
51
+
52
+ def revoke(self, token_id: str) -> bool:
53
+ """Delete the record (revocation is row deletion); returns whether it existed."""
54
+
55
+ def revoke_all_for_user(self, user_id: Any) -> int:
56
+ """Delete every token belonging to ``user_id``; returns how many."""
57
+
58
+ def list_for_user(self, user_id: Any) -> list[ApiTokenRecord]:
59
+ """Return the user's live (non-expired) token records."""
60
+
61
+ def purge_expired(self) -> None:
62
+ """Drop expired records (maintenance)."""
63
+
64
+
65
+ class InMemoryApiTokenRepository:
66
+ """Default dict-backed token repository for tests, demos, and prototypes."""
67
+
68
+ def __init__(self) -> None:
69
+ self._tokens: dict[str, ApiTokenRecord] = {}
70
+
71
+ def create(
72
+ self,
73
+ *,
74
+ user_id: Any,
75
+ token_hash: str,
76
+ name: str | None,
77
+ abilities: list[str],
78
+ expires_at: float | None,
79
+ ) -> ApiTokenRecord:
80
+ record = ApiTokenRecord(
81
+ id=generate_token_id(),
82
+ user_id=user_id,
83
+ token_hash=token_hash,
84
+ name=name,
85
+ abilities=list(abilities),
86
+ expires_at=expires_at,
87
+ )
88
+ self._tokens[record.id] = record
89
+ return record
90
+
91
+ def find(self, token_id: str) -> ApiTokenRecord | None:
92
+ record = self._tokens.get(token_id)
93
+ if record is None:
94
+ return None
95
+ if record.expired:
96
+ del self._tokens[token_id]
97
+ return None
98
+ return record
99
+
100
+ def touch(self, token_id: str) -> None:
101
+ record = self._tokens.get(token_id)
102
+ if record is not None:
103
+ record.last_used_at = time.time()
104
+
105
+ def revoke(self, token_id: str) -> bool:
106
+ return self._tokens.pop(token_id, None) is not None
107
+
108
+ def revoke_all_for_user(self, user_id: Any) -> int:
109
+ doomed = [tid for tid, rec in self._tokens.items() if rec.user_id == user_id]
110
+ for tid in doomed:
111
+ del self._tokens[tid]
112
+ return len(doomed)
113
+
114
+ def list_for_user(self, user_id: Any) -> list[ApiTokenRecord]:
115
+ return [
116
+ rec for rec in self._tokens.values() if rec.user_id == user_id and not rec.expired
117
+ ]
118
+
119
+ def purge_expired(self) -> None:
120
+ self._tokens = {tid: rec for tid, rec in self._tokens.items() if not rec.expired}
@@ -0,0 +1,152 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import threading
5
+ import time
6
+ from typing import Any
7
+
8
+ from .models import ApiTokenRecord
9
+ from .repository import generate_token_id
10
+
11
+ _SCHEMA = """
12
+ CREATE TABLE IF NOT EXISTS {table} (
13
+ id TEXT PRIMARY KEY,
14
+ user_id TEXT,
15
+ token_hash TEXT NOT NULL,
16
+ name TEXT,
17
+ abilities TEXT NOT NULL,
18
+ last_used_at REAL,
19
+ expires_at REAL,
20
+ created_at REAL NOT NULL
21
+ )
22
+ """
23
+
24
+ _COLUMNS = "id, user_id, token_hash, name, abilities, last_used_at, expires_at, created_at"
25
+
26
+
27
+ class SqlApiTokenRepository:
28
+ """Persistent :class:`~.repository.ApiTokenRepository` backed by a
29
+ ``personal_api_tokens`` table.
30
+
31
+ Works with any DB-API 2.0 connection using ``qmark`` placeholders (sqlite3
32
+ out of the box). ``user_id`` and ``abilities`` are JSON-encoded so
33
+ identifier types and ability lists round-trip exactly, matching the
34
+ in-memory repository. A lock serialises access because FastAPI runs sync
35
+ endpoints in a thread pool.
36
+ """
37
+
38
+ def __init__(
39
+ self,
40
+ connection: Any,
41
+ table: str = "personal_api_tokens",
42
+ create_table: bool = True,
43
+ ) -> None:
44
+ self._conn = connection
45
+ self._table = table
46
+ self._lock = threading.Lock()
47
+ if create_table:
48
+ with self._lock:
49
+ self._conn.execute(_SCHEMA.format(table=table))
50
+ self._conn.commit()
51
+
52
+ def create(
53
+ self,
54
+ *,
55
+ user_id: Any,
56
+ token_hash: str,
57
+ name: str | None,
58
+ abilities: list[str],
59
+ expires_at: float | None,
60
+ ) -> ApiTokenRecord:
61
+ record = ApiTokenRecord(
62
+ id=generate_token_id(),
63
+ user_id=user_id,
64
+ token_hash=token_hash,
65
+ name=name,
66
+ abilities=list(abilities),
67
+ expires_at=expires_at,
68
+ )
69
+ with self._lock:
70
+ self._conn.execute(
71
+ f"INSERT INTO {self._table} ({_COLUMNS}) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
72
+ (
73
+ record.id,
74
+ json.dumps(record.user_id),
75
+ record.token_hash,
76
+ record.name,
77
+ json.dumps(record.abilities),
78
+ record.last_used_at,
79
+ record.expires_at,
80
+ record.created_at,
81
+ ),
82
+ )
83
+ self._conn.commit()
84
+ return record
85
+
86
+ def find(self, token_id: str) -> ApiTokenRecord | None:
87
+ with self._lock:
88
+ row = self._conn.execute(
89
+ f"SELECT {_COLUMNS} FROM {self._table} WHERE id = ?", (token_id,)
90
+ ).fetchone()
91
+ if row is None:
92
+ return None
93
+ record = self._to_record(row)
94
+ if record.expired:
95
+ self.revoke(token_id)
96
+ return None
97
+ return record
98
+
99
+ def touch(self, token_id: str) -> None:
100
+ with self._lock:
101
+ self._conn.execute(
102
+ f"UPDATE {self._table} SET last_used_at = ? WHERE id = ?",
103
+ (time.time(), token_id),
104
+ )
105
+ self._conn.commit()
106
+
107
+ def revoke(self, token_id: str) -> bool:
108
+ with self._lock:
109
+ cursor = self._conn.execute(
110
+ f"DELETE FROM {self._table} WHERE id = ?", (token_id,)
111
+ )
112
+ self._conn.commit()
113
+ return cursor.rowcount > 0
114
+
115
+ def revoke_all_for_user(self, user_id: Any) -> int:
116
+ with self._lock:
117
+ cursor = self._conn.execute(
118
+ f"DELETE FROM {self._table} WHERE user_id = ?", (json.dumps(user_id),)
119
+ )
120
+ self._conn.commit()
121
+ return cursor.rowcount
122
+
123
+ def list_for_user(self, user_id: Any) -> list[ApiTokenRecord]:
124
+ now = time.time()
125
+ with self._lock:
126
+ rows = self._conn.execute(
127
+ f"SELECT {_COLUMNS} FROM {self._table} "
128
+ "WHERE user_id = ? AND (expires_at IS NULL OR expires_at >= ?)",
129
+ (json.dumps(user_id), now),
130
+ ).fetchall()
131
+ return [self._to_record(row) for row in rows]
132
+
133
+ def purge_expired(self) -> None:
134
+ with self._lock:
135
+ self._conn.execute(
136
+ f"DELETE FROM {self._table} WHERE expires_at IS NOT NULL AND expires_at < ?",
137
+ (time.time(),),
138
+ )
139
+ self._conn.commit()
140
+
141
+ @staticmethod
142
+ def _to_record(row: Any) -> ApiTokenRecord:
143
+ return ApiTokenRecord(
144
+ id=row[0],
145
+ user_id=json.loads(row[1]),
146
+ token_hash=row[2],
147
+ name=row[3],
148
+ abilities=json.loads(row[4]),
149
+ last_used_at=row[5],
150
+ expires_at=row[6],
151
+ created_at=row[7],
152
+ )
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Iterable
4
+
5
+ from fastapi import FastAPI
6
+
7
+
8
+ class Application:
9
+ """Thin composition root mirroring ``Application([(AuthProvider, AuthConfig)])``.
10
+
11
+ Creates a FastAPI app and registers each ``(ProviderClass, config)`` pair
12
+ onto it. The resulting FastAPI instance is exposed as ``.api`` and the
13
+ Application is itself ASGI-callable, so it can be served directly::
14
+
15
+ app = Application([(AuthProvider, AuthConfig)])
16
+ # uvicorn app:app -> uses Application.__call__
17
+ # uvicorn app:app.api -> uses the FastAPI instance
18
+ """
19
+
20
+ def __init__(
21
+ self,
22
+ providers: Iterable[tuple[type, Any]] | None = None,
23
+ api: FastAPI | None = None,
24
+ ) -> None:
25
+ self.api = api or FastAPI(title="FastAPI Startkit Auth")
26
+ self.providers: list[Any] = []
27
+ for provider_cls, config in providers or []:
28
+ provider = provider_cls(config)
29
+ provider.register(self.api)
30
+ self.providers.append(provider)
31
+
32
+ @property
33
+ def auth(self):
34
+ """Return the AuthManager of the first registered provider."""
35
+ return self.providers[0].manager if self.providers else None
36
+
37
+ async def __call__(self, scope, receive, send):
38
+ await self.api(scope, receive, send)
@@ -0,0 +1,4 @@
1
+ from .models import Client
2
+ from .repository import InMemoryClientRepository
3
+
4
+ __all__ = ["Client", "InMemoryClientRepository"]
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+
6
+ @dataclass
7
+ class Client:
8
+ """An OAuth2 client (application).
9
+
10
+ Confidential clients authenticate with a secret; public clients (SPAs, native
11
+ apps) do not and must use the authorization-code + PKCE flow. An empty
12
+ ``grant_types`` means the client may use any supported grant.
13
+ """
14
+
15
+ id: str
16
+ name: str
17
+ secret: str | None = None
18
+ redirect_uris: list[str] = field(default_factory=list)
19
+ confidential: bool = True
20
+ grant_types: list[str] = field(default_factory=list)
21
+ revoked: bool = False
22
+
23
+ def allows_redirect(self, uri: str) -> bool:
24
+ return uri in self.redirect_uris
25
+
26
+ def allows_grant(self, grant_type: str) -> bool:
27
+ if not self.grant_types:
28
+ return True
29
+ return grant_type in self.grant_types
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ import secrets
4
+ import uuid
5
+
6
+ from ..security.hashing import BcryptHasher, Hasher
7
+ from .models import Client
8
+
9
+
10
+ class InMemoryClientRepository:
11
+ """Client registry with hashed-secret storage.
12
+
13
+ ``register`` returns the plaintext secret exactly once (mirroring how real
14
+ OAuth servers surface credentials); only its hash is retained.
15
+ """
16
+
17
+ def __init__(self, hasher: Hasher | None = None) -> None:
18
+ self._hasher = hasher or BcryptHasher()
19
+ self._clients: dict[str, Client] = {}
20
+
21
+ def register(
22
+ self,
23
+ *,
24
+ name: str,
25
+ redirect_uris: list[str] | None = None,
26
+ confidential: bool = True,
27
+ grant_types: list[str] | None = None,
28
+ ) -> tuple[Client, str | None]:
29
+ client_id = uuid.uuid4().hex
30
+ plain_secret: str | None = None
31
+ stored_secret: str | None = None
32
+ if confidential:
33
+ plain_secret = secrets.token_urlsafe(40)
34
+ stored_secret = self._hasher.make(plain_secret)
35
+ client = Client(
36
+ id=client_id,
37
+ name=name,
38
+ secret=stored_secret,
39
+ redirect_uris=list(redirect_uris or []),
40
+ confidential=confidential,
41
+ grant_types=list(grant_types or []),
42
+ )
43
+ self._clients[client_id] = client
44
+ return client, plain_secret
45
+
46
+ def add(self, client: Client) -> Client:
47
+ self._clients[client.id] = client
48
+ return client
49
+
50
+ def find(self, client_id: str) -> Client | None:
51
+ return self._clients.get(client_id)
52
+
53
+ def all(self) -> list[Client]:
54
+ return list(self._clients.values())
55
+
56
+ def authenticate(self, client_id: str, secret: str | None) -> Client | None:
57
+ client = self._clients.get(client_id)
58
+ if client is None or client.revoked:
59
+ return None
60
+ if not client.confidential:
61
+ return client
62
+ if secret is None or client.secret is None:
63
+ return None
64
+ if self._hasher.verify(secret, client.secret):
65
+ return client
66
+ return None
67
+
68
+ def delete(self, client_id: str) -> bool:
69
+ return self._clients.pop(client_id, None) is not None