Skip to content

Commit cd4cd64

Browse files
committed
Tweak to enable OSM token bridge by default
1 parent 83e333d commit cd4cd64

3 files changed

Lines changed: 181 additions & 86 deletions

File tree

api/core/config.py

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -38,21 +38,33 @@ class Settings(BaseSettings):
3838
# proxy destination--"osm-web" is a virtual docker network endpoint
3939
WS_OSM_HOST: str = "http://osm-web"
4040

41-
# OSM token bridge: when a TDEI token is validated, mirror it into the OSM
42-
# database's `oauth_access_tokens` table so osm-rails (doorkeeper) and cgimap
43-
# authenticate it via their standard OAuth2 path -- no custom JWT handling
44-
# needed in those services. Disabled while WS_OSM_OAUTH_APPLICATION_ID is 0.
45-
# Set it to the id of the doorkeeper `oauth_applications` row these tokens
46-
# should belong to (create one via the OSM `register_apps` rake task or SQL).
47-
WS_OSM_OAUTH_APPLICATION_ID: int = 0
48-
49-
# Scopes granted to the mirrored token. Must cover the OSM API operations the
50-
# frontend performs; see `lib/oauth.rb` in the OSM website for valid values.
41+
# OSM token bridge: when enabled, a validated TDEI token is mirrored into the
42+
# OSM database's `oauth_access_tokens` table so osm-rails (doorkeeper) and
43+
# cgimap authenticate it via their standard OAuth2 path -- no custom JWT
44+
# handling needed in those services. The backend also auto-creates the
45+
# doorkeeper `oauth_applications` row (keyed by WS_OSM_OAUTH_CLIENT_UID and
46+
# owned by the dedicated system user below) that these tokens belong to, so
47+
# no manual OSM setup is required.
48+
WS_OSM_TOKEN_BRIDGE_ENABLED: bool = True
49+
50+
# Stable client id (uid) for the auto-created doorkeeper application. Point
51+
# this at an existing application's uid to reuse it instead of creating one.
52+
WS_OSM_OAUTH_CLIENT_UID: str = "workspaces-backend"
53+
54+
# Scopes granted to the application and mirrored tokens. Must cover the OSM
55+
# API operations the frontend performs; see `lib/oauth.rb` in the OSM website.
5156
WS_OSM_OAUTH_SCOPES: str = (
5257
"read_prefs write_prefs write_api write_changeset_comments "
5358
"read_gpx write_gpx write_notes"
5459
)
5560

61+
# Dedicated OSM `users` row that owns the auto-created doorkeeper application
62+
# (satisfies oauth_applications.owner_id, a NOT-NULL FK to users). It never
63+
# signs in; these values just need to be stable and unique among users.
64+
WS_OSM_SYSTEM_USER_AUTH_UID: str = "workspaces-backend-system"
65+
WS_OSM_SYSTEM_USER_DISPLAY_NAME: str = "Workspaces Backend (system)"
66+
WS_OSM_SYSTEM_USER_EMAIL: str = "workspaces-backend-system@tdei.us"
67+
5668
SENTRY_DSN: str = ""
5769

5870
model_config = SettingsConfigDict(

api/core/security.py

Lines changed: 99 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import secrets
12
import time
23
from enum import StrEnum
34
from uuid import UUID
@@ -20,8 +21,8 @@
2021
# @test: Test that the methods on the UserInfo class return the correct values for a given set of project groups and workspace roles
2122
# @test: Test that any failed network requests are handled gracefully
2223
# @test: Test that the caching mechanism works correctly and evicts entries when roles change
23-
# @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
24-
# @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
24+
# @test: Test that when WS_OSM_TOKEN_BRIDGE_ENABLED, a validated token is mirrored into oauth_access_tokens with the doorkeeper application + system-owner user + caller user auto-provisioned and expires_in from the JWT exp; and is a no-op when disabled
25+
# @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_TOKEN_BRIDGE_ENABLED
2526

2627
# Set up logger for this module
2728
logger = get_logger(__name__)
@@ -338,6 +339,84 @@ async def validate_token(
338339
return user_info
339340

340341

342+
async def _ensure_osm_user(
343+
session: AsyncSession,
344+
*,
345+
auth_uid: str,
346+
email: str | None,
347+
display_name: str,
348+
) -> int | None:
349+
"""Idempotently provision an OSM ``users`` row (auth_provider ``TDEI``) and
350+
return its id. ``email`` is synthesised when absent -- the column is UNIQUE
351+
and NOT NULL."""
352+
await session.execute(
353+
text(
354+
"INSERT INTO users (auth_uid, email, display_name, auth_provider, "
355+
"status, pass_crypt, data_public, email_valid, terms_seen, "
356+
"creation_time, terms_agreed, tou_agreed) "
357+
"VALUES (:auth_uid, :email, :name, 'TDEI', 'active', 'none', "
358+
"true, true, true, (now() at time zone 'utc'), "
359+
"(now() at time zone 'utc'), (now() at time zone 'utc')) "
360+
"ON CONFLICT (auth_uid) DO NOTHING"
361+
),
362+
{
363+
"auth_uid": auth_uid,
364+
"email": email or f"{auth_uid}@tdei.invalid",
365+
"name": display_name,
366+
},
367+
)
368+
row = (
369+
await session.execute(
370+
text(
371+
"SELECT id FROM users "
372+
"WHERE auth_provider = 'TDEI' AND auth_uid = :auth_uid"
373+
),
374+
{"auth_uid": auth_uid},
375+
)
376+
).first()
377+
return row[0] if row else None
378+
379+
380+
async def _ensure_osm_oauth_application(session: AsyncSession) -> int | None:
381+
"""Ensure the doorkeeper application the mirrored tokens belong to exists,
382+
creating it (owned by a dedicated system user) if needed. Idempotent by the
383+
configured client uid; returns the application id."""
384+
owner_id = await _ensure_osm_user(
385+
session,
386+
auth_uid=settings.WS_OSM_SYSTEM_USER_AUTH_UID,
387+
email=settings.WS_OSM_SYSTEM_USER_EMAIL,
388+
display_name=settings.WS_OSM_SYSTEM_USER_DISPLAY_NAME,
389+
)
390+
if owner_id is None:
391+
return None
392+
await session.execute(
393+
text(
394+
"INSERT INTO oauth_applications (owner_type, owner_id, name, uid, "
395+
"secret, redirect_uri, scopes, confidential, created_at, updated_at) "
396+
"VALUES ('User', :owner_id, :name, :uid, :secret, "
397+
"'urn:ietf:wg:oauth:2.0:oob', :scopes, true, "
398+
"(now() at time zone 'utc'), (now() at time zone 'utc')) "
399+
"ON CONFLICT (uid) DO NOTHING"
400+
),
401+
{
402+
"owner_id": owner_id,
403+
"name": "Workspaces Backend",
404+
"uid": settings.WS_OSM_OAUTH_CLIENT_UID,
405+
# Never used (tokens are inserted directly rather than issued via the
406+
# OAuth flow), but the column is NOT NULL.
407+
"secret": secrets.token_hex(32),
408+
"scopes": settings.WS_OSM_OAUTH_SCOPES,
409+
},
410+
)
411+
row = (
412+
await session.execute(
413+
text("SELECT id FROM oauth_applications WHERE uid = :uid"),
414+
{"uid": settings.WS_OSM_OAUTH_CLIENT_UID},
415+
)
416+
).first()
417+
return row[0] if row else None
418+
419+
341420
async def _bridge_token_to_osm(
342421
session: AsyncSession,
343422
*,
@@ -351,46 +430,28 @@ async def _bridge_token_to_osm(
351430
(doorkeeper) and cgimap authenticate it through their standard OAuth2 path,
352431
with no custom JWT handling required in those services.
353432
354-
Two idempotent writes: provision the OSM ``users`` row (owns the token via
355-
``resource_owner_id``) and insert a plaintext ``oauth_access_tokens`` row.
356-
Doorkeeper stores tokens plaintext (``SecretStoring::Plain``), so the raw JWT
357-
matches on lookup for both osm-rails and cgimap; ``expires_in`` tracks the
358-
JWT's own ``exp`` so OSM expires it in lockstep with TDEI.
433+
Auto-provisions everything it needs: the doorkeeper ``oauth_applications``
434+
row (owned by a dedicated system user), the caller's ``users`` row, and a
435+
plaintext ``oauth_access_tokens`` row -- no manual OSM setup. Doorkeeper
436+
stores tokens plaintext (``SecretStoring::Plain``), so the raw JWT matches on
437+
lookup for both osm-rails and cgimap; ``expires_in`` tracks the JWT's ``exp``.
359438
360-
No-op unless ``WS_OSM_OAUTH_APPLICATION_ID`` is set. Best-effort: a failure
361-
here must not break token validation -- the ``/api/v1`` routes keep working;
362-
only the proxied OSM calls would 401 until the row exists.
439+
No-op unless ``WS_OSM_TOKEN_BRIDGE_ENABLED``. Best-effort: a failure here
440+
must not break token validation -- the ``/api/v1`` routes keep working; only
441+
the proxied OSM calls would 401 until the row exists.
363442
"""
364-
if settings.WS_OSM_OAUTH_APPLICATION_ID <= 0:
443+
if not settings.WS_OSM_TOKEN_BRIDGE_ENABLED:
365444
return
366445

367-
auth_uid = str(user_uuid)
368446
try:
369-
# Provision the users row (same shape as _provision_users_from_tdei).
370-
await session.execute(
371-
text(
372-
"INSERT INTO users (auth_uid, email, display_name, auth_provider, "
373-
"status, pass_crypt, data_public, email_valid, terms_seen, "
374-
"creation_time, terms_agreed, tou_agreed) "
375-
"VALUES (:auth_uid, :email, :name, 'TDEI', 'active', 'none', "
376-
"true, true, true, (now() at time zone 'utc'), "
377-
"(now() at time zone 'utc'), (now() at time zone 'utc')) "
378-
"ON CONFLICT (auth_uid) DO NOTHING"
379-
),
380-
{"auth_uid": auth_uid, "email": email, "name": user_name},
447+
app_id = await _ensure_osm_oauth_application(session)
448+
if app_id is None:
449+
return
450+
user_id = await _ensure_osm_user(
451+
session, auth_uid=str(user_uuid), email=email, display_name=user_name
381452
)
382-
row = (
383-
await session.execute(
384-
text(
385-
"SELECT id FROM users "
386-
"WHERE auth_provider = 'TDEI' AND auth_uid = :auth_uid"
387-
),
388-
{"auth_uid": auth_uid},
389-
)
390-
).first()
391-
if row is None:
453+
if user_id is None:
392454
return
393-
user_id = row[0]
394455

395456
expires_in = max(0, exp - int(time.time())) if exp else None
396457
await session.execute(
@@ -410,7 +471,7 @@ async def _bridge_token_to_osm(
410471
"revoked_at = NULL"
411472
),
412473
{
413-
"app_id": settings.WS_OSM_OAUTH_APPLICATION_ID,
474+
"app_id": app_id,
414475
"user_id": user_id,
415476
"token": token,
416477
"scopes": settings.WS_OSM_OAUTH_SCOPES,
@@ -431,14 +492,14 @@ async def _revoke_osm_token(session: AsyncSession, token: str) -> None:
431492
432493
Called when a user's token rotates (new ``jti``) so the previous JWT stops
433494
authenticating against osm-rails/cgimap before its own ``exp`` rather than
434-
lingering until it expires. No-op unless the bridge is configured.
495+
lingering until it expires. No-op unless the bridge is enabled.
435496
436497
Note: OSM/cgimap are reachable only *through* this proxy, and every request
437498
first passes ``validate_token`` -- so a TDEI-revoked token is already
438499
rejected upstream. This revocation is defense-in-depth for the superseded
439500
token and keeps the OSM token store tidy.
440501
"""
441-
if settings.WS_OSM_OAUTH_APPLICATION_ID <= 0:
502+
if not settings.WS_OSM_TOKEN_BRIDGE_ENABLED:
442503
return
443504
try:
444505
await session.execute(

0 commit comments

Comments
 (0)