Skip to content

Integrate Hanko SSO login without the framework upgrade - #545

Open
hg1g wants to merge 4 commits into
mainfrom
feature/hanko-login-v2
Open

Integrate Hanko SSO login without the framework upgrade#545
hg1g wants to merge 4 commits into
mainfrom
feature/hanko-login-v2

Conversation

@hg1g

@hg1g hg1g commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Integrates the shared HOT login (Hanko SSO) into the Export Tool, as an
alternative to #538.

This branch builds directly on @andrea-chirillano's work in #538 — the auth
helpers, middleware, onboarding flow, frontend integration and tests all come
from there. What changes is the dependency footprint and a handful of fixes
found while reviewing it.

Why a second PR

#538 upgrades the framework along with the login change:

#538 this branch
Django ~=3.2.22~=4.2 ~=3.2.22 (unchanged)
django-oauth-toolkit 1.3.2>=2.0.0 1.3.2 (unchanged)
djangorestframework ~=3.11.0>=3.14.0 ~=3.11.0 (unchanged)
djangorestframework-gis ~=0.15>=1.0 ~=0.15 (unchanged)
gunicorn, oauthlib, requests_oauthlib bumped unchanged

The framework upgrade turned out not to be necessary. hotosm-auth[django]
declares django>=4.2 and drf>=3.14, but that's the extra's pin, not a
requirement of the code: hotosm_auth_django only imports AppConfig,
settings, connection, JsonResponse, SimpleLazyObject, csrf_exempt,
path and DRF's APIView/Response/status — all present in 3.2. Installing
hotosm-auth without the extra pulls the same wheel, and Django and DRF
stay at the versions this project already provides.

The practical difference is the database. django-oauth-toolkit 1.3.2 ships 2
migrations; 2.x ships 13. Production is on those same 2. #538 would apply 11
schema migrations to the OAuth2 tables, which a revert doesn't undo. This
branch applies exactly one new migration, hotosm_auth_django.0001, which
creates a table and touches nothing existing.

Fixes on top of #538

  • get_user_permissions returned [] for every non-superuser under Hanko.
    It only read the superuser branch and never looked at user_permissions or
    groups, so users with HDX or partner rights lost those menus. Both providers
    now resolve permissions the same way — the middleware maps the session onto a
    real Django user, so per-user and group permissions still apply.
  • Dropped the CSS that hid admin-only nav items. A :has() block with
    !important hid HDX, Partners, Admin, Workers, Status and Stats for
    everyone under Hanko. It was compensating for the empty permissions above;
    with those fixed it isn't needed, and it would have overridden them anyway.
  • Kept the OAuth2 provider routes mounted. Feature: integrate Hanko auth login  #538 removes /o/ under Hanko,
    which takes down the public API's authorization server — the application
    console and token revocation that Help → API links to. Those routes are not
    part of user login and now stay mounted under both providers.
  • login view: preserves ?next=, routes users without a mapping through
    onboarding instead of dropping them on the home page, rejects off-site
    redirect targets, and url-encodes return_to so an OAuth2 target's own query
    string survives (an unescaped & truncated it).
  • ADMIN_EMAILS moved out of the compose file into .env, next to
    SECRET_KEY and COOKIE_SECRET. Changing who is an admin no longer needs a
    commit.
  • Task runner: != "SUBMITTED" or != "RUNNING" is always true; now and.

Also left out of this branch, as unrelated to login: the test-fixture changes
(conftest.py, setup.cfg, FeatureSelection → constant), a yarn.lock that
moved without package.json (adding lit, preact, classnames — none
declared or imported, the components load from CDN), requireAuth on
/exports and /configurations which are public routes on main, and a
navigation change on configuration save.

Tested on export.testlogin.hotosm.org

Deployed from this branch against dev.login, with a clean database.

  • Runtime inside the running container: django 3.2.25, drf 3.11.2,
    DOT 1.3.2. oauth2_provider migrations: 2, same as production.
  • Login, onboarding and user mapping work end to end.
  • Permissions: a non-admin user with the 6 HDX/partner permissions gets exactly
    those 6 and is_superuser: false, and sees HDX and Partners in the navbar —
    while Admin, Workers, Status and Stats stay hidden, since those need
    auth.add_user.
  • ADMIN_EMAILS grants superuser per request without persisting it to the
    database.
  • OAuth2 for third parties, under Hanko: created an application at
    /o/applications/, authorized it through the Hanko login, and the resulting
    bearer token returned that user's private configuration from
    /api/configurations — while no token, an invalid token and an expired token
    all returned nothing. Hanko authenticates people, OAuth2 keeps authorizing
    machines.

Behaviour changes worth knowing

  • /api/permissions returns 401 instead of a 302 to the login page,
    and some DRF endpoints return 403 where they returned 401 (the Hanko
    authenticator doesn't set a WWW-Authenticate header). Both still deny;
    clients that follow the redirect or distinguish the codes are affected.
  • CORS goes from CORS_ORIGIN_ALLOW_ALL to a regex allowlist
    (*.hotosm.org, *.hotosm.test, localhost) — credentialed CORS can't use
    allow-all.
  • ADMIN_EMAILS overrides is_superuser per request. It must be populated
    before switching a deployment to Hanko, or current admins lose access.

Not yet verified

  • Legacy user onboarding against real data. The test environment started
    from an empty database. Production has users with social_auth records, and
    onboarding matches them by OSM id and falls back to email. This needs a run
    against a production dump before going live — including that their existing
    OAuth2 applications stay attached to the same Django user.
  • The ~700 tests in this branch aren't run by CI: ci.yaml runs make test,
    which is a single HDX test file. The suite is under the test_all target.
  • Production runs on systemd, not Docker, so the compose files here only cover
    the test environment; the production deploy needs its own env wiring.

AUTH_PROVIDER defaults to legacy, and in that mode this branch behaves like
main — so it can be deployed without changing anything for users, and Hanko
switched on afterwards with one variable.

hg1g added 4 commits August 10, 2026 14:20
Adds hotosm-auth alongside the existing OSM OAuth2 login, switchable with
AUTH_PROVIDER. Hanko middleware maps the session onto a Django user so
login_required and the permission classes keep working unchanged.

Installs hotosm-auth without the [django] extra: that extra only pins
django>=4.2 and drf>=3.14, which this project already provides at its own
versions, so pulling it would force an unrelated framework upgrade.

The OAuth2 provider routes stay mounted under both providers -- they are
the public API's authorization server for third parties, not part of user
login. Also fixes the login view to preserve ?next=, to route unmapped
users through onboarding instead of dropping them on the home page, to
reject off-site redirect targets, and to url-encode return_to so an
OAuth2 target's own query string survives.
Replaces the OAuth2 login button with the shared hotosm-auth component
when running under Hanko, and adds the hotosm-tool-menu component. API
calls go through buildAuthConfig, which sends cookies under Hanko and
keeps the bearer header under the legacy provider.
Covers the auth helpers, the status and onboarding views, and the DRF
authentication class.
Compose stack and dev Dockerfile used to run the Hanko branch on
export.testlogin.hotosm.org against dev.login. Redis host and the public
raw-data-api URL now come from the environment so the containers can
reach them, and ADMIN_EMAILS moves out of the compose file into .env.

Also fixes a broken condition in the task runner: the SUBMITTED/RUNNING
check used or, which is always true.
@hg1g hg1g mentioned this pull request Aug 10, 2026
11 tasks
@spwoodcock
spwoodcock self-requested a review August 12, 2026 11:10
@spwoodcock

Copy link
Copy Markdown
Member

Thanks for making this - makes sense to avoid the out of scope upgrades if not needed.

Hopefully this is a bit easier to review & debug when we need to fix anything on prod deploy 👍

@spwoodcock spwoodcock left a comment

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.

Much tighter PR focusing only on the auth integration.

Looks good to me:

  • I reviewed the backend code.
  • The frontend code I skimmed and it looks fine, but there is a lot of view every line.
  • As for the tests, were the written post-update, or pre-update? It would be great if these were tests that checked the existing functionality on prod, then we tested that nothing broke in the tests after implementing the auth. No biggie, as the tests are win either way I guess

Comment thread core/settings/base.py
)

CORS_ALLOW_CREDENTIALS = True
CORS_ALLOWED_ORIGIN_REGEXES = [

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.

Should these probably be set by env vars, instead of hardcoded as insecure defaults?

When we deploy, we can be sure to set in the .env:

  • localhost is fine for the test server
  • r"^https://.*\.hotosm\.org$", only for prod

Comment thread docker/nginx.conf

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.

Is this only for local dev too, or the actual production nginx config? Probably worth clarifying in a comment at the top 🙏

If it's prod, we could possibly tighten it up a little with extra headers, compression, caching etc.

Comment thread tasks/task_runners.py
if not user:
user = job.user
if job.last_run_status != "SUBMITTED" or job.last_run_status != "RUNNING":
if job.last_run_status != "SUBMITTED" and job.last_run_status != "RUNNING":

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.

Just leaving a comment here to say this was a fix from the previous PR.

The conditional always resulted in True previously, now it doesn't.

The .save can be safely removed as .create automatically calls .save when used.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants