Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions .env.dev.example
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,16 @@ OSM_SCOPE=read_prefs
OSM_LOGIN_REDIRECT_URI=http://127.0.0.1:3500/authenticate/
OSM_SECRET_KEY=dev-osm-secret-key

# Authentication: "legacy" or "hanko"
AUTH_PROVIDER=legacy
# HANKO_API_URL=https://dev.login.hotosm.org
# COOKIE_SECRET=your-cookie-secret
# COOKIE_DOMAIN=.hotosm.org
# COOKIE_SECURE=true
# JWT_AUDIENCE=
# LOGIN_URL=https://dev.login.hotosm.org
# OSM_REDIRECT_URI=http://127.0.0.1:8000/api/v1/auth/osm/callback/

ALLOWED_ORIGINS=http://127.0.0.1:3500
FRONTEND_URL=http://127.0.0.1:3500

Expand All @@ -34,8 +44,9 @@ EMAIL_USE_TLS=False

ENABLE_FAIR_PREDICTOR=True

## Frontend

## Frontend

VITE_BASE_API_URL="http://localhost:8200/api/v1/"
VITE_FAIR_PREDICTOR_API_URL="http://localhost:8200/api/v1/fairpredictor/predict/"
VITE_AUTH_PROVIDER="legacy"
# VITE_HANKO_URL="https://dev.login.hotosm.org"
94 changes: 94 additions & 0 deletions PR_DRAFT.md
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kindly remove this from the PR , rather its okay to include it in the description , if you are comfortable with .md you can put this in gist and share the link !

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done!

Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Add Hanko SSO Authentication

## Summary

Integrates Hanko SSO authentication as an alternative to legacy OSM OAuth, enabling single sign-on across the HOT ecosystem via login.hotosm.org.

**Key changes:**
- New `AUTH_PROVIDER` setting to switch between `legacy` (current) and `hanko` modes
- Hanko auth uses JWT cookies instead of access-token headers
- User onboarding flow to link existing accounts or create new ones
- Shared auth-libs web component for login UI

**This PR is functionality only.** Deploy configuration (Dockerfiles, workflows, nginx) comes in a separate PR.

## For Deployment: Required Secrets & Variables

### Backend Environment Variables

When deploying with `AUTH_PROVIDER=hanko`:

| Variable | Required | Example | Description |
|----------|----------|---------|-------------|
| `AUTH_PROVIDER` | Yes | `hanko` | Set to `hanko` to enable SSO |
| `HANKO_API_URL` | Yes | `https://login.hotosm.org` | Hanko service URL |
| `COOKIE_SECRET` | Yes | `<shared-secret>` | **Must match login service** - for cookie encryption |
| `COOKIE_DOMAIN` | Yes | `.hotosm.org` | Domain for auth cookies |
| `LOGIN_URL` | No | `https://login.hotosm.org` | Login service URL for redirects |
| `FRONTEND_URL` | Yes | `https://fair.hotosm.org` | Frontend URL for redirects |

### Frontend Environment Variables

| Variable | Required | Example | Description |
|----------|----------|---------|-------------|
| `VITE_AUTH_PROVIDER` | Yes | `hanko` | Must match backend |
| `VITE_HANKO_URL` | Yes | `https://login.hotosm.org` | Hanko service URL |

### Important Notes

1. **`COOKIE_SECRET` must be shared** with the login service (login.hotosm.org) - coordinate with login team
2. **`COOKIE_DOMAIN`** should be `.hotosm.org` for production so cookies work across subdomains
3. **Default is `legacy` mode** - existing deployments continue working without changes

## New Dependencies

| Package | Location | Notes |
|---------|----------|-------|
| `hotosm-auth[django]==0.2.10` | Backend (PyPI) | Hanko auth middleware & helpers |
| `@hotosm/hanko-auth` | Frontend (npm) | Login web component |

## New API Endpoints

| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/v1/auth/onboarding/` | GET | Callback from login service after onboarding |
| `/api/v1/auth/status/` | GET | Check authentication status |

## How it Works

### Legacy Mode (default)
```
AUTH_PROVIDER=legacy
```
No changes - continues using OSM OAuth with access-token header.

### Hanko Mode
```
AUTH_PROVIDER=hanko
```
1. User clicks login → redirected to login.hotosm.org
2. Hanko sets JWT cookie after authentication
3. Backend middleware validates JWT cookie
4. If user mapping exists → authenticated
5. If no mapping → user goes through onboarding

### Onboarding Flow
New Hanko users choose:
- **"I had an account"** → Connect OSM to recover existing fAIr data
- **"I'm new"** → Create fresh account with synthetic ID

## Test Plan

- [ ] Legacy auth continues working (`AUTH_PROVIDER=legacy`)
- [ ] Hanko login/logout flow works
- [ ] New user onboarding creates account
- [ ] Existing user onboarding recovers data
- [ ] Navbar shows correct user state
- [ ] Protected routes redirect to login correctly
- [ ] `?mine=true` filter works for both auth types

## Backward Compatibility

- **Default is `legacy`** - no action needed for existing deployments
- Existing users continue working with OSM OAuth
- Can switch to `hanko` when ready by setting environment variables
6 changes: 6 additions & 0 deletions backend/core/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ class AuthenticationException(FairBaseException):
error_code = "AUTHENTICATION_ERROR"


class LoginException(FairBaseException):
default_message = "Login failed"
status_code = status.HTTP_400_BAD_REQUEST
error_code = "LOGIN_ERROR"


class AuthorizationException(FairBaseException):
default_message = "Permission denied"
status_code = status.HTTP_403_FORBIDDEN
Expand Down
12 changes: 10 additions & 2 deletions backend/core/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
from django_ratelimit.decorators import ratelimit
from geojson2osm import geojson2osm
from login.authentication import OsmAuthentication
from login.hanko_helpers import HankoUserFilterMixin
from login.permissions import (
IsAdminUser,
IsOsmAuthenticated,
Expand All @@ -61,6 +62,13 @@
from rest_framework_gis.filters import InBBoxFilter, TMSTileFilter
from shapely.geometry import box

from login.authentication import OsmAuthentication
from login.permissions import (
IsAdminUser,
IsOsmAuthenticated,
IsOwnerOrReadOnly,
IsStaffUser,
)
from .exceptions import (
ExternalServiceException,
ResourceNotFoundException,
Expand Down Expand Up @@ -247,7 +255,7 @@ def home(request):
)


class DatasetViewSet(BaseSpatialViewSet):
class DatasetViewSet(HankoUserFilterMixin, BaseSpatialViewSet):
"""
API endpoint for managing training datasets.

Expand Down Expand Up @@ -355,7 +363,7 @@ def create(self, request, *args, **kwargs):
return super().create(request, *args, **kwargs)


class ModelViewSet(BaseSpatialViewSet):
class ModelViewSet(HankoUserFilterMixin, BaseSpatialViewSet):
"""
API endpoint for managing AI models.

Expand Down
10 changes: 10 additions & 0 deletions backend/docker_sample_env
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,22 @@ SECRET_KEY=yl2w)c0boi_ma-1v5)935^2#&m*r!1s9z9^*9e5co^08_ixzo6
DATABASE_URL=postgis://postgres:admin@pgsql:5432/ai
EXPORT_TOOL_API_URL=https://raw api url.hotosm.org/v1
CORS_ALLOWED_ORIGINS=http://127.0.0.1:3000
# Authentication: "legacy" (default) or "hanko"
AUTH_PROVIDER=legacy

# Legacy OSM OAuth (when AUTH_PROVIDER=legacy)
OSM_CLIENT_ID=
OSM_CLIENT_SECRET=
OSM_URL=https://www.openstreetmap.org
OSM_SCOPE=read_prefs
OSM_LOGIN_REDIRECT_URI=http://127.0.0.1:3000/authenticate/
OSM_SECRET_KEY=

# Hanko SSO (when AUTH_PROVIDER=hanko)
# HANKO_API_URL=https://login.hotosm.org
# COOKIE_SECRET=shared-secret-for-cookie-encryption
# COOKIE_DOMAIN=.hotosm.org
# LOGIN_URL=https://login.hotosm.org
CELERY_BROKER_URL="redis://redis:6379/0"
CELERY_RESULT_BACKEND="redis://redis:6379/0"
RAMP_HOME="/RAMP_HOME"
Expand Down
34 changes: 34 additions & 0 deletions backend/fairproject/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,28 @@
default="http://127.0.0.1:8000/api/v1/auth/callback/" if DEBUG else None,
)

# Authentication provider constants
class AuthProvider:
LEGACY = "legacy"
HANKO = "hanko"


AUTH_PROVIDER = env("AUTH_PROVIDER", default=AuthProvider.LEGACY)

if AUTH_PROVIDER == AuthProvider.HANKO:
HANKO_API_URL = env("HANKO_API_URL")
COOKIE_SECRET = env("COOKIE_SECRET")
COOKIE_DOMAIN = env("COOKIE_DOMAIN", default=None)
COOKIE_SECURE = env.bool("COOKIE_SECURE", default=not DEBUG)
JWT_AUDIENCE = env("JWT_AUDIENCE", default=None)

LOGIN_URL = env("LOGIN_URL", default="https://login.hotosm.org")

OSM_REDIRECT_URI = env(
"OSM_REDIRECT_URI",
default="http://127.0.0.1:8000/api/v1/auth/osm/callback/" if DEBUG else None,
)


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

Expand Down Expand Up @@ -123,6 +145,9 @@
"login",
]

if AUTH_PROVIDER == AuthProvider.HANKO:
INSTALLED_APPS.append("hotosm_auth_django")

MIDDLEWARE = [
"corsheaders.middleware.CorsMiddleware",
"django.middleware.security.SecurityMiddleware",
Expand All @@ -134,6 +159,12 @@
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]

if AUTH_PROVIDER == AuthProvider.HANKO:
MIDDLEWARE.insert(
MIDDLEWARE.index("django.contrib.auth.middleware.AuthenticationMiddleware"),
"hotosm_auth_django.HankoAuthMiddleware",
)

ROOT_URLCONF = "fairproject.urls"
WSGI_APPLICATION = "fairproject.wsgi.application"

Expand Down Expand Up @@ -438,6 +469,9 @@ def get_allowed_hosts():
if hostname not in hosts:
hosts.append(hostname)

configured_hosts = env.list("ALLOWED_HOSTS", default=[])
hosts.extend(configured_hosts)

if DEBUG:
try:
hosts.extend([gethostname(), gethostbyname(gethostname())])
Expand Down
15 changes: 14 additions & 1 deletion backend/fairproject/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

from core.views import home
from django.conf import settings
from fairproject.settings import AuthProvider
from django.conf.urls import include
from django.contrib import admin
from django.urls import path
Expand All @@ -25,14 +26,26 @@
SpectacularSwaggerView,
)

admin_mapping_patterns = []
if settings.AUTH_PROVIDER == AuthProvider.HANKO:
from hotosm_auth_django.admin_routes import create_admin_urlpatterns
admin_mapping_patterns = create_admin_urlpatterns(
app_name="fair",
user_model="login.OsmUser",
user_id_column="osm_id",
user_name_column="username",
user_email_column="email",
)

urlpatterns = [
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
path("api/swagger/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"),
path("api/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"),
path("api/", home, name="home"),
path("api/v1/auth/", include("login.urls")),
path("api/v1/", include("core.urls")),
path("api/admin/", admin.site.urls),
path("api/admin/", include(admin_mapping_patterns)),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why ? what is api admin mapping patterns ?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these are admin endpoints from hotosm-auth for managing hanko-to-OSM user mappings. The login repo has an admin dashboard that communicates with these endpoints in all apps (fAIr, portal, drone-tm, etc.) to view/delete mappings. The mappings live in each app's DB, not in login.

path("django-admin/", admin.site.urls),
]

if settings.DEBUG:
Expand Down
54 changes: 52 additions & 2 deletions backend/login/authentication.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
import logging
from django.conf import settings
from osm_login_python.core import Auth
from fairproject.settings import AuthProvider
from rest_framework import authentication, exceptions

from .models import OsmUser

logger = logging.getLogger(__name__)


class OsmAuthentication(authentication.BaseAuthentication):
class LegacyOsmAuthentication(authentication.BaseAuthentication):
"""Legacy OSM OAuth authentication using osm_login_python.

Used when AUTH_PROVIDER="legacy".
Reads access-token from header and validates directly with OSM.
"""
def authenticate(self, request):
from osm_login_python.core import Auth

access_token = request.headers.get(
"access-token"
) # get the access token as header
Expand Down Expand Up @@ -56,3 +63,46 @@ def authenticate(self, request):
"OSM authentication failed: Invalid or expired access token"
)
return (user, None) # authentication successful return id,user_name,img
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no need to remove the inline comments ?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

restored!



class HankoAuthentication(authentication.BaseAuthentication):
"""Hanko SSO authentication using user mappings."""
def authenticate(self, request):
from hotosm_auth_django import get_mapped_user_id

if not hasattr(request, 'hotosm'):
raise exceptions.AuthenticationFailed(
"HankoAuthMiddleware not configured"
)

hanko_user = request.hotosm.user

if not hanko_user:
logger.debug("No Hanko user in request")
return (None, None)

mapped_osm_id = get_mapped_user_id(hanko_user, app_name="fair")

if mapped_osm_id is not None:
try:
osm_id = int(mapped_osm_id)
user = OsmUser.objects.get(osm_id=osm_id)
logger.debug(f"Authenticated via mapping: Hanko={hanko_user.email}, osm_id={osm_id}")
return (user, None)
except (OsmUser.DoesNotExist, ValueError) as e:
logger.warning(f"Mapping exists but user not found: osm_id={mapped_osm_id}, error={e}")
# Fall through to onboarding.

request.needs_onboarding = True
request.hanko_user_for_onboarding = hanko_user
logger.debug(f"Hanko user {hanko_user.email} needs onboarding (no mapping)")
return (None, None)


# Select authentication class based on AUTH_PROVIDER
if settings.AUTH_PROVIDER == AuthProvider.HANKO:
logger.info("Using Hanko SSO authentication")
OsmAuthentication = HankoAuthentication
else:
logger.info("Using legacy OSM authentication")
OsmAuthentication = LegacyOsmAuthentication
Loading
Loading