authx-identity 0.1.0rc1__tar.gz

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 (37) hide show
  1. authx_identity-0.1.0rc1/LICENSE +22 -0
  2. authx_identity-0.1.0rc1/PKG-INFO +156 -0
  3. authx_identity-0.1.0rc1/README.md +106 -0
  4. authx_identity-0.1.0rc1/authx/__init__.py +0 -0
  5. authx_identity-0.1.0rc1/authx/api/__init__.py +0 -0
  6. authx_identity-0.1.0rc1/authx/api/deps.py +59 -0
  7. authx_identity-0.1.0rc1/authx/api/v1/__init__.py +0 -0
  8. authx_identity-0.1.0rc1/authx/api/v1/endpoints/__init__.py +0 -0
  9. authx_identity-0.1.0rc1/authx/api/v1/endpoints/oidc.py +76 -0
  10. authx_identity-0.1.0rc1/authx/api/v1/internal/__init__.py +0 -0
  11. authx_identity-0.1.0rc1/authx/api/v1/internal/identities.py +142 -0
  12. authx_identity-0.1.0rc1/authx/core/__init__.py +0 -0
  13. authx_identity-0.1.0rc1/authx/core/logging.py +37 -0
  14. authx_identity-0.1.0rc1/authx/core/security.py +118 -0
  15. authx_identity-0.1.0rc1/authx/core/settings.py +61 -0
  16. authx_identity-0.1.0rc1/authx/db/__init__.py +0 -0
  17. authx_identity-0.1.0rc1/authx/db/session.py +37 -0
  18. authx_identity-0.1.0rc1/authx/main.py +76 -0
  19. authx_identity-0.1.0rc1/authx/middleware/__init__.py +0 -0
  20. authx_identity-0.1.0rc1/authx/middleware/logging.py +25 -0
  21. authx_identity-0.1.0rc1/authx/models/__init__.py +4 -0
  22. authx_identity-0.1.0rc1/authx/models/refresh_token.py +46 -0
  23. authx_identity-0.1.0rc1/authx/models/user_identity.py +79 -0
  24. authx_identity-0.1.0rc1/authx/schemas/__init__.py +0 -0
  25. authx_identity-0.1.0rc1/authx/schemas/identity.py +86 -0
  26. authx_identity-0.1.0rc1/authx/services/__init__.py +0 -0
  27. authx_identity-0.1.0rc1/authx/services/identity_service.py +255 -0
  28. authx_identity-0.1.0rc1/authx_client/__init__.py +18 -0
  29. authx_identity-0.1.0rc1/authx_client/authx_client.py +189 -0
  30. authx_identity-0.1.0rc1/authx_client/authx_jwt.py +67 -0
  31. authx_identity-0.1.0rc1/authx_identity.egg-info/PKG-INFO +156 -0
  32. authx_identity-0.1.0rc1/authx_identity.egg-info/SOURCES.txt +35 -0
  33. authx_identity-0.1.0rc1/authx_identity.egg-info/dependency_links.txt +1 -0
  34. authx_identity-0.1.0rc1/authx_identity.egg-info/requires.txt +25 -0
  35. authx_identity-0.1.0rc1/authx_identity.egg-info/top_level.txt +2 -0
  36. authx_identity-0.1.0rc1/pyproject.toml +119 -0
  37. authx_identity-0.1.0rc1/setup.cfg +4 -0
@@ -0,0 +1,22 @@
1
+
2
+ MIT License
3
+
4
+ Copyright (c) 2026 codefleet chandrashekhar-bhosale
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
@@ -0,0 +1,156 @@
1
+ Metadata-Version: 2.4
2
+ Name: authx-identity
3
+ Version: 0.1.0rc1
4
+ Summary: Standalone OIDC identity microservice and client library for DjangoPlay and Python applications.
5
+ Author: CodeFleet Labs
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/codefleetx/authx
8
+ Project-URL: Repository, https://github.com/codefleetx/authx
9
+ Project-URL: Issues, https://github.com/codefleetx/authx/issues
10
+ Keywords: authx,oidc,oauth2,identity,authentication,jwt,fastapi,microservice,djangoplay
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Internet :: WWW/HTTP
18
+ Classifier: Topic :: Security
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Classifier: Framework :: FastAPI
21
+ Classifier: Environment :: Web Environment
22
+ Requires-Python: >=3.11
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: fastapi>=0.111.0
26
+ Requires-Dist: uvicorn[standard]>=0.29.0
27
+ Requires-Dist: sqlalchemy>=2.0.0
28
+ Requires-Dist: alembic>=1.13.0
29
+ Requires-Dist: asyncpg>=0.29.0
30
+ Requires-Dist: psycopg2-binary>=2.9.0
31
+ Requires-Dist: passlib[bcrypt]>=1.7.4
32
+ Requires-Dist: python-jose[cryptography]>=3.3.0
33
+ Requires-Dist: cryptography>=42.0.0
34
+ Requires-Dist: python-multipart>=0.0.9
35
+ Requires-Dist: pydantic>=2.7.0
36
+ Requires-Dist: pydantic-settings>=2.2.0
37
+ Requires-Dist: email-validator>=2.0.0
38
+ Requires-Dist: httpx>=0.27.0
39
+ Requires-Dist: python-dotenv>=1.0.0
40
+ Requires-Dist: structlog>=24.1.0
41
+ Provides-Extra: dev
42
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
43
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
44
+ Requires-Dist: pytest-cov>=5.0.0; extra == "dev"
45
+ Requires-Dist: aiosqlite>=0.20.0; extra == "dev"
46
+ Requires-Dist: ruff>=0.4.0; extra == "dev"
47
+ Requires-Dist: build>=1.2.0; extra == "dev"
48
+ Requires-Dist: twine>=5.0.0; extra == "dev"
49
+ Dynamic: license-file
50
+
51
+ # AuthX
52
+ <p align="left">
53
+ Maintained by
54
+ <img
55
+ src="static/logo/dp/icon.svg"
56
+ alt="DjangoPlay Icon"
57
+ width="20"
58
+ valign="middle"
59
+ />
60
+ <img
61
+ src="static/logo/dp/name.svg"
62
+ alt="DjangoPlay"
63
+ width="72"
64
+ valign="middle"
65
+ />
66
+ </p>
67
+ <a href="https://djangoplay.org">https://djangoplay.org</a>
68
+
69
+ ##
70
+
71
+ ![Python](https://img.shields.io/pypi/pyversions/authx)
72
+ ![License](https://img.shields.io/badge/license-MIT-green)
73
+
74
+ Standalone OIDC identity microservice for codefleetx applications.
75
+
76
+ ## What it does
77
+
78
+ AuthX is a standards-compliant OpenID Connect (OIDC) identity provider built with FastAPI. It:
79
+
80
+ - Issues signed JWT access tokens and refresh tokens
81
+ - Exposes public OIDC endpoints (`/token`, `/userinfo`, `/jwks`, `/.well-known/openid-configuration`)
82
+ - Exposes an internal API (`/internal/identities`) for trusted services (e.g. DjangoPlay) to create and look up identities
83
+ - Supports email/password and SSO (Google, Apple) identity providers
84
+ - Is stateless and horizontally scalable
85
+
86
+ ## Architecture
87
+
88
+ ```
89
+ ┌──────────────────────────────────────┐
90
+ │ Client (browser, mobile, CLI) │
91
+ │ → POST /token (login) │
92
+ │ → GET /userinfo (who am I?) │
93
+ └────────────────┬─────────────────────┘
94
+ │ JWT
95
+ ┌───────────▼──────────────────┐
96
+ │ AuthX │
97
+ │ FastAPI + PostgreSQL │
98
+ │ Issues & validates JWTs │
99
+ └───────────┬──────────────────┘
100
+ │ Internal API (service token)
101
+ ┌───────────▼──────────────────┐
102
+ │ DjangoPlay │
103
+ │ Trusts AuthX JWTs │
104
+ │ Owns EmploymentProfile │
105
+ │ Owns MemberProfile │
106
+ └──────────────────────────────┘
107
+ ```
108
+
109
+ ## Endpoints
110
+
111
+ ### Public OIDC
112
+ | Method | Path | Description |
113
+ |--------|------|-------------|
114
+ | GET | `/.well-known/openid-configuration` | OIDC discovery document |
115
+ | GET | `/jwks` | Public keys for JWT verification |
116
+ | POST | `/token` | Issue access + refresh token |
117
+ | POST | `/token/refresh` | Refresh access token |
118
+ | GET | `/userinfo` | Get identity info from token |
119
+
120
+ ### Internal (service token required)
121
+ | Method | Path | Description |
122
+ |--------|------|-------------|
123
+ | POST | `/internal/identities` | Create identity |
124
+ | GET | `/internal/identities/{id}` | Get identity by ID |
125
+ | GET | `/internal/identities/by-email/{email}` | Lookup by email |
126
+ | GET | `/internal/identities/by-sso` | Lookup by SSO provider + ID |
127
+ | PATCH | `/internal/identities/{id}` | Update identity fields |
128
+ | DELETE | `/internal/identities/{id}` | Soft delete identity |
129
+
130
+ ## Running locally
131
+
132
+ ```bash
133
+ cp .env.example .env
134
+ # fill in values
135
+
136
+ docker compose up
137
+ ```
138
+
139
+ Migrations run automatically on startup.
140
+
141
+ ## Running in production
142
+
143
+ ```bash
144
+ docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
145
+ ```
146
+
147
+ ## Environment variables
148
+
149
+ See `.env.example` for all required variables.
150
+
151
+ ## JWT verification (for consumers)
152
+
153
+ Consumers (DjangoPlay, etc.) should:
154
+ 1. Fetch public keys from `/jwks` on startup (cache them)
155
+ 2. Verify JWT signatures locally — no AuthX call needed per request
156
+ 3. Only call `/userinfo` for server-to-server lookups without a JWT in hand
@@ -0,0 +1,106 @@
1
+ # AuthX
2
+ <p align="left">
3
+ Maintained by
4
+ <img
5
+ src="static/logo/dp/icon.svg"
6
+ alt="DjangoPlay Icon"
7
+ width="20"
8
+ valign="middle"
9
+ />
10
+ <img
11
+ src="static/logo/dp/name.svg"
12
+ alt="DjangoPlay"
13
+ width="72"
14
+ valign="middle"
15
+ />
16
+ </p>
17
+ <a href="https://djangoplay.org">https://djangoplay.org</a>
18
+
19
+ ##
20
+
21
+ ![Python](https://img.shields.io/pypi/pyversions/authx)
22
+ ![License](https://img.shields.io/badge/license-MIT-green)
23
+
24
+ Standalone OIDC identity microservice for codefleetx applications.
25
+
26
+ ## What it does
27
+
28
+ AuthX is a standards-compliant OpenID Connect (OIDC) identity provider built with FastAPI. It:
29
+
30
+ - Issues signed JWT access tokens and refresh tokens
31
+ - Exposes public OIDC endpoints (`/token`, `/userinfo`, `/jwks`, `/.well-known/openid-configuration`)
32
+ - Exposes an internal API (`/internal/identities`) for trusted services (e.g. DjangoPlay) to create and look up identities
33
+ - Supports email/password and SSO (Google, Apple) identity providers
34
+ - Is stateless and horizontally scalable
35
+
36
+ ## Architecture
37
+
38
+ ```
39
+ ┌──────────────────────────────────────┐
40
+ │ Client (browser, mobile, CLI) │
41
+ │ → POST /token (login) │
42
+ │ → GET /userinfo (who am I?) │
43
+ └────────────────┬─────────────────────┘
44
+ │ JWT
45
+ ┌───────────▼──────────────────┐
46
+ │ AuthX │
47
+ │ FastAPI + PostgreSQL │
48
+ │ Issues & validates JWTs │
49
+ └───────────┬──────────────────┘
50
+ │ Internal API (service token)
51
+ ┌───────────▼──────────────────┐
52
+ │ DjangoPlay │
53
+ │ Trusts AuthX JWTs │
54
+ │ Owns EmploymentProfile │
55
+ │ Owns MemberProfile │
56
+ └──────────────────────────────┘
57
+ ```
58
+
59
+ ## Endpoints
60
+
61
+ ### Public OIDC
62
+ | Method | Path | Description |
63
+ |--------|------|-------------|
64
+ | GET | `/.well-known/openid-configuration` | OIDC discovery document |
65
+ | GET | `/jwks` | Public keys for JWT verification |
66
+ | POST | `/token` | Issue access + refresh token |
67
+ | POST | `/token/refresh` | Refresh access token |
68
+ | GET | `/userinfo` | Get identity info from token |
69
+
70
+ ### Internal (service token required)
71
+ | Method | Path | Description |
72
+ |--------|------|-------------|
73
+ | POST | `/internal/identities` | Create identity |
74
+ | GET | `/internal/identities/{id}` | Get identity by ID |
75
+ | GET | `/internal/identities/by-email/{email}` | Lookup by email |
76
+ | GET | `/internal/identities/by-sso` | Lookup by SSO provider + ID |
77
+ | PATCH | `/internal/identities/{id}` | Update identity fields |
78
+ | DELETE | `/internal/identities/{id}` | Soft delete identity |
79
+
80
+ ## Running locally
81
+
82
+ ```bash
83
+ cp .env.example .env
84
+ # fill in values
85
+
86
+ docker compose up
87
+ ```
88
+
89
+ Migrations run automatically on startup.
90
+
91
+ ## Running in production
92
+
93
+ ```bash
94
+ docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
95
+ ```
96
+
97
+ ## Environment variables
98
+
99
+ See `.env.example` for all required variables.
100
+
101
+ ## JWT verification (for consumers)
102
+
103
+ Consumers (DjangoPlay, etc.) should:
104
+ 1. Fetch public keys from `/jwks` on startup (cache them)
105
+ 2. Verify JWT signatures locally — no AuthX call needed per request
106
+ 3. Only call `/userinfo` for server-to-server lookups without a JWT in hand
File without changes
File without changes
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+
5
+ from fastapi import Depends, Header, HTTPException, status
6
+ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
7
+ from jose import JWTError
8
+ from sqlalchemy.ext.asyncio import AsyncSession
9
+
10
+ from authx.core.security import decode_token
11
+ from authx.core.settings import get_settings
12
+ from authx.db.session import get_db
13
+ from authx.models.user_identity import UserIdentity
14
+ from authx.services.identity_service import IdentityNotFoundError, IdentityService
15
+
16
+ bearer_scheme = HTTPBearer()
17
+
18
+
19
+ async def require_internal_token(
20
+ x_service_token: str = Header(..., alias="X-Service-Token"),
21
+ ) -> None:
22
+ """Guard for /internal/* endpoints. DjangoPlay must send X-Service-Token header."""
23
+ if x_service_token != get_settings().internal_service_token:
24
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid service token.")
25
+
26
+
27
+ async def get_current_identity(
28
+ credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
29
+ db: AsyncSession = Depends(get_db),
30
+ ) -> UserIdentity:
31
+ """Decode Bearer JWT and return the UserIdentity. Used by /userinfo."""
32
+ exc = HTTPException(
33
+ status_code=status.HTTP_401_UNAUTHORIZED,
34
+ detail="Invalid or expired token.",
35
+ headers={"WWW-Authenticate": "Bearer"},
36
+ )
37
+ try:
38
+ payload = decode_token(credentials.credentials)
39
+ except JWTError:
40
+ raise exc
41
+
42
+ if payload.get("type") != "access":
43
+ raise exc
44
+
45
+ try:
46
+ identity_id = uuid.UUID(payload["sub"])
47
+ except (KeyError, ValueError):
48
+ raise exc
49
+
50
+ service = IdentityService(db)
51
+ try:
52
+ identity = await service.get_by_id(identity_id)
53
+ except IdentityNotFoundError:
54
+ raise exc
55
+
56
+ if not identity.is_active:
57
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Account is inactive.")
58
+
59
+ return identity
File without changes
@@ -0,0 +1,76 @@
1
+ """Public OIDC endpoints: discovery, JWKS, token, refresh, userinfo."""
2
+
3
+ from fastapi import APIRouter, Depends, HTTPException, status
4
+ from sqlalchemy.ext.asyncio import AsyncSession
5
+
6
+ from authx.api.deps import get_current_identity
7
+ from authx.core.security import get_jwks
8
+ from authx.core.settings import get_settings
9
+ from authx.db.session import get_db
10
+ from authx.models.user_identity import UserIdentity
11
+ from authx.schemas.identity import (
12
+ IdentityPublicResponse,
13
+ JWKSResponse,
14
+ RefreshRequest,
15
+ TokenRequest,
16
+ TokenResponse,
17
+ )
18
+ from authx.services.identity_service import IdentityService, InvalidCredentialsError, TokenError
19
+
20
+ router = APIRouter(tags=["OIDC"])
21
+
22
+
23
+ @router.get("/.well-known/openid-configuration")
24
+ async def openid_configuration():
25
+ s = get_settings()
26
+ base = s.app_base_url.rstrip("/")
27
+ return {
28
+ "issuer": s.jwt_issuer,
29
+ "token_endpoint": f"{base}/token",
30
+ "userinfo_endpoint": f"{base}/userinfo",
31
+ "jwks_uri": f"{base}/jwks",
32
+ "response_types_supported": ["token"],
33
+ "subject_types_supported": ["public"],
34
+ "id_token_signing_alg_values_supported": [s.jwt_algorithm],
35
+ "scopes_supported": ["openid", "email", "profile"],
36
+ "grant_types_supported": ["password", "refresh_token"],
37
+ }
38
+
39
+
40
+ @router.get("/jwks", response_model=JWKSResponse)
41
+ async def jwks():
42
+ """Public keys for JWT verification. Consumers cache this on startup."""
43
+ return get_jwks()
44
+
45
+
46
+ @router.post("/token", response_model=TokenResponse)
47
+ async def token(data: TokenRequest, db: AsyncSession = Depends(get_db)):
48
+ """Issue access + refresh tokens (email/password login)."""
49
+ try:
50
+ return await IdentityService(db).authenticate(data.email, data.password)
51
+ except InvalidCredentialsError as e:
52
+ raise HTTPException(
53
+ status_code=status.HTTP_401_UNAUTHORIZED,
54
+ detail=str(e),
55
+ headers={"WWW-Authenticate": "Bearer"},
56
+ )
57
+
58
+
59
+ @router.post("/token/refresh", response_model=TokenResponse)
60
+ async def token_refresh(data: RefreshRequest, db: AsyncSession = Depends(get_db)):
61
+ """Rotate refresh token and issue a new access + refresh pair."""
62
+ try:
63
+ return await IdentityService(db).refresh(data.refresh_token)
64
+ except TokenError as e:
65
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(e))
66
+
67
+
68
+ @router.get("/userinfo", response_model=IdentityPublicResponse)
69
+ async def userinfo(identity: UserIdentity = Depends(get_current_identity)):
70
+ """Standard OIDC /userinfo endpoint."""
71
+ return IdentityPublicResponse(
72
+ sub=str(identity.id),
73
+ email=identity.email,
74
+ email_verified=identity.is_verified,
75
+ preferred_username=identity.username,
76
+ )
@@ -0,0 +1,142 @@
1
+ """
2
+ Internal API — service-to-service endpoints.
3
+
4
+ All routes require X-Service-Token header.
5
+ DjangoPlay calls these to create, lookup, and update identities.
6
+
7
+ These are NOT public OIDC endpoints. They are a private contract
8
+ between AuthX and trusted backend services.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import uuid
14
+
15
+ from fastapi import APIRouter, Depends, HTTPException, Query, status
16
+ from sqlalchemy.ext.asyncio import AsyncSession
17
+
18
+ from authx.api.deps import require_internal_token
19
+ from authx.db.session import get_db
20
+ from authx.schemas.identity import (
21
+ IdentityCreateRequest,
22
+ IdentityResponse,
23
+ IdentityUpdateRequest,
24
+ )
25
+ from authx.services.identity_service import (
26
+ IdentityAlreadyExistsError,
27
+ IdentityNotFoundError,
28
+ IdentityService,
29
+ )
30
+
31
+ router = APIRouter(
32
+ prefix="/internal/identities",
33
+ tags=["Internal"],
34
+ dependencies=[Depends(require_internal_token)],
35
+ )
36
+
37
+
38
+ def _service(db: AsyncSession = Depends(get_db)) -> IdentityService:
39
+ return IdentityService(db)
40
+
41
+
42
+ # ------------------------------------------------------------------
43
+ # Create
44
+ # ------------------------------------------------------------------
45
+
46
+ @router.post("", response_model=IdentityResponse, status_code=status.HTTP_201_CREATED)
47
+ async def create_identity(
48
+ data: IdentityCreateRequest,
49
+ service: IdentityService = Depends(_service),
50
+ ):
51
+ """
52
+ Create a new UserIdentity.
53
+
54
+ Called by DjangoPlay when:
55
+ - Admin creates an Employee (→ EmploymentProfile)
56
+ - Social signup creates a Member (→ MemberProfile)
57
+ """
58
+ try:
59
+ identity = await service.create(data)
60
+ return identity
61
+ except IdentityAlreadyExistsError as e:
62
+ raise HTTPException(
63
+ status_code=status.HTTP_409_CONFLICT,
64
+ detail=str(e),
65
+ )
66
+
67
+
68
+ # ------------------------------------------------------------------
69
+ # Read — by ID
70
+ # ------------------------------------------------------------------
71
+
72
+ @router.get("/{identity_id}", response_model=IdentityResponse)
73
+ async def get_identity(
74
+ identity_id: uuid.UUID,
75
+ service: IdentityService = Depends(_service),
76
+ ):
77
+ try:
78
+ return await service.get_by_id(identity_id)
79
+ except IdentityNotFoundError as e:
80
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
81
+
82
+
83
+ # ------------------------------------------------------------------
84
+ # Read — by email
85
+ # ------------------------------------------------------------------
86
+
87
+ @router.get("/by-email/{email}", response_model=IdentityResponse)
88
+ async def get_identity_by_email(
89
+ email: str,
90
+ service: IdentityService = Depends(_service),
91
+ ):
92
+ try:
93
+ return await service.get_by_email(email)
94
+ except IdentityNotFoundError as e:
95
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
96
+
97
+
98
+ # ------------------------------------------------------------------
99
+ # Read — by SSO provider + ID
100
+ # ------------------------------------------------------------------
101
+
102
+ @router.get("/by-sso/lookup", response_model=IdentityResponse)
103
+ async def get_identity_by_sso(
104
+ provider: str = Query(..., description="SSO provider code: GOOGLE | APPLE"),
105
+ sso_id: str = Query(..., description="Provider-issued UID"),
106
+ service: IdentityService = Depends(_service),
107
+ ):
108
+ try:
109
+ return await service.get_by_sso(provider, sso_id)
110
+ except IdentityNotFoundError as e:
111
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
112
+
113
+
114
+ # ------------------------------------------------------------------
115
+ # Update
116
+ # ------------------------------------------------------------------
117
+
118
+ @router.patch("/{identity_id}", response_model=IdentityResponse)
119
+ async def update_identity(
120
+ identity_id: uuid.UUID,
121
+ data: IdentityUpdateRequest,
122
+ service: IdentityService = Depends(_service),
123
+ ):
124
+ try:
125
+ return await service.update(identity_id, data)
126
+ except IdentityNotFoundError as e:
127
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
128
+
129
+
130
+ # ------------------------------------------------------------------
131
+ # Soft delete
132
+ # ------------------------------------------------------------------
133
+
134
+ @router.delete("/{identity_id}", status_code=status.HTTP_204_NO_CONTENT)
135
+ async def delete_identity(
136
+ identity_id: uuid.UUID,
137
+ service: IdentityService = Depends(_service),
138
+ ):
139
+ try:
140
+ await service.soft_delete(identity_id)
141
+ except IdentityNotFoundError as e:
142
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
File without changes
@@ -0,0 +1,37 @@
1
+ import logging
2
+ import sys
3
+
4
+ import structlog
5
+
6
+
7
+ def configure_logging(is_production: bool = False) -> None:
8
+ shared_processors = [
9
+ structlog.contextvars.merge_contextvars,
10
+ structlog.stdlib.add_logger_name,
11
+ structlog.stdlib.add_log_level,
12
+ structlog.processors.TimeStamper(fmt="iso"),
13
+ structlog.processors.StackInfoRenderer(),
14
+ ]
15
+
16
+ if is_production:
17
+ processors = [*shared_processors, structlog.processors.JSONRenderer()]
18
+ else:
19
+ processors = [*shared_processors, structlog.dev.ConsoleRenderer(colors=True)]
20
+
21
+ structlog.configure(
22
+ processors=processors,
23
+ wrapper_class=structlog.stdlib.BoundLogger,
24
+ context_class=dict,
25
+ logger_factory=structlog.stdlib.LoggerFactory(),
26
+ cache_logger_on_first_use=True,
27
+ )
28
+
29
+ handler = logging.StreamHandler(sys.stdout)
30
+ root_logger = logging.getLogger()
31
+ root_logger.handlers = [handler]
32
+ root_logger.setLevel(logging.INFO)
33
+ logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
34
+
35
+
36
+ def get_logger(name: str):
37
+ return structlog.get_logger(name)