csrd-auth 0.1.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.
csrd/auth/__init__.py ADDED
@@ -0,0 +1,58 @@
1
+ """Pluggable authentication for FastAPI applications.
2
+
3
+ The auth system is built around two pluggable protocols:
4
+
5
+ 1. :class:`Authenticator` — how to validate a token and produce claims.
6
+ 2. :class:`KeyProvider` — how to resolve the cryptographic key(s) used
7
+ for JWT verification.
8
+ """
9
+
10
+ from ._authenticators import (
11
+ CallbackAuthenticator,
12
+ ChainedAuthenticator,
13
+ JWTAuthenticator,
14
+ RemoteAuthenticator,
15
+ StaticAuthenticator,
16
+ _default_claims_mapper,
17
+ )
18
+ from ._factory import (
19
+ create_bearer_dependency,
20
+ create_jwt_bearer,
21
+ )
22
+ from ._key_providers import (
23
+ CallbackKeyProvider,
24
+ EnvKeyProvider,
25
+ JWKSKeyProvider,
26
+ MultiKeyProvider,
27
+ StaticKeyProvider,
28
+ _coerce_key_provider,
29
+ )
30
+ from ._protocols import (
31
+ AuthCallback,
32
+ Authenticator,
33
+ KeyCallback,
34
+ KeyProvider,
35
+ KeySource,
36
+ )
37
+
38
+ __all__ = (
39
+ "AuthCallback",
40
+ "Authenticator",
41
+ "CallbackAuthenticator",
42
+ "CallbackKeyProvider",
43
+ "ChainedAuthenticator",
44
+ "EnvKeyProvider",
45
+ "JWKSKeyProvider",
46
+ "JWTAuthenticator",
47
+ "KeyCallback",
48
+ "KeyProvider",
49
+ "KeySource",
50
+ "MultiKeyProvider",
51
+ "RemoteAuthenticator",
52
+ "StaticAuthenticator",
53
+ "StaticKeyProvider",
54
+ "_coerce_key_provider",
55
+ "_default_claims_mapper",
56
+ "create_bearer_dependency",
57
+ "create_jwt_bearer",
58
+ )
@@ -0,0 +1,220 @@
1
+ # ruff: noqa: B904
2
+ """Built-in authenticator implementations."""
3
+
4
+ import inspect
5
+ import logging
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass, field
8
+ from typing import Any
9
+
10
+ import jwt as pyjwt
11
+ from fastapi import HTTPException, Request
12
+ from starlette.status import HTTP_401_UNAUTHORIZED
13
+
14
+ from csrd.models.claims import UserClaims
15
+
16
+ from ._key_providers import _coerce_key_provider
17
+ from ._protocols import AuthCallback, Authenticator, KeySource
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ def _default_claims_mapper(payload: dict[str, Any]) -> UserClaims:
23
+ sub = payload.get("sub", "")
24
+ user_name = (
25
+ payload.get("user_name") or payload.get("preferred_username") or payload.get("email") or ""
26
+ )
27
+
28
+ authorities = payload.get("authorities") or payload.get("roles") or []
29
+ if isinstance(authorities, str):
30
+ authorities = authorities.split()
31
+
32
+ return UserClaims(
33
+ sub=sub,
34
+ user_name=user_name,
35
+ authorities=list(authorities),
36
+ )
37
+
38
+
39
+ @dataclass
40
+ class JWTAuthenticator:
41
+ """Decode and verify a JWT token locally using PyJWT."""
42
+
43
+ key: KeySource | None = None
44
+ algorithms: list[str] = field(default_factory=lambda: ["HS256"])
45
+ audience: str | None = None
46
+ issuer: str | None = None
47
+ options: dict[str, Any] = field(default_factory=dict)
48
+ claims_mapper: Callable[[dict[str, Any]], UserClaims] | None = None
49
+
50
+ def __post_init__(self) -> None:
51
+ self._key_provider = _coerce_key_provider(self.key)
52
+
53
+ async def __call__(self, request: Request, token: str) -> UserClaims:
54
+ mapper = self.claims_mapper or _default_claims_mapper
55
+
56
+ try:
57
+ unverified_headers = pyjwt.get_unverified_header(token)
58
+ except pyjwt.DecodeError:
59
+ raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Malformed token")
60
+
61
+ resolved = self._key_provider(unverified_headers, request.app)
62
+ if inspect.isawaitable(resolved):
63
+ resolved = await resolved
64
+
65
+ decode_kwargs: dict[str, Any] = {
66
+ "algorithms": self.algorithms,
67
+ "options": dict(self.options),
68
+ }
69
+ if self.audience is not None:
70
+ decode_kwargs["audience"] = self.audience
71
+ if self.issuer is not None:
72
+ decode_kwargs["issuer"] = self.issuer
73
+
74
+ try:
75
+ payload = pyjwt.decode(token, resolved, **decode_kwargs)
76
+ except pyjwt.ExpiredSignatureError:
77
+ raise HTTPException(
78
+ status_code=HTTP_401_UNAUTHORIZED,
79
+ detail="Token has expired",
80
+ )
81
+ except pyjwt.InvalidTokenError as exc:
82
+ logger.debug("JWT validation failed: %s", exc)
83
+ raise HTTPException(
84
+ status_code=HTTP_401_UNAUTHORIZED,
85
+ detail="Invalid token",
86
+ )
87
+
88
+ return mapper(payload)
89
+
90
+
91
+ @dataclass
92
+ class StaticAuthenticator:
93
+ """Accept a fixed token value and return preconfigured claims."""
94
+
95
+ token: str
96
+ claims: UserClaims = field(default_factory=lambda: UserClaims(sub="static"))
97
+
98
+ async def __call__(self, request: Request, token: str) -> UserClaims:
99
+ import hmac
100
+
101
+ if not hmac.compare_digest(token, self.token):
102
+ raise HTTPException(
103
+ status_code=HTTP_401_UNAUTHORIZED,
104
+ detail="Invalid token",
105
+ )
106
+ return self.claims
107
+
108
+
109
+ @dataclass
110
+ class RemoteAuthenticator:
111
+ """Validate the token by calling an upstream HTTP endpoint."""
112
+
113
+ url: str
114
+ method: str = "GET"
115
+ token_header: str = "Authorization"
116
+ token_prefix: str = "Bearer "
117
+ claims_mapper: Callable[[dict[str, Any]], UserClaims] | None = None
118
+ httpx_client_kwargs: dict[str, Any] = field(default_factory=dict)
119
+ client: Any = None
120
+
121
+ async def __call__(self, request: Request, token: str) -> UserClaims:
122
+ import httpx
123
+
124
+ mapper = self.claims_mapper or _default_claims_mapper
125
+ headers = {self.token_header: f"{self.token_prefix}{token}"}
126
+
127
+ async def _do_request(c: httpx.AsyncClient) -> httpx.Response:
128
+ try:
129
+ return await c.request(self.method, self.url, headers=headers)
130
+ except httpx.HTTPError as exc:
131
+ logger.warning("Remote auth request failed: %s", exc)
132
+ raise HTTPException(
133
+ status_code=HTTP_401_UNAUTHORIZED,
134
+ detail="Authentication service unavailable",
135
+ )
136
+
137
+ if self.client is not None:
138
+ resp = await _do_request(self.client)
139
+ else:
140
+ client_kwargs = {"timeout": 10.0, **self.httpx_client_kwargs}
141
+ async with httpx.AsyncClient(**client_kwargs) as client:
142
+ resp = await _do_request(client)
143
+
144
+ if resp.status_code >= 400:
145
+ logger.debug(
146
+ "Remote auth rejected token: status=%d body=%s",
147
+ resp.status_code,
148
+ resp.text[:200],
149
+ )
150
+ raise HTTPException(
151
+ status_code=HTTP_401_UNAUTHORIZED,
152
+ detail="Token rejected by authentication service",
153
+ )
154
+
155
+ try:
156
+ payload = resp.json()
157
+ except Exception:
158
+ raise HTTPException(
159
+ status_code=HTTP_401_UNAUTHORIZED,
160
+ detail="Invalid response from authentication service",
161
+ )
162
+
163
+ if isinstance(payload, dict) and payload.get("active") is False:
164
+ raise HTTPException(
165
+ status_code=HTTP_401_UNAUTHORIZED,
166
+ detail="Token is not active",
167
+ )
168
+
169
+ return mapper(payload)
170
+
171
+
172
+ @dataclass
173
+ class CallbackAuthenticator:
174
+ """Delegate authentication to an arbitrary user-provided function."""
175
+
176
+ callback: AuthCallback
177
+
178
+ async def __call__(self, request: Request, token: str) -> UserClaims:
179
+ try:
180
+ result = self.callback(request, token)
181
+ if inspect.isawaitable(result):
182
+ result = await result
183
+ except HTTPException:
184
+ raise
185
+ except Exception as exc:
186
+ logger.debug("Callback authenticator failed: %s", exc)
187
+ raise HTTPException(
188
+ status_code=HTTP_401_UNAUTHORIZED,
189
+ detail="Authentication failed",
190
+ ) from exc
191
+
192
+ if not isinstance(result, UserClaims):
193
+ raise TypeError(
194
+ f"Authenticator callback must return UserClaims, got {type(result).__name__}"
195
+ )
196
+ return result
197
+
198
+
199
+ @dataclass
200
+ class ChainedAuthenticator:
201
+ """Try multiple authenticators in order; first success wins."""
202
+
203
+ authenticators: list[Authenticator | Any] = field(default_factory=list)
204
+
205
+ async def __call__(self, request: Request, token: str) -> UserClaims:
206
+ last_exc: HTTPException | None = None
207
+ for auth in self.authenticators:
208
+ try:
209
+ result = auth(request, token)
210
+ if inspect.isawaitable(result):
211
+ result = await result
212
+ return result
213
+ except HTTPException as exc:
214
+ last_exc = exc
215
+ continue
216
+
217
+ raise last_exc or HTTPException(
218
+ status_code=HTTP_401_UNAUTHORIZED,
219
+ detail="All authenticators failed",
220
+ )
csrd/auth/_factory.py ADDED
@@ -0,0 +1,93 @@
1
+ """Dependency factory functions for FastAPI auth integration."""
2
+
3
+ import inspect
4
+ from collections.abc import Callable
5
+ from typing import Any
6
+
7
+ from fastapi import Request
8
+
9
+ from csrd.context.platform import user_info_context
10
+ from csrd.models.claims import UserClaims
11
+
12
+ from ._authenticators import (
13
+ CallbackAuthenticator,
14
+ ChainedAuthenticator,
15
+ JWTAuthenticator,
16
+ RemoteAuthenticator,
17
+ StaticAuthenticator,
18
+ )
19
+ from ._protocols import AuthCallback, Authenticator, KeySource
20
+
21
+
22
+ def create_bearer_dependency(
23
+ authenticator: Authenticator | AuthCallback,
24
+ *,
25
+ token_finder: Callable[[], str] | None = None,
26
+ ) -> Callable:
27
+ """Build a FastAPI dependency from any :class:`Authenticator`.
28
+
29
+ Parameters
30
+ ----------
31
+ authenticator:
32
+ The authenticator instance or callback to use.
33
+ token_finder:
34
+ Zero-arg callable that extracts the bearer token from the
35
+ current request context. When *None*, the token must be
36
+ provided by a framework-level mechanism (e.g. the versioning
37
+ dispatch middleware populates the contextvar automatically).
38
+ """
39
+ if not isinstance(
40
+ authenticator,
41
+ (
42
+ JWTAuthenticator,
43
+ StaticAuthenticator,
44
+ RemoteAuthenticator,
45
+ CallbackAuthenticator,
46
+ ChainedAuthenticator,
47
+ ),
48
+ ) and callable(authenticator):
49
+ authenticator = CallbackAuthenticator(authenticator)
50
+
51
+ async def _bearer_dependency(request: Request) -> UserClaims:
52
+ if token_finder is None:
53
+ raise RuntimeError(
54
+ "No token_finder configured. Either pass token_finder= to "
55
+ "create_bearer_dependency(), or use csrd.versioning which "
56
+ "provides this automatically."
57
+ )
58
+ token = token_finder()
59
+
60
+ result = authenticator(request, token)
61
+ if inspect.isawaitable(result):
62
+ claims = await result
63
+ else:
64
+ claims = result
65
+
66
+ user_info_context.set(claims)
67
+ return claims
68
+
69
+ return _bearer_dependency
70
+
71
+
72
+ def create_jwt_bearer(
73
+ *,
74
+ key: KeySource | None = None,
75
+ algorithms: list[str] | None = None,
76
+ audience: str | None = None,
77
+ issuer: str | None = None,
78
+ options: dict[str, Any] | None = None,
79
+ claims_mapper: Callable[[dict[str, Any]], UserClaims] | None = None,
80
+ token_finder: Callable[[], str] | None = None,
81
+ ) -> Callable:
82
+ """Convenience shortcut: build a JWT bearer dependency."""
83
+ return create_bearer_dependency(
84
+ JWTAuthenticator(
85
+ key=key,
86
+ algorithms=algorithms or ["HS256"],
87
+ audience=audience,
88
+ issuer=issuer,
89
+ options=options or {},
90
+ claims_mapper=claims_mapper,
91
+ ),
92
+ token_finder=token_finder,
93
+ )
@@ -0,0 +1,219 @@
1
+ # ruff: noqa: B904
2
+ """Built-in key provider implementations."""
3
+
4
+ import logging
5
+ import time
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass, field
8
+ from typing import Any
9
+
10
+ from fastapi import HTTPException
11
+ from pydantic import SecretStr
12
+ from starlette.status import HTTP_401_UNAUTHORIZED
13
+
14
+ from ._protocols import KeyCallback, KeyProvider, KeySource
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ @dataclass
20
+ class StaticKeyProvider:
21
+ """A single fixed key — HMAC secret string, PEM-encoded public key, or raw bytes."""
22
+
23
+ key: str | bytes | SecretStr
24
+
25
+ def __call__(self, token_headers: dict[str, Any], app: Any) -> str | bytes:
26
+ if isinstance(self.key, SecretStr):
27
+ return self.key.get_secret_value()
28
+ return self.key
29
+
30
+
31
+ @dataclass
32
+ class EnvKeyProvider:
33
+ """Read the JWT key from ``app.state`` or a fallback settings loader.
34
+
35
+ Parameters
36
+ ----------
37
+ state_key:
38
+ Attribute name on ``app.state`` where a settings object with a
39
+ ``jwt_secret`` field is stored. Defaults to ``"_versioning_settings"``.
40
+ settings_loader:
41
+ Zero-arg callable that returns a settings object with a
42
+ ``jwt_secret`` attribute. Called when ``app.state`` lookup fails.
43
+ If *None* and the state lookup also fails, a ``RuntimeError``
44
+ is raised.
45
+ """
46
+
47
+ state_key: str = "_versioning_settings"
48
+ settings_loader: Callable[[], Any] | None = None
49
+
50
+ def __call__(self, token_headers: dict[str, Any], app: Any) -> str:
51
+ settings = getattr(app.state, self.state_key, None)
52
+ if settings is None and self.settings_loader is not None:
53
+ settings = self.settings_loader()
54
+
55
+ if settings is None:
56
+ raise RuntimeError(
57
+ "No JWT secret configured. Set the JWT_SECRET env var, "
58
+ "pass key= to JWTAuthenticator, or use a KeyProvider."
59
+ )
60
+
61
+ jwt_key = getattr(settings, "jwt_secret", None)
62
+ if jwt_key is None:
63
+ raise RuntimeError(
64
+ "No JWT secret configured. Set the JWT_SECRET env var, "
65
+ "pass key= to JWTAuthenticator, or use a KeyProvider."
66
+ )
67
+ return jwt_key.get_secret_value() if isinstance(jwt_key, SecretStr) else str(jwt_key)
68
+
69
+
70
+ @dataclass
71
+ class JWKSKeyProvider:
72
+ """Fetch signing keys from a JWKS (JSON Web Key Set) endpoint."""
73
+
74
+ url: str
75
+ cache_ttl: float = 300.0
76
+ httpx_kwargs: dict[str, Any] = field(default_factory=dict)
77
+
78
+ _jwk_set: Any = field(default=None, init=False, repr=False)
79
+ _fetched_at: float = field(default=0.0, init=False, repr=False)
80
+
81
+ async def __call__(self, token_headers: dict[str, Any], app: Any) -> Any:
82
+ kid = token_headers.get("kid")
83
+ alg = token_headers.get("alg", "RS256")
84
+
85
+ key = self._find_key(kid, alg)
86
+ if key is not None:
87
+ return key
88
+
89
+ await self._async_refresh()
90
+ key = self._find_key(kid, alg)
91
+ if key is not None:
92
+ return key
93
+
94
+ detail = f"No matching key found in JWKS (kid={kid})" if kid else "No keys in JWKS"
95
+ raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail=detail)
96
+
97
+ def _find_key(self, kid: str | None, alg: str) -> Any:
98
+ if self._jwk_set is None or (time.monotonic() - self._fetched_at > self.cache_ttl):
99
+ return None
100
+
101
+ try:
102
+ from jwt import PyJWKSet # noqa: F401 — availability check
103
+ except ImportError:
104
+ raise RuntimeError(
105
+ "JWKSKeyProvider requires PyJWT[crypto]. Install with: pip install 'PyJWT[crypto]'"
106
+ ) from None
107
+
108
+ for jwk in self._jwk_set.keys:
109
+ jwk_kid = getattr(jwk, "key_id", None)
110
+ if kid is not None and jwk_kid != kid:
111
+ continue
112
+ return jwk.key
113
+ return None
114
+
115
+ async def _async_refresh(self) -> None:
116
+ if self._jwk_set is not None and time.monotonic() - self._fetched_at <= self.cache_ttl:
117
+ return
118
+
119
+ import httpx
120
+
121
+ try:
122
+ from jwt import PyJWKSet
123
+ except ImportError:
124
+ raise RuntimeError(
125
+ "JWKSKeyProvider requires PyJWT[crypto]. Install with: pip install 'PyJWT[crypto]'"
126
+ ) from None
127
+
128
+ kwargs = {"timeout": 10.0, **self.httpx_kwargs}
129
+ try:
130
+ async with httpx.AsyncClient(**kwargs) as client:
131
+ resp = await client.get(self.url)
132
+ resp.raise_for_status()
133
+ self._jwk_set = PyJWKSet.from_dict(resp.json())
134
+ self._fetched_at = time.monotonic()
135
+ logger.debug(
136
+ "Refreshed JWKS from %s: %d key(s)",
137
+ self.url,
138
+ len(self._jwk_set.keys),
139
+ )
140
+ except Exception as exc:
141
+ logger.warning("Failed to fetch JWKS from %s: %s", self.url, exc)
142
+ if self._jwk_set is None:
143
+ raise HTTPException(
144
+ status_code=HTTP_401_UNAUTHORIZED,
145
+ detail="Unable to fetch signing keys",
146
+ )
147
+
148
+
149
+ @dataclass
150
+ class MultiKeyProvider:
151
+ """Route to different keys by ``kid`` JWT header, or try all keys in order."""
152
+
153
+ providers: dict[str, KeyProvider | str | bytes] | list[KeyProvider | str | bytes]
154
+
155
+ def __call__(self, token_headers: dict[str, Any], app: Any) -> Any:
156
+ kid = token_headers.get("kid")
157
+
158
+ if isinstance(self.providers, dict):
159
+ if kid is not None and kid in self.providers:
160
+ return self._resolve_one(self.providers[kid], token_headers, app)
161
+
162
+ for provider in self.providers.values():
163
+ try:
164
+ return self._resolve_one(provider, token_headers, app)
165
+ except Exception:
166
+ continue
167
+
168
+ raise HTTPException(
169
+ status_code=HTTP_401_UNAUTHORIZED,
170
+ detail=f"No key found for kid={kid}" if kid else "No matching key",
171
+ )
172
+
173
+ last_exc: Exception | None = None
174
+ for provider in self.providers:
175
+ try:
176
+ return self._resolve_one(provider, token_headers, app)
177
+ except Exception as exc:
178
+ last_exc = exc
179
+ continue
180
+
181
+ raise last_exc or HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="No matching key")
182
+
183
+ @staticmethod
184
+ def _resolve_one(
185
+ provider: KeyProvider | str | bytes, token_headers: dict[str, Any], app: Any
186
+ ) -> Any:
187
+ if isinstance(provider, (str, bytes)):
188
+ return provider
189
+ if isinstance(provider, SecretStr):
190
+ return provider.get_secret_value()
191
+ return provider(token_headers, app)
192
+
193
+
194
+ @dataclass
195
+ class CallbackKeyProvider:
196
+ """Delegate key resolution to any user-supplied function."""
197
+
198
+ callback: KeyCallback
199
+
200
+ def __call__(self, token_headers: dict[str, Any], app: Any) -> Any:
201
+ return self.callback(token_headers, app)
202
+
203
+
204
+ # ── Coerce raw values into KeyProvider instances ────────────────────────
205
+
206
+
207
+ def _coerce_key_provider(key: KeySource | None) -> KeyProvider:
208
+ if key is None:
209
+ return EnvKeyProvider()
210
+ if isinstance(key, KeyProvider):
211
+ return key
212
+ if isinstance(key, (str, bytes, SecretStr)):
213
+ return StaticKeyProvider(key)
214
+ if callable(key):
215
+ return CallbackKeyProvider(key)
216
+ raise TypeError(
217
+ f"key must be a string, bytes, SecretStr, KeyProvider, or callable — "
218
+ f"got {type(key).__name__}"
219
+ )
@@ -0,0 +1,29 @@
1
+ """Protocols and type aliases for the auth system."""
2
+
3
+ from collections.abc import Callable
4
+ from typing import Any, Protocol, runtime_checkable
5
+
6
+ from fastapi import Request
7
+ from pydantic import SecretStr
8
+
9
+ from csrd.models.claims import UserClaims
10
+
11
+
12
+ @runtime_checkable
13
+ class Authenticator(Protocol):
14
+ """Protocol for pluggable authentication strategies."""
15
+
16
+ def __call__(self, request: Request, token: str) -> UserClaims: ...
17
+
18
+
19
+ @runtime_checkable
20
+ class KeyProvider(Protocol):
21
+ """Protocol for pluggable JWT key resolution."""
22
+
23
+ def __call__(self, token_headers: dict[str, Any], app: Any) -> Any: ...
24
+
25
+
26
+ AuthCallback = Callable[[Request, str], UserClaims | Any]
27
+ KeyCallback = Callable[..., Any]
28
+
29
+ KeySource = str | bytes | SecretStr | KeyProvider
csrd/auth/py.typed ADDED
File without changes
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.4
2
+ Name: csrd-auth
3
+ Version: 0.1.0
4
+ Summary: Pluggable JWT authentication — authenticators, key providers, and FastAPI dependency factories
5
+ Project-URL: Repository, https://github.com/csrd-api/fastapi-common
6
+ Project-URL: Documentation, https://github.com/csrd-api/fastapi-common/tree/main/packages/auth
7
+ Project-URL: Changelog, https://github.com/csrd-api/fastapi-common/blob/main/CHANGELOG.md
8
+ License: MIT
9
+ Requires-Python: >=3.12
10
+ Requires-Dist: csrd-context
11
+ Requires-Dist: csrd-models
12
+ Requires-Dist: fastapi<1,>=0.115
13
+ Requires-Dist: pydantic<3,>=2
14
+ Requires-Dist: pyjwt<3,>=2
@@ -0,0 +1,9 @@
1
+ csrd/auth/__init__.py,sha256=2_2Ug0hx23XoQmYNs6M2g79uBUS4ck4Rg7DviD0qpfQ,1320
2
+ csrd/auth/_authenticators.py,sha256=C3rRNlr1qoIz8AphoUamuozbB7m5HylyQR_CbpSmXXk,7254
3
+ csrd/auth/_factory.py,sha256=J2mGoyw_JX1C8bxJpvvXULwh5Gin0hTDCv-Ula6cf6E,2801
4
+ csrd/auth/_key_providers.py,sha256=Py7yuHK1WIQh4iO86ScfVIUa1p_5rQDu_Gts3cgOoCo,7512
5
+ csrd/auth/_protocols.py,sha256=Mh68kIwn3EipKhsiBoxfNvZtVlHJcKdKl5hpupJrOIo,756
6
+ csrd/auth/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ csrd_auth-0.1.0.dist-info/METADATA,sha256=S1pNBc0p6Dd0eMYrSaW_cfoZmb3U-7Pw6FgW_NXwu6I,594
8
+ csrd_auth-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
9
+ csrd_auth-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any