diff --git a/api/core/config.py b/api/core/config.py index 643e586..7911264 100644 --- a/api/core/config.py +++ b/api/core/config.py @@ -38,6 +38,21 @@ class Settings(BaseSettings): # proxy destination--"osm-web" is a virtual docker network endpoint WS_OSM_HOST: str = "http://osm-web" + # OSM token bridge: when a TDEI token is validated, mirror it into the OSM + # database's `oauth_access_tokens` table so osm-rails (doorkeeper) and cgimap + # authenticate it via their standard OAuth2 path -- no custom JWT handling + # needed in those services. Disabled while WS_OSM_OAUTH_APPLICATION_ID is 0. + # Set it to the id of the doorkeeper `oauth_applications` row these tokens + # should belong to (create one via the OSM `register_apps` rake task or SQL). + WS_OSM_OAUTH_APPLICATION_ID: int = 0 + + # Scopes granted to the mirrored token. Must cover the OSM API operations the + # frontend performs; see `lib/oauth.rb` in the OSM website for valid values. + WS_OSM_OAUTH_SCOPES: str = ( + "read_prefs write_prefs write_api write_changeset_comments " + "read_gpx write_gpx write_notes" + ) + SENTRY_DSN: str = "" model_config = SettingsConfigDict( diff --git a/api/core/security.py b/api/core/security.py index 0ff32e2..1db25f9 100644 --- a/api/core/security.py +++ b/api/core/security.py @@ -1,3 +1,4 @@ +import time from enum import StrEnum from uuid import UUID @@ -19,6 +20,8 @@ # @test: Test that the methods on the UserInfo class return the correct values for a given set of project groups and workspace roles # @test: Test that any failed network requests are handled gracefully # @test: Test that the caching mechanism works correctly and evicts entries when roles change +# @test: Test that a validated token is mirrored into the OSM oauth_access_tokens table (with the user provisioned and expires_in from the JWT exp) when WS_OSM_OAUTH_APPLICATION_ID is set, and is a no-op otherwise +# @test: Test that re-presenting a token reactivates its OSM row (revoked_at cleared, expiry refreshed) and that a rotated (superseded) token is revoked, both gated on WS_OSM_OAUTH_APPLICATION_ID # Set up logger for this module logger = get_logger(__name__) @@ -316,6 +319,14 @@ async def validate_token( logger.info("Token validation cache hit") return cached logger.info("Token validation cache miss: token rotated") + # The old token is superseded; revoke its OSM row so it stops + # authenticating against osm-rails/cgimap before its own exp. + # TODO: this assumes a single active token per user. Truly concurrent + # sessions (multiple valid jtis for one user) will flap -- each request + # revokes the other session's OSM token and reactivates its own via the + # DO UPDATE upsert. To support multi-session, track multiple active jtis + # per user (cache + revoke set) instead of a single cached token. + await _revoke_osm_token(osm_db_session, cached.credentials) del _user_info_cache[user_uuid] # Cache miss: fetch TDEI roles and DB data: @@ -327,6 +338,123 @@ async def validate_token( return user_info +async def _bridge_token_to_osm( + session: AsyncSession, + *, + user_uuid: UUID, + user_name: str, + email: str | None, + token: str, + exp: int | None, +) -> None: + """Mirror a validated TDEI token into the OSM database so osm-rails + (doorkeeper) and cgimap authenticate it through their standard OAuth2 path, + with no custom JWT handling required in those services. + + Two idempotent writes: provision the OSM ``users`` row (owns the token via + ``resource_owner_id``) and insert a plaintext ``oauth_access_tokens`` row. + Doorkeeper stores tokens plaintext (``SecretStoring::Plain``), so the raw JWT + matches on lookup for both osm-rails and cgimap; ``expires_in`` tracks the + JWT's own ``exp`` so OSM expires it in lockstep with TDEI. + + No-op unless ``WS_OSM_OAUTH_APPLICATION_ID`` is set. Best-effort: a failure + here must not break token validation -- the ``/api/v1`` routes keep working; + only the proxied OSM calls would 401 until the row exists. + """ + if settings.WS_OSM_OAUTH_APPLICATION_ID <= 0: + return + + auth_uid = str(user_uuid) + try: + # Provision the users row (same shape as _provision_users_from_tdei). + await session.execute( + text( + "INSERT INTO users (auth_uid, email, display_name, auth_provider, " + "status, pass_crypt, data_public, email_valid, terms_seen, " + "creation_time, terms_agreed, tou_agreed) " + "VALUES (:auth_uid, :email, :name, 'TDEI', 'active', 'none', " + "true, true, true, (now() at time zone 'utc'), " + "(now() at time zone 'utc'), (now() at time zone 'utc')) " + "ON CONFLICT (auth_uid) DO NOTHING" + ), + {"auth_uid": auth_uid, "email": email, "name": user_name}, + ) + row = ( + await session.execute( + text( + "SELECT id FROM users " + "WHERE auth_provider = 'TDEI' AND auth_uid = :auth_uid" + ), + {"auth_uid": auth_uid}, + ) + ).first() + if row is None: + return + user_id = row[0] + + expires_in = max(0, exp - int(time.time())) if exp else None + await session.execute( + text( + "INSERT INTO oauth_access_tokens (application_id, " + "resource_owner_id, token, scopes, created_at, expires_in) " + "VALUES (:app_id, :user_id, :token, :scopes, " + "(now() at time zone 'utc'), :expires_in) " + # Re-presenting a token reactivates it: refresh the expiry to + # track this validation and clear any prior revocation, so a + # token revoked on rotation heals if it is used again. + "ON CONFLICT (token) DO UPDATE SET " + "resource_owner_id = EXCLUDED.resource_owner_id, " + "scopes = EXCLUDED.scopes, " + "created_at = EXCLUDED.created_at, " + "expires_in = EXCLUDED.expires_in, " + "revoked_at = NULL" + ), + { + "app_id": settings.WS_OSM_OAUTH_APPLICATION_ID, + "user_id": user_id, + "token": token, + "scopes": settings.WS_OSM_OAUTH_SCOPES, + "expires_in": expires_in, + }, + ) + await session.commit() + except Exception as e: + # Never fail auth on a bridge error; the OSM row just won't exist yet. + logger.warning( + "Failed to bridge TDEI token into OSM oauth_access_tokens: %s", e + ) + await session.rollback() + + +async def _revoke_osm_token(session: AsyncSession, token: str) -> None: + """Revoke a superseded token's OSM ``oauth_access_tokens`` row (best-effort). + + Called when a user's token rotates (new ``jti``) so the previous JWT stops + authenticating against osm-rails/cgimap before its own ``exp`` rather than + lingering until it expires. No-op unless the bridge is configured. + + Note: OSM/cgimap are reachable only *through* this proxy, and every request + first passes ``validate_token`` -- so a TDEI-revoked token is already + rejected upstream. This revocation is defense-in-depth for the superseded + token and keeps the OSM token store tidy. + """ + if settings.WS_OSM_OAUTH_APPLICATION_ID <= 0: + return + try: + await session.execute( + text( + "UPDATE oauth_access_tokens " + "SET revoked_at = (now() at time zone 'utc') " + "WHERE token = :token AND revoked_at IS NULL" + ), + {"token": token}, + ) + await session.commit() + except Exception as e: + logger.warning("Failed to revoke superseded OSM token: %s", e) + await session.rollback() + + async def _validate_token_uncached( token: str, user_uuid: UUID, @@ -427,6 +555,17 @@ async def _validate_token_uncached( osmRoles[i["workspace_id"]].append(i["role"]) r.osmWorkspaceRoles = osmRoles + # Mirror this validated token into the OSM DB so proxied OSM/cgimap calls + # authenticate via the standard OAuth2 path. No-op unless configured. + await _bridge_token_to_osm( + osm_db_session, + user_uuid=user_uuid, + user_name=r.user_name, + email=payload.get("email"), + token=token, + exp=payload.get("exp"), + ) + logger.info("Finished validation of token") return r diff --git a/tests/unit/test_token_bridge.py b/tests/unit/test_token_bridge.py new file mode 100644 index 0000000..401ad73 --- /dev/null +++ b/tests/unit/test_token_bridge.py @@ -0,0 +1,197 @@ +"""Tests for the OSM token bridge in ``api/core/security.py``. + +``_bridge_token_to_osm`` mirrors a validated TDEI token into the OSM database's +``oauth_access_tokens`` table so osm-rails (doorkeeper) and cgimap authenticate +it through their standard OAuth2 path. We cover: + +- it is a no-op unless ``WS_OSM_OAUTH_APPLICATION_ID`` is configured, +- when enabled it provisions the ``users`` row and inserts a plaintext + ``oauth_access_tokens`` row owned by that user, with ``expires_in`` derived + from the JWT ``exp``, +- a DB failure never propagates (auth must not break) and rolls back. +""" + +import time +from typing import cast +from uuid import uuid4 + +from sqlmodel.ext.asyncio.session import AsyncSession + +import api.core.security as security +from api.core.config import settings + + +class RecordingSession: + """Async session stand-in that records (sql, params) and returns a user id + row for the ``SELECT id FROM users`` lookup.""" + + def __init__(self, user_id=99): + self.calls: list[tuple[str, dict]] = [] + self.commits = 0 + self.rollbacks = 0 + self._user_id = user_id + + async def execute(self, statement, params=None): + sql = str(statement) + self.calls.append((sql, params or {})) + + class _R: + def __init__(self, rows): + self._rows = rows + + def first(self): + return self._rows[0] if self._rows else None + + if "SELECT id FROM users" in sql: + return _R([(self._user_id,)]) + return _R([]) + + async def commit(self): + self.commits += 1 + + async def rollback(self): + self.rollbacks += 1 + + def sql_for(self, needle): + return [c for c in self.calls if needle in c[0]] + + +async def test_bridge_is_noop_when_disabled(monkeypatch): + """With WS_OSM_OAUTH_APPLICATION_ID == 0 the bridge touches no DB.""" + monkeypatch.setattr(settings, "WS_OSM_OAUTH_APPLICATION_ID", 0) + session = RecordingSession() + + await security._bridge_token_to_osm( + cast(AsyncSession, session), + user_uuid=uuid4(), + user_name="alice", + email="alice@example.com", + token="tok", + exp=None, + ) + + assert session.calls == [] + assert session.commits == 0 + + +async def test_bridge_provisions_user_and_mirrors_token(monkeypatch): + """When enabled: users upsert -> id lookup -> oauth_access_tokens upsert.""" + monkeypatch.setattr(settings, "WS_OSM_OAUTH_APPLICATION_ID", 7) + monkeypatch.setattr(settings, "WS_OSM_OAUTH_SCOPES", "write_api read_prefs") + uid = uuid4() + exp = int(time.time()) + 3600 + session = RecordingSession(user_id=42) + + await security._bridge_token_to_osm( + cast(AsyncSession, session), + user_uuid=uid, + user_name="alice", + email="alice@example.com", + token="jwt-token", + exp=exp, + ) + + # users row provisioned idempotently, keyed by auth_uid = str(sub) + user_inserts = session.sql_for("INSERT INTO users") + assert user_inserts and "ON CONFLICT (auth_uid)" in user_inserts[0][0] + assert user_inserts[0][1]["auth_uid"] == str(uid) + assert user_inserts[0][1]["email"] == "alice@example.com" + + # token mirrored with the configured application/scopes and exp-derived TTL + token_inserts = session.sql_for("oauth_access_tokens") + assert token_inserts and "ON CONFLICT (token)" in token_inserts[0][0] + # Re-presenting a token must reactivate it (clear revocation, refresh expiry). + assert "DO UPDATE" in token_inserts[0][0] + assert "revoked_at = NULL" in token_inserts[0][0] + params = token_inserts[0][1] + assert params["app_id"] == 7 + assert params["user_id"] == 42 + assert params["token"] == "jwt-token" + assert params["scopes"] == "write_api read_prefs" + assert 3590 <= params["expires_in"] <= 3600 + + assert session.commits == 1 + assert session.rollbacks == 0 + + +async def test_bridge_expires_in_none_without_exp(monkeypatch): + """A token without an `exp` claim mirrors with a NULL expires_in.""" + monkeypatch.setattr(settings, "WS_OSM_OAUTH_APPLICATION_ID", 7) + session = RecordingSession() + + await security._bridge_token_to_osm( + cast(AsyncSession, session), + user_uuid=uuid4(), + user_name="alice", + email=None, + token="jwt-token", + exp=None, + ) + + params = session.sql_for("oauth_access_tokens")[0][1] + assert params["expires_in"] is None + + +async def test_bridge_is_best_effort_on_db_error(monkeypatch): + """A DB failure must not propagate out of validation; it rolls back.""" + monkeypatch.setattr(settings, "WS_OSM_OAUTH_APPLICATION_ID", 7) + + class BoomSession(RecordingSession): + async def execute(self, statement, params=None): + raise RuntimeError("db down") + + session = BoomSession() + + # Must not raise. + await security._bridge_token_to_osm( + cast(AsyncSession, session), + user_uuid=uuid4(), + user_name="alice", + email=None, + token="tok", + exp=None, + ) + + assert session.rollbacks == 1 + assert session.commits == 0 + + +async def test_revoke_is_noop_when_disabled(monkeypatch): + """With the bridge off, revoking a rotated token touches no DB.""" + monkeypatch.setattr(settings, "WS_OSM_OAUTH_APPLICATION_ID", 0) + session = RecordingSession() + + await security._revoke_osm_token(cast(AsyncSession, session), "old-token") + + assert session.calls == [] + assert session.commits == 0 + + +async def test_revoke_marks_only_the_superseded_token(monkeypatch): + """Revocation flips revoked_at for exactly the given token, if not already.""" + monkeypatch.setattr(settings, "WS_OSM_OAUTH_APPLICATION_ID", 7) + session = RecordingSession() + + await security._revoke_osm_token(cast(AsyncSession, session), "old-token") + + updates = session.sql_for("UPDATE oauth_access_tokens") + assert updates and "revoked_at" in updates[0][0] + assert "WHERE token = :token AND revoked_at IS NULL" in updates[0][0] + assert updates[0][1]["token"] == "old-token" + assert session.commits == 1 + + +async def test_revoke_is_best_effort_on_db_error(monkeypatch): + """A DB failure during revocation must not propagate; it rolls back.""" + monkeypatch.setattr(settings, "WS_OSM_OAUTH_APPLICATION_ID", 7) + + class BoomSession(RecordingSession): + async def execute(self, statement, params=None): + raise RuntimeError("db down") + + session = BoomSession() + + await security._revoke_osm_token(cast(AsyncSession, session), "tok") + + assert session.rollbacks == 1 + assert session.commits == 0