hotosm-auth 0.2.3__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 (31) hide show
  1. hotosm_auth-0.2.3/.gitignore +48 -0
  2. hotosm_auth-0.2.3/PKG-INFO +99 -0
  3. hotosm_auth-0.2.3/README.md +57 -0
  4. hotosm_auth-0.2.3/pyproject.toml +81 -0
  5. hotosm_auth-0.2.3/src/hotosm_auth/__init__.py +87 -0
  6. hotosm_auth-0.2.3/src/hotosm_auth/config.py +279 -0
  7. hotosm_auth-0.2.3/src/hotosm_auth/crypto.py +130 -0
  8. hotosm_auth-0.2.3/src/hotosm_auth/exceptions.py +39 -0
  9. hotosm_auth-0.2.3/src/hotosm_auth/jwt_validator.py +193 -0
  10. hotosm_auth-0.2.3/src/hotosm_auth/logger.py +173 -0
  11. hotosm_auth-0.2.3/src/hotosm_auth/models.py +91 -0
  12. hotosm_auth-0.2.3/src/hotosm_auth/osm_oauth.py +381 -0
  13. hotosm_auth-0.2.3/src/hotosm_auth/schemas/__init__.py +15 -0
  14. hotosm_auth-0.2.3/src/hotosm_auth/schemas/admin.py +45 -0
  15. hotosm_auth-0.2.3/src/hotosm_auth_django/__init__.py +88 -0
  16. hotosm_auth-0.2.3/src/hotosm_auth_django/admin_routes.py +356 -0
  17. hotosm_auth-0.2.3/src/hotosm_auth_django/apps.py +6 -0
  18. hotosm_auth-0.2.3/src/hotosm_auth_django/middleware.py +513 -0
  19. hotosm_auth-0.2.3/src/hotosm_auth_django/migrations/0001_initial.py +62 -0
  20. hotosm_auth-0.2.3/src/hotosm_auth_django/migrations/__init__.py +0 -0
  21. hotosm_auth-0.2.3/src/hotosm_auth_django/models.py +67 -0
  22. hotosm_auth-0.2.3/src/hotosm_auth_django/osm_views.py +292 -0
  23. hotosm_auth-0.2.3/src/hotosm_auth_fastapi/__init__.py +106 -0
  24. hotosm_auth-0.2.3/src/hotosm_auth_fastapi/admin.py +73 -0
  25. hotosm_auth-0.2.3/src/hotosm_auth_fastapi/admin_routes.py +248 -0
  26. hotosm_auth-0.2.3/src/hotosm_auth_fastapi/admin_routes_psycopg.py +274 -0
  27. hotosm_auth-0.2.3/src/hotosm_auth_fastapi/db_models.py +83 -0
  28. hotosm_auth-0.2.3/src/hotosm_auth_fastapi/dependencies.py +404 -0
  29. hotosm_auth-0.2.3/src/hotosm_auth_fastapi/osm_routes.py +216 -0
  30. hotosm_auth-0.2.3/src/hotosm_auth_fastapi/setup.py +142 -0
  31. hotosm_auth-0.2.3/uv.lock +1201 -0
@@ -0,0 +1,48 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ build/
8
+ develop-eggs/
9
+ dist/
10
+ downloads/
11
+ eggs/
12
+ .eggs/
13
+ lib/
14
+ lib64/
15
+ parts/
16
+ sdist/
17
+ var/
18
+ wheels/
19
+ *.egg-info/
20
+ .installed.cfg
21
+ *.egg
22
+
23
+ # Virtual environments
24
+ venv/
25
+ env/
26
+ ENV/
27
+
28
+ # Testing
29
+ .pytest_cache/
30
+ .coverage
31
+ htmlcov/
32
+ .tox/
33
+
34
+ # IDE
35
+ .vscode/
36
+ .idea/
37
+ *.swp
38
+ *.swo
39
+ *~
40
+
41
+ # OS
42
+ .DS_Store
43
+ Thumbs.db
44
+
45
+ # Mypy
46
+ .mypy_cache/
47
+ .dmypy.json
48
+ dmypy.json
@@ -0,0 +1,99 @@
1
+ Metadata-Version: 2.4
2
+ Name: hotosm-auth
3
+ Version: 0.2.3
4
+ Summary: HOTOSM SSO authentication library for Hanko + OSM OAuth
5
+ Project-URL: Homepage, https://github.com/hotosm/login
6
+ Project-URL: Documentation, https://hotosm.github.io/auth-docs
7
+ Project-URL: Repository, https://github.com/hotosm/login/tree/main/auth-libs/python
8
+ Project-URL: Issues, https://github.com/hotosm/login/issues
9
+ Author-email: HOTOSM <tech@hotosm.org>
10
+ License: AGPL-3.0
11
+ Keywords: auth,django,fastapi,hanko,hotosm,oauth,osm,sso
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Framework :: Django
14
+ Classifier: Framework :: FastAPI
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Requires-Python: >=3.10
23
+ Requires-Dist: cryptography>=41.0.0
24
+ Requires-Dist: httpx>=0.25.0
25
+ Requires-Dist: pydantic>=2.0.0
26
+ Requires-Dist: pyjwt[crypto]>=2.8.0
27
+ Requires-Dist: python-dateutil>=2.8.0
28
+ Provides-Extra: dev
29
+ Requires-Dist: black>=23.0.0; extra == 'dev'
30
+ Requires-Dist: mypy>=1.5.0; extra == 'dev'
31
+ Requires-Dist: pytest-asyncio>=0.21.0; extra == 'dev'
32
+ Requires-Dist: pytest-cov>=4.1.0; extra == 'dev'
33
+ Requires-Dist: pytest>=7.4.0; extra == 'dev'
34
+ Requires-Dist: ruff>=0.1.0; extra == 'dev'
35
+ Provides-Extra: django
36
+ Requires-Dist: django>=4.2.0; extra == 'django'
37
+ Requires-Dist: djangorestframework>=3.14.0; extra == 'django'
38
+ Provides-Extra: fastapi
39
+ Requires-Dist: fastapi>=0.104.0; extra == 'fastapi'
40
+ Requires-Dist: sqlalchemy>=2.0.0; extra == 'fastapi'
41
+ Description-Content-Type: text/markdown
42
+
43
+ # HOTOSM Auth Library - Python
44
+
45
+ FastAPI/Django integration for Hanko authentication with OSM OAuth support.
46
+
47
+ ## Installation
48
+
49
+ ```bash
50
+ pip install hotosm-auth
51
+ ```
52
+
53
+ ## Usage
54
+
55
+ ### FastAPI
56
+
57
+ ```python
58
+ from fastapi import FastAPI, Depends
59
+ from hotosm_auth.integrations.fastapi import CurrentUser
60
+
61
+ app = FastAPI()
62
+
63
+ @app.get("/me")
64
+ async def get_me(user: CurrentUser):
65
+ return {"id": user.id, "email": user.email}
66
+ ```
67
+
68
+ ### Django
69
+
70
+ ```python
71
+ from django.http import JsonResponse
72
+ from hotosm_auth.integrations.django import require_auth
73
+
74
+ @require_auth
75
+ def my_view(request):
76
+ return JsonResponse({"user_id": request.user.id})
77
+ ```
78
+
79
+ ## Features
80
+
81
+ - JWT validation for Hanko tokens
82
+ - OSM OAuth 2.0 integration
83
+ - User mapping between Hanko and application users
84
+ - FastAPI and Django integrations
85
+
86
+ ## Configuration
87
+
88
+ Set these environment variables:
89
+
90
+ ```bash
91
+ HANKO_API_URL=https://login.hotosm.org
92
+ OSM_CLIENT_ID=your_osm_client_id
93
+ OSM_CLIENT_SECRET=your_osm_secret
94
+ COOKIE_SECRET=your_secret_key
95
+ ```
96
+
97
+ ## License
98
+
99
+ AGPL-3.0
@@ -0,0 +1,57 @@
1
+ # HOTOSM Auth Library - Python
2
+
3
+ FastAPI/Django integration for Hanko authentication with OSM OAuth support.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install hotosm-auth
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ### FastAPI
14
+
15
+ ```python
16
+ from fastapi import FastAPI, Depends
17
+ from hotosm_auth.integrations.fastapi import CurrentUser
18
+
19
+ app = FastAPI()
20
+
21
+ @app.get("/me")
22
+ async def get_me(user: CurrentUser):
23
+ return {"id": user.id, "email": user.email}
24
+ ```
25
+
26
+ ### Django
27
+
28
+ ```python
29
+ from django.http import JsonResponse
30
+ from hotosm_auth.integrations.django import require_auth
31
+
32
+ @require_auth
33
+ def my_view(request):
34
+ return JsonResponse({"user_id": request.user.id})
35
+ ```
36
+
37
+ ## Features
38
+
39
+ - JWT validation for Hanko tokens
40
+ - OSM OAuth 2.0 integration
41
+ - User mapping between Hanko and application users
42
+ - FastAPI and Django integrations
43
+
44
+ ## Configuration
45
+
46
+ Set these environment variables:
47
+
48
+ ```bash
49
+ HANKO_API_URL=https://login.hotosm.org
50
+ OSM_CLIENT_ID=your_osm_client_id
51
+ OSM_CLIENT_SECRET=your_osm_secret
52
+ COOKIE_SECRET=your_secret_key
53
+ ```
54
+
55
+ ## License
56
+
57
+ AGPL-3.0
@@ -0,0 +1,81 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "hotosm-auth"
7
+ version = "0.2.3"
8
+ description = "HOTOSM SSO authentication library for Hanko + OSM OAuth"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "AGPL-3.0" }
12
+ authors = [
13
+ { name = "HOTOSM", email = "tech@hotosm.org" },
14
+ ]
15
+ keywords = ["hotosm", "auth", "hanko", "osm", "oauth", "sso", "fastapi", "django"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Topic :: Software Development :: Libraries :: Python Modules",
25
+ "Framework :: FastAPI",
26
+ "Framework :: Django",
27
+ ]
28
+
29
+ dependencies = [
30
+ "pyjwt[crypto]>=2.8.0",
31
+ "httpx>=0.25.0",
32
+ "cryptography>=41.0.0",
33
+ "pydantic>=2.0.0",
34
+ "python-dateutil>=2.8.0",
35
+ ]
36
+
37
+ [project.optional-dependencies]
38
+ fastapi = [
39
+ "fastapi>=0.104.0",
40
+ "sqlalchemy>=2.0.0",
41
+ ]
42
+ django = [
43
+ "django>=4.2.0",
44
+ "djangorestframework>=3.14.0",
45
+ ]
46
+ dev = [
47
+ "pytest>=7.4.0",
48
+ "pytest-asyncio>=0.21.0",
49
+ "pytest-cov>=4.1.0",
50
+ "black>=23.0.0",
51
+ "ruff>=0.1.0",
52
+ "mypy>=1.5.0",
53
+ ]
54
+
55
+ [project.urls]
56
+ Homepage = "https://github.com/hotosm/login"
57
+ Documentation = "https://hotosm.github.io/auth-docs"
58
+ Repository = "https://github.com/hotosm/login/tree/main/auth-libs/python"
59
+ Issues = "https://github.com/hotosm/login/issues"
60
+
61
+ [tool.hatch.build.targets.wheel]
62
+ packages = ["src/hotosm_auth", "src/hotosm_auth_fastapi", "src/hotosm_auth_django"]
63
+
64
+ [tool.pytest.ini_options]
65
+ asyncio_mode = "auto"
66
+ testpaths = ["tests"]
67
+
68
+ [tool.black]
69
+ line-length = 88
70
+ target-version = ['py310']
71
+
72
+ [tool.ruff]
73
+ line-length = 88
74
+ target-version = "py310"
75
+ select = ["E", "F", "I", "N", "W"]
76
+
77
+ [tool.mypy]
78
+ python_version = "3.10"
79
+ warn_return_any = true
80
+ warn_unused_configs = true
81
+ disallow_untyped_defs = true
@@ -0,0 +1,87 @@
1
+ """
2
+ hotosm-auth: HOTOSM SSO Authentication Library
3
+
4
+ This library provides authentication for HOTOSM applications using:
5
+ - Hanko v2.1.0 for base SSO (Google, GitHub, Email/Password)
6
+ - OpenStreetMap OAuth 2.0 for OSM authorization
7
+
8
+ Core Package Structure:
9
+ - hotosm_auth: Core functionality (this package)
10
+ - hotosm_auth_fastapi: FastAPI integration
11
+ - hotosm_auth_django: Django integration
12
+
13
+ Quick Start (FastAPI):
14
+ from hotosm_auth_fastapi import setup_auth, Auth
15
+
16
+ app = FastAPI()
17
+ setup_auth(app)
18
+
19
+ @app.get("/me")
20
+ async def me(auth: Auth):
21
+ return {"user": auth.user.email}
22
+
23
+ Quick Start (Django):
24
+ # settings.py
25
+ MIDDLEWARE = ['hotosm_auth_django.HankoAuthMiddleware']
26
+
27
+ # views.py
28
+ from hotosm_auth_django import login_required
29
+
30
+ @login_required
31
+ def my_view(request):
32
+ return JsonResponse({"email": request.hotosm.user.email})
33
+ """
34
+
35
+ __version__ = "0.2.0"
36
+
37
+ # Core models and configuration
38
+ from hotosm_auth.models import HankoUser, OSMConnection, OSMScope
39
+ from hotosm_auth.config import AuthConfig
40
+ from hotosm_auth.exceptions import (
41
+ AuthenticationError,
42
+ TokenExpiredError,
43
+ TokenInvalidError,
44
+ CookieDecryptionError,
45
+ OSMOAuthError,
46
+ )
47
+ from hotosm_auth.jwt_validator import JWTValidator
48
+ from hotosm_auth.crypto import CookieCrypto
49
+ from hotosm_auth.osm_oauth import OSMOAuthClient
50
+ from hotosm_auth.logger import get_logger, log_auth_event
51
+
52
+ # Admin schemas (used by both FastAPI and Django)
53
+ from hotosm_auth.schemas.admin import (
54
+ MappingResponse,
55
+ MappingListResponse,
56
+ MappingCreate,
57
+ MappingUpdate,
58
+ )
59
+
60
+ __all__ = [
61
+ # Version
62
+ "__version__",
63
+ # Models
64
+ "HankoUser",
65
+ "OSMConnection",
66
+ "OSMScope",
67
+ # Configuration
68
+ "AuthConfig",
69
+ # Exceptions
70
+ "AuthenticationError",
71
+ "TokenExpiredError",
72
+ "TokenInvalidError",
73
+ "CookieDecryptionError",
74
+ "OSMOAuthError",
75
+ # Core classes
76
+ "JWTValidator",
77
+ "CookieCrypto",
78
+ "OSMOAuthClient",
79
+ # Logging
80
+ "get_logger",
81
+ "log_auth_event",
82
+ # Admin schemas
83
+ "MappingResponse",
84
+ "MappingListResponse",
85
+ "MappingCreate",
86
+ "MappingUpdate",
87
+ ]
@@ -0,0 +1,279 @@
1
+ """
2
+ Configuration for HOTOSM authentication.
3
+
4
+ Apps should create one AuthConfig instance at startup with their settings.
5
+ """
6
+
7
+ import os
8
+ from typing import Optional
9
+ from pydantic import BaseModel, Field, HttpUrl
10
+ from hotosm_auth.logger import get_logger
11
+
12
+ logger = get_logger(__name__)
13
+
14
+
15
+ class AuthConfig(BaseModel):
16
+ """Configuration for hotosm-auth library.
17
+
18
+ Example usage:
19
+ config = AuthConfig(
20
+ hanko_api_url="https://login.hotosm.org",
21
+ cookie_secret="your-secret-key-32-bytes-min",
22
+ cookie_domain=".hotosm.org",
23
+ osm_enabled=True,
24
+ )
25
+ """
26
+
27
+ # Hanko configuration
28
+ hanko_api_url: HttpUrl = Field(
29
+ ...,
30
+ description="Hanko API URL (e.g., https://login.hotosm.org)",
31
+ )
32
+
33
+ # Cookie configuration
34
+ cookie_secret: str = Field(
35
+ ...,
36
+ min_length=32,
37
+ description="Secret key for encrypting OSM cookies (min 32 bytes)",
38
+ )
39
+ cookie_domain: Optional[str] = Field(
40
+ None,
41
+ description="Domain for cookies (e.g., '.hotosm.org' for all subdomains)",
42
+ )
43
+ cookie_secure: bool = Field(
44
+ True,
45
+ description="Use secure cookies (HTTPS only). Set False for local dev.",
46
+ )
47
+ cookie_samesite: str = Field(
48
+ "lax",
49
+ description="SameSite cookie policy: 'lax', 'strict', or 'none'",
50
+ )
51
+
52
+ # OSM OAuth configuration
53
+ osm_enabled: bool = Field(
54
+ False,
55
+ description="Enable OSM OAuth integration",
56
+ )
57
+ osm_client_id: Optional[str] = Field(
58
+ None,
59
+ description="OSM OAuth client ID (required if osm_enabled=True)",
60
+ )
61
+ osm_client_secret: Optional[str] = Field(
62
+ None,
63
+ description="OSM OAuth client secret (required if osm_enabled=True)",
64
+ )
65
+ osm_redirect_uri: Optional[str] = Field(
66
+ None,
67
+ description="OSM OAuth redirect URI (e.g., https://app.hotosm.org/auth/osm/callback)",
68
+ )
69
+ osm_scopes: list[str] = Field(
70
+ default_factory=lambda: ["read_prefs"],
71
+ description="Default OSM OAuth scopes to request",
72
+ )
73
+ osm_api_url: HttpUrl = Field(
74
+ default="https://www.openstreetmap.org",
75
+ description="OSM API base URL (use https://master.apis.dev.openstreetmap.org for dev)",
76
+ )
77
+
78
+ # JWT validation
79
+ jwt_audience: Optional[str] = Field(
80
+ None,
81
+ description="Expected JWT audience claim (optional)",
82
+ )
83
+ jwt_issuer: Optional[str] = Field(
84
+ "auto",
85
+ description="Expected JWT issuer claim (optional, 'auto' defaults to hanko_api_url, None skips validation)",
86
+ )
87
+
88
+ # JWKS caching
89
+ jwks_cache_ttl: int = Field(
90
+ 3600,
91
+ description="JWKS cache TTL in seconds (default 1 hour)",
92
+ )
93
+
94
+ # Admin configuration
95
+ admin_emails: str = Field(
96
+ "",
97
+ description="Comma-separated list of admin email addresses",
98
+ )
99
+
100
+ @property
101
+ def admin_email_list(self) -> list[str]:
102
+ """Parse admin_emails into a list of lowercase email addresses."""
103
+ return [e.strip().lower() for e in self.admin_emails.split(",") if e.strip()]
104
+
105
+ def model_post_init(self, __context) -> None:
106
+ """Validate configuration after initialization."""
107
+ # If OSM is enabled, require client credentials
108
+ if self.osm_enabled:
109
+ if not self.osm_client_id:
110
+ raise ValueError("osm_client_id is required when osm_enabled=True")
111
+ if not self.osm_client_secret:
112
+ raise ValueError("osm_client_secret is required when osm_enabled=True")
113
+
114
+ # Auto-generate osm_redirect_uri if not provided
115
+ if not self.osm_redirect_uri:
116
+ # Default pattern: {HANKO_API_URL}/auth/osm/callback
117
+ auto_uri = f"{str(self.hanko_api_url).rstrip('/')}/auth/osm/callback"
118
+ logger.info(f"OSM_REDIRECT_URI not set, auto-generating: {auto_uri}")
119
+ logger.info("If this doesn't match your app's path, set OSM_REDIRECT_URI explicitly.")
120
+ object.__setattr__(self, 'osm_redirect_uri', auto_uri)
121
+
122
+ # Default JWT issuer to Hanko API URL if set to "auto"
123
+ # Use object.__setattr__ because model is frozen
124
+ if self.jwt_issuer == "auto":
125
+ # Strip trailing slash from HttpUrl (Pydantic adds it automatically)
126
+ issuer_url = str(self.hanko_api_url).rstrip('/')
127
+ object.__setattr__(self, 'jwt_issuer', issuer_url)
128
+ logger.info(f"JWT issuer set to: {issuer_url} (from hanko_api_url)")
129
+ elif self.jwt_issuer:
130
+ logger.info(f"JWT issuer explicitly set to: {self.jwt_issuer}")
131
+ else:
132
+ logger.warning("JWT issuer validation is DISABLED (jwt_issuer=None)")
133
+ # If None, issuer validation is skipped
134
+
135
+ @classmethod
136
+ def from_env(cls) -> "AuthConfig":
137
+ """
138
+ Load configuration from environment variables.
139
+
140
+ This is the recommended way to configure hotosm-auth in production.
141
+
142
+ Required environment variables:
143
+ HANKO_API_URL: Hanko API URL (e.g., https://login.hotosm.org)
144
+ COOKIE_SECRET: Secret key for encrypting cookies (min 32 bytes)
145
+
146
+ Optional environment variables:
147
+ COOKIE_DOMAIN: Cookie domain (e.g., ".hotosm.org")
148
+ COOKIE_SECURE: Use secure cookies (default: "true")
149
+ COOKIE_SAMESITE: SameSite policy (default: "lax")
150
+ JWT_AUDIENCE: Expected JWT audience
151
+ JWT_ISSUER: Expected JWT issuer (default: "auto")
152
+ OSM_CLIENT_ID: OSM OAuth client ID
153
+ OSM_CLIENT_SECRET: OSM OAuth client secret
154
+ OSM_REDIRECT_URI: OSM OAuth redirect URI (optional, auto-generated from HANKO_API_URL if not set)
155
+ OSM_SCOPES: Space-separated OSM scopes (default: "read_prefs")
156
+ OSM_API_URL: OSM API URL (default: https://www.openstreetmap.org)
157
+ ADMIN_EMAILS: Comma-separated list of admin email addresses
158
+
159
+ Example:
160
+ # .env file
161
+ HANKO_API_URL=https://login.hotosm.org
162
+ COOKIE_SECRET=your-secret-key-min-32-bytes
163
+ OSM_CLIENT_ID=your-osm-client-id
164
+ OSM_CLIENT_SECRET=your-osm-client-secret
165
+
166
+ # Python code
167
+ config = AuthConfig.from_env()
168
+
169
+ Returns:
170
+ AuthConfig: Configuration loaded from environment
171
+
172
+ Raises:
173
+ ValueError: If required environment variables are missing
174
+ """
175
+ # Try to load .env file from current working directory
176
+ try:
177
+ from dotenv import load_dotenv
178
+ import os as _os
179
+ # Load from current directory and walk up parent directories
180
+ load_dotenv(dotenv_path=_os.path.join(_os.getcwd(), '.env'), verbose=False)
181
+ # Also try parent directories
182
+ load_dotenv(verbose=False)
183
+ except ImportError:
184
+ # dotenv not installed, rely on environment variables being set
185
+ pass
186
+
187
+ # Required variables
188
+ hanko_api_url = os.getenv("HANKO_API_URL")
189
+ if not hanko_api_url:
190
+ raise ValueError(
191
+ "HANKO_API_URL environment variable is required. "
192
+ "Set it to your Hanko API URL (e.g., https://login.hotosm.org)"
193
+ )
194
+
195
+ cookie_secret = os.getenv("COOKIE_SECRET")
196
+ if not cookie_secret:
197
+ raise ValueError(
198
+ "COOKIE_SECRET environment variable is required. "
199
+ "Generate a secure secret with: python -c \"import secrets; print(secrets.token_urlsafe(32))\""
200
+ )
201
+
202
+ # Optional variables with smart defaults
203
+ cookie_domain = os.getenv("COOKIE_DOMAIN")
204
+ cookie_secure_env = os.getenv("COOKIE_SECURE")
205
+ cookie_samesite = os.getenv("COOKIE_SAMESITE", "lax")
206
+
207
+ # Smart default for COOKIE_DOMAIN (based on HANKO_API_URL)
208
+ if not cookie_domain:
209
+ from urllib.parse import urlparse
210
+ parsed = urlparse(hanko_api_url)
211
+ hostname = parsed.hostname or "localhost"
212
+
213
+ if hostname in ["localhost", "127.0.0.1"]:
214
+ cookie_domain = "localhost"
215
+ logger.info("COOKIE_DOMAIN not set, auto-detected: localhost")
216
+ else:
217
+ # Production: extract root domain with dot prefix
218
+ # e.g., "login.hotosm.org" → ".hotosm.org"
219
+ parts = hostname.split(".")
220
+ if len(parts) >= 2:
221
+ cookie_domain = f".{'.'.join(parts[-2:])}"
222
+ logger.info(f"COOKIE_DOMAIN not set, auto-detected: {cookie_domain}")
223
+ else:
224
+ cookie_domain = None # No domain restriction
225
+ logger.info("COOKIE_DOMAIN not set, using no domain restriction")
226
+
227
+ # Smart default for COOKIE_SECURE (based on HANKO_API_URL scheme)
228
+ if cookie_secure_env is None:
229
+ cookie_secure = hanko_api_url.startswith("https://")
230
+ logger.info(f"COOKIE_SECURE not set, auto-detected: {cookie_secure} (from {hanko_api_url})")
231
+ else:
232
+ cookie_secure = cookie_secure_env.lower() == "true"
233
+
234
+ # JWT configuration
235
+ jwt_audience = os.getenv("JWT_AUDIENCE") or None
236
+ # If JWT_ISSUER is not set or empty, default to "auto"
237
+ # This ensures issuer validation is enabled by default
238
+ jwt_issuer_env = os.getenv("JWT_ISSUER")
239
+ if jwt_issuer_env and jwt_issuer_env.strip():
240
+ jwt_issuer = jwt_issuer_env
241
+ logger.info(f"JWT_ISSUER from env: {jwt_issuer}")
242
+ else:
243
+ jwt_issuer = "auto" # Default to auto instead of None
244
+ logger.info("JWT_ISSUER not set, defaulting to 'auto'")
245
+
246
+ # OSM configuration
247
+ osm_client_id = os.getenv("OSM_CLIENT_ID")
248
+ osm_client_secret = os.getenv("OSM_CLIENT_SECRET")
249
+ osm_redirect_uri = os.getenv("OSM_REDIRECT_URI")
250
+ osm_scopes_str = os.getenv("OSM_SCOPES", "read_prefs")
251
+ osm_scopes = osm_scopes_str.split() if osm_scopes_str else ["read_prefs"]
252
+ osm_api_url = os.getenv("OSM_API_URL", "https://www.openstreetmap.org")
253
+
254
+ # Determine if OSM is enabled
255
+ osm_enabled = bool(osm_client_id and osm_client_secret)
256
+
257
+ # Admin configuration
258
+ admin_emails = os.getenv("ADMIN_EMAILS", "")
259
+
260
+ return cls(
261
+ hanko_api_url=hanko_api_url,
262
+ cookie_secret=cookie_secret,
263
+ cookie_domain=cookie_domain,
264
+ cookie_secure=cookie_secure,
265
+ cookie_samesite=cookie_samesite,
266
+ jwt_audience=jwt_audience,
267
+ jwt_issuer=jwt_issuer,
268
+ osm_enabled=osm_enabled,
269
+ osm_client_id=osm_client_id if osm_enabled else None,
270
+ osm_client_secret=osm_client_secret if osm_enabled else None,
271
+ osm_redirect_uri=osm_redirect_uri if osm_enabled else None,
272
+ osm_scopes=osm_scopes if osm_enabled else [],
273
+ osm_api_url=osm_api_url,
274
+ admin_emails=admin_emails,
275
+ )
276
+
277
+ class Config:
278
+ """Pydantic configuration."""
279
+ frozen = True # Make config immutable after creation