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: 15 additions & 0 deletions api/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
139 changes: 139 additions & 0 deletions api/core/security.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import time
from enum import StrEnum
from uuid import UUID

Expand All @@ -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__)
Expand Down Expand Up @@ -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:
Expand All @@ -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,
Expand Down Expand Up @@ -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
197 changes: 197 additions & 0 deletions tests/unit/test_token_bridge.py
Original file line number Diff line number Diff line change
@@ -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
Loading