Skip to content

Commit 057e231

Browse files
Merge pull request #492 from hotosm/feature/hanko-auth
Add Hanko SSO Authentication
2 parents 8cc9a35 + b0d2500 commit 057e231

35 files changed

Lines changed: 1424 additions & 210 deletions

.env.dev.example

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,16 @@ OSM_SCOPE=read_prefs
2222
OSM_LOGIN_REDIRECT_URI=http://127.0.0.1:3500/authenticate/
2323
OSM_SECRET_KEY=dev-osm-secret-key
2424

25+
# Authentication: "legacy" or "hanko"
26+
AUTH_PROVIDER=legacy
27+
# HANKO_API_URL=https://dev.login.hotosm.org
28+
# COOKIE_SECRET=your-cookie-secret
29+
# COOKIE_DOMAIN=.hotosm.org
30+
# COOKIE_SECURE=true
31+
# JWT_AUDIENCE=
32+
# LOGIN_URL=https://dev.login.hotosm.org
33+
# OSM_REDIRECT_URI=http://127.0.0.1:8000/api/v1/auth/osm/callback/
34+
2535
ALLOWED_ORIGINS=http://127.0.0.1:3500
2636
FRONTEND_URL=http://127.0.0.1:3500
2737

@@ -34,8 +44,9 @@ EMAIL_USE_TLS=False
3444

3545
ENABLE_FAIR_PREDICTOR=True
3646

37-
## Frontend
38-
47+
## Frontend
3948

4049
VITE_BASE_API_URL="http://localhost:8200/api/v1/"
4150
VITE_FAIR_PREDICTOR_API_URL="http://localhost:8200/api/v1/fairpredictor/predict/"
51+
VITE_AUTH_PROVIDER="legacy"
52+
# VITE_HANKO_URL="https://dev.login.hotosm.org"

PR_DRAFT.md

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# Add Hanko SSO Authentication
2+
3+
## Summary
4+
5+
Integrates Hanko SSO authentication as an alternative to legacy OSM OAuth, enabling single sign-on across the HOT ecosystem via login.hotosm.org.
6+
7+
**Key changes:**
8+
- New `AUTH_PROVIDER` setting to switch between `legacy` (current) and `hanko` modes
9+
- Hanko auth uses JWT cookies instead of access-token headers
10+
- User onboarding flow to link existing accounts or create new ones
11+
- Shared auth-libs web component for login UI
12+
13+
**This PR is functionality only.** Deploy configuration (Dockerfiles, workflows, nginx) comes in a separate PR.
14+
15+
## For Deployment: Required Secrets & Variables
16+
17+
### Backend Environment Variables
18+
19+
When deploying with `AUTH_PROVIDER=hanko`:
20+
21+
| Variable | Required | Example | Description |
22+
|----------|----------|---------|-------------|
23+
| `AUTH_PROVIDER` | Yes | `hanko` | Set to `hanko` to enable SSO |
24+
| `HANKO_API_URL` | Yes | `https://login.hotosm.org` | Hanko service URL |
25+
| `COOKIE_SECRET` | Yes | `<shared-secret>` | **Must match login service** - for cookie encryption |
26+
| `COOKIE_DOMAIN` | Yes | `.hotosm.org` | Domain for auth cookies |
27+
| `LOGIN_URL` | No | `https://login.hotosm.org` | Login service URL for redirects |
28+
| `FRONTEND_URL` | Yes | `https://fair.hotosm.org` | Frontend URL for redirects |
29+
30+
### Frontend Environment Variables
31+
32+
| Variable | Required | Example | Description |
33+
|----------|----------|---------|-------------|
34+
| `VITE_AUTH_PROVIDER` | Yes | `hanko` | Must match backend |
35+
| `VITE_HANKO_URL` | Yes | `https://login.hotosm.org` | Hanko service URL |
36+
37+
### Important Notes
38+
39+
1. **`COOKIE_SECRET` must be shared** with the login service (login.hotosm.org) - coordinate with login team
40+
2. **`COOKIE_DOMAIN`** should be `.hotosm.org` for production so cookies work across subdomains
41+
3. **Default is `legacy` mode** - existing deployments continue working without changes
42+
43+
## New Dependencies
44+
45+
| Package | Location | Notes |
46+
|---------|----------|-------|
47+
| `hotosm-auth[django]==0.2.10` | Backend (PyPI) | Hanko auth middleware & helpers |
48+
| `@hotosm/hanko-auth` | Frontend (npm) | Login web component |
49+
50+
## New API Endpoints
51+
52+
| Endpoint | Method | Description |
53+
|----------|--------|-------------|
54+
| `/api/v1/auth/onboarding/` | GET | Callback from login service after onboarding |
55+
| `/api/v1/auth/status/` | GET | Check authentication status |
56+
57+
## How it Works
58+
59+
### Legacy Mode (default)
60+
```
61+
AUTH_PROVIDER=legacy
62+
```
63+
No changes - continues using OSM OAuth with access-token header.
64+
65+
### Hanko Mode
66+
```
67+
AUTH_PROVIDER=hanko
68+
```
69+
1. User clicks login → redirected to login.hotosm.org
70+
2. Hanko sets JWT cookie after authentication
71+
3. Backend middleware validates JWT cookie
72+
4. If user mapping exists → authenticated
73+
5. If no mapping → user goes through onboarding
74+
75+
### Onboarding Flow
76+
New Hanko users choose:
77+
- **"I had an account"** → Connect OSM to recover existing fAIr data
78+
- **"I'm new"** → Create fresh account with synthetic ID
79+
80+
## Test Plan
81+
82+
- [ ] Legacy auth continues working (`AUTH_PROVIDER=legacy`)
83+
- [ ] Hanko login/logout flow works
84+
- [ ] New user onboarding creates account
85+
- [ ] Existing user onboarding recovers data
86+
- [ ] Navbar shows correct user state
87+
- [ ] Protected routes redirect to login correctly
88+
- [ ] `?mine=true` filter works for both auth types
89+
90+
## Backward Compatibility
91+
92+
- **Default is `legacy`** - no action needed for existing deployments
93+
- Existing users continue working with OSM OAuth
94+
- Can switch to `hanko` when ready by setting environment variables

backend/core/exceptions.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ class AuthenticationException(FairBaseException):
4040
error_code = "AUTHENTICATION_ERROR"
4141

4242

43+
class LoginException(FairBaseException):
44+
default_message = "Login failed"
45+
status_code = status.HTTP_400_BAD_REQUEST
46+
error_code = "LOGIN_ERROR"
47+
48+
4349
class AuthorizationException(FairBaseException):
4450
default_message = "Permission denied"
4551
status_code = status.HTTP_403_FORBIDDEN

backend/core/views.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
from django_ratelimit.decorators import ratelimit
4343
from geojson2osm import geojson2osm
4444
from login.authentication import OsmAuthentication
45+
from login.hanko_helpers import HankoUserFilterMixin
4546
from login.permissions import (
4647
IsAdminUser,
4748
IsOsmAuthenticated,
@@ -61,6 +62,13 @@
6162
from rest_framework_gis.filters import InBBoxFilter, TMSTileFilter
6263
from shapely.geometry import box
6364

65+
from login.authentication import OsmAuthentication
66+
from login.permissions import (
67+
IsAdminUser,
68+
IsOsmAuthenticated,
69+
IsOwnerOrReadOnly,
70+
IsStaffUser,
71+
)
6472
from .exceptions import (
6573
ExternalServiceException,
6674
ResourceNotFoundException,
@@ -248,7 +256,7 @@ def home(request):
248256
)
249257

250258

251-
class DatasetViewSet(BaseSpatialViewSet):
259+
class DatasetViewSet(HankoUserFilterMixin, BaseSpatialViewSet):
252260
"""
253261
API endpoint for managing training datasets.
254262
@@ -356,7 +364,7 @@ def create(self, request, *args, **kwargs):
356364
return super().create(request, *args, **kwargs)
357365

358366

359-
class ModelViewSet(BaseSpatialViewSet):
367+
class ModelViewSet(HankoUserFilterMixin, BaseSpatialViewSet):
360368
"""
361369
API endpoint for managing AI models.
362370

backend/docker_sample_env

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,22 @@ SECRET_KEY=yl2w)c0boi_ma-1v5)935^2#&m*r!1s9z9^*9e5co^08_ixzo6
33
DATABASE_URL=postgis://postgres:admin@pgsql:5432/ai
44
EXPORT_TOOL_API_URL=https://raw api url.hotosm.org/v1
55
CORS_ALLOWED_ORIGINS=http://127.0.0.1:3000
6+
# Authentication: "legacy" (default) or "hanko"
7+
AUTH_PROVIDER=legacy
8+
9+
# Legacy OSM OAuth (when AUTH_PROVIDER=legacy)
610
OSM_CLIENT_ID=
711
OSM_CLIENT_SECRET=
812
OSM_URL=https://www.openstreetmap.org
913
OSM_SCOPE=read_prefs
1014
OSM_LOGIN_REDIRECT_URI=http://127.0.0.1:3000/authenticate/
1115
OSM_SECRET_KEY=
16+
17+
# Hanko SSO (when AUTH_PROVIDER=hanko)
18+
# HANKO_API_URL=https://login.hotosm.org
19+
# COOKIE_SECRET=shared-secret-for-cookie-encryption
20+
# COOKIE_DOMAIN=.hotosm.org
21+
# LOGIN_URL=https://login.hotosm.org
1222
CELERY_BROKER_URL="redis://redis:6379/0"
1323
CELERY_RESULT_BACKEND="redis://redis:6379/0"
1424
RAMP_HOME="/RAMP_HOME"

backend/fairproject/settings.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,28 @@
5555
default="http://127.0.0.1:8000/api/v1/auth/callback/" if DEBUG else None,
5656
)
5757

58+
# Authentication provider constants
59+
class AuthProvider:
60+
LEGACY = "legacy"
61+
HANKO = "hanko"
62+
63+
64+
AUTH_PROVIDER = env("AUTH_PROVIDER", default=AuthProvider.LEGACY)
65+
66+
if AUTH_PROVIDER == AuthProvider.HANKO:
67+
HANKO_API_URL = env("HANKO_API_URL")
68+
COOKIE_SECRET = env("COOKIE_SECRET")
69+
COOKIE_DOMAIN = env("COOKIE_DOMAIN", default=None)
70+
COOKIE_SECURE = env.bool("COOKIE_SECURE", default=not DEBUG)
71+
JWT_AUDIENCE = env("JWT_AUDIENCE", default=None)
72+
73+
LOGIN_URL = env("LOGIN_URL", default="https://login.hotosm.org")
74+
75+
OSM_REDIRECT_URI = env(
76+
"OSM_REDIRECT_URI",
77+
default="http://127.0.0.1:8000/api/v1/auth/osm/callback/" if DEBUG else None,
78+
)
79+
5880

5981
USE_S3_TO_UPLOAD_MODELS = env.bool("USE_S3_TO_UPLOAD_MODELS", default=False)
6082

@@ -123,6 +145,9 @@
123145
"login",
124146
]
125147

148+
if AUTH_PROVIDER == AuthProvider.HANKO:
149+
INSTALLED_APPS.append("hotosm_auth_django")
150+
126151
MIDDLEWARE = [
127152
"corsheaders.middleware.CorsMiddleware",
128153
"django.middleware.security.SecurityMiddleware",
@@ -134,6 +159,12 @@
134159
"django.middleware.clickjacking.XFrameOptionsMiddleware",
135160
]
136161

162+
if AUTH_PROVIDER == AuthProvider.HANKO:
163+
MIDDLEWARE.insert(
164+
MIDDLEWARE.index("django.contrib.auth.middleware.AuthenticationMiddleware"),
165+
"hotosm_auth_django.HankoAuthMiddleware",
166+
)
167+
137168
ROOT_URLCONF = "fairproject.urls"
138169
WSGI_APPLICATION = "fairproject.wsgi.application"
139170

@@ -438,6 +469,9 @@ def get_allowed_hosts():
438469
if hostname not in hosts:
439470
hosts.append(hostname)
440471

472+
configured_hosts = env.list("ALLOWED_HOSTS", default=[])
473+
hosts.extend(configured_hosts)
474+
441475
if DEBUG:
442476
try:
443477
hosts.extend([gethostname(), gethostbyname(gethostname())])

backend/fairproject/urls.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
from core.views import home
1818
from django.conf import settings
19+
from fairproject.settings import AuthProvider
1920
from django.conf.urls import include
2021
from django.contrib import admin
2122
from django.urls import path
@@ -25,14 +26,26 @@
2526
SpectacularSwaggerView,
2627
)
2728

29+
admin_mapping_patterns = []
30+
if settings.AUTH_PROVIDER == AuthProvider.HANKO:
31+
from hotosm_auth_django.admin_routes import create_admin_urlpatterns
32+
admin_mapping_patterns = create_admin_urlpatterns(
33+
app_name="fair",
34+
user_model="login.OsmUser",
35+
user_id_column="osm_id",
36+
user_name_column="username",
37+
user_email_column="email",
38+
)
39+
2840
urlpatterns = [
2941
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
3042
path("api/swagger/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"),
3143
path("api/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"),
3244
path("api/", home, name="home"),
3345
path("api/v1/auth/", include("login.urls")),
3446
path("api/v1/", include("core.urls")),
35-
path("api/admin/", admin.site.urls),
47+
path("api/admin/", include(admin_mapping_patterns)),
48+
path("django-admin/", admin.site.urls),
3649
]
3750

3851
if settings.DEBUG:

backend/login/authentication.py

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,22 @@
11
import logging
22
from django.conf import settings
3-
from osm_login_python.core import Auth
3+
from fairproject.settings import AuthProvider
44
from rest_framework import authentication, exceptions
55

66
from .models import OsmUser
77

88
logger = logging.getLogger(__name__)
99

1010

11-
class OsmAuthentication(authentication.BaseAuthentication):
11+
class LegacyOsmAuthentication(authentication.BaseAuthentication):
12+
"""Legacy OSM OAuth authentication using osm_login_python.
13+
14+
Used when AUTH_PROVIDER="legacy".
15+
Reads access-token from header and validates directly with OSM.
16+
"""
1217
def authenticate(self, request):
18+
from osm_login_python.core import Auth
19+
1320
access_token = request.headers.get(
1421
"access-token"
1522
) # get the access token as header
@@ -56,3 +63,46 @@ def authenticate(self, request):
5663
"OSM authentication failed: Invalid or expired access token"
5764
)
5865
return (user, None) # authentication successful return id,user_name,img
66+
67+
68+
class HankoAuthentication(authentication.BaseAuthentication):
69+
"""Hanko SSO authentication using user mappings."""
70+
def authenticate(self, request):
71+
from hotosm_auth_django import get_mapped_user_id
72+
73+
if not hasattr(request, 'hotosm'):
74+
raise exceptions.AuthenticationFailed(
75+
"HankoAuthMiddleware not configured"
76+
)
77+
78+
hanko_user = request.hotosm.user
79+
80+
if not hanko_user:
81+
logger.debug("No Hanko user in request")
82+
return (None, None)
83+
84+
mapped_osm_id = get_mapped_user_id(hanko_user, app_name="fair")
85+
86+
if mapped_osm_id is not None:
87+
try:
88+
osm_id = int(mapped_osm_id)
89+
user = OsmUser.objects.get(osm_id=osm_id)
90+
logger.debug(f"Authenticated via mapping: Hanko={hanko_user.email}, osm_id={osm_id}")
91+
return (user, None)
92+
except (OsmUser.DoesNotExist, ValueError) as e:
93+
logger.warning(f"Mapping exists but user not found: osm_id={mapped_osm_id}, error={e}")
94+
# Fall through to onboarding.
95+
96+
request.needs_onboarding = True
97+
request.hanko_user_for_onboarding = hanko_user
98+
logger.debug(f"Hanko user {hanko_user.email} needs onboarding (no mapping)")
99+
return (None, None)
100+
101+
102+
# Select authentication class based on AUTH_PROVIDER
103+
if settings.AUTH_PROVIDER == AuthProvider.HANKO:
104+
logger.info("Using Hanko SSO authentication")
105+
OsmAuthentication = HankoAuthentication
106+
else:
107+
logger.info("Using legacy OSM authentication")
108+
OsmAuthentication = LegacyOsmAuthentication

0 commit comments

Comments
 (0)