Add OpenCDE Foundation API 1.1 OAuth2 proxy routes

Implements GET /foundation/1.1/oauth2/auth_url, POST /oauth2/token, and
POST /oauth2/token_refresh, proxying to Forgejo's OAuth2 endpoints using
IFCURL_OAUTH2_CLIENT_ID and IFCURL_OAUTH2_CLIENT_SECRET env vars.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Bruno Postle 2026-06-07 10:08:13 +01:00
parent d975658515
commit 48169ff94b
4 changed files with 364 additions and 2 deletions

View file

@ -72,7 +72,7 @@
{"id":"ifcurl-6xq","title":"Query panel does not appear after selector resolves","description":"When a selector is set in the viewer and a query= parameter is also present, the query results table (showing GUID → value pairs) should appear in a side panel. This panel never appears. The applyQuery() function in viewer.js fetches /query from the ifcurl service and populates queryTbody, but the panel remains hidden. Needs investigation: check whether the query param is being passed correctly, whether the /query endpoint returns data, and whether queryPanel.style.display is being set.","status":"closed","priority":3,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-05-16T13:24:59Z","created_by":"Bruno Postle","updated_at":"2026-05-16T20:02:21Z","started_at":"2026-05-16T14:14:52Z","closed_at":"2026-05-16T20:02:21Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-6xq","title":"Query panel does not appear after selector resolves","description":"When a selector is set in the viewer and a query= parameter is also present, the query results table (showing GUID → value pairs) should appear in a side panel. This panel never appears. The applyQuery() function in viewer.js fetches /query from the ifcurl service and populates queryTbody, but the panel remains hidden. Needs investigation: check whether the query param is being passed correctly, whether the /query endpoint returns data, and whether queryPanel.style.display is being set.","status":"closed","priority":3,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-05-16T13:24:59Z","created_by":"Bruno Postle","updated_at":"2026-05-16T20:02:21Z","started_at":"2026-05-16T14:14:52Z","closed_at":"2026-05-16T20:02:21Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-hwk","title":"Meta panel: missing type counts, icon is hamburger not (i)","description":"The metadata panel works for storey isolation but has two issues: (1) The toolbar button is a hamburger icon, not an (i) icon as documented in CLAUDE.md check #17. Update the checklist text to match reality. (2) The type counts breakdown (IfcWall: N, IfcSlab: N, etc.) does not appear in the panel — only the storey list is shown. Investigate whether populateMetaPanel() is failing silently or the type classifier isn't running correctly.","status":"closed","priority":3,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-05-16T13:24:45Z","created_by":"Bruno Postle","updated_at":"2026-05-16T20:02:21Z","started_at":"2026-05-16T14:15:06Z","closed_at":"2026-05-16T20:02:21Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-hwk","title":"Meta panel: missing type counts, icon is hamburger not (i)","description":"The metadata panel works for storey isolation but has two issues: (1) The toolbar button is a hamburger icon, not an (i) icon as documented in CLAUDE.md check #17. Update the checklist text to match reality. (2) The type counts breakdown (IfcWall: N, IfcSlab: N, etc.) does not appear in the panel — only the storey list is shown. Investigate whether populateMetaPanel() is failing silently or the type classifier isn't running correctly.","status":"closed","priority":3,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-05-16T13:24:45Z","created_by":"Bruno Postle","updated_at":"2026-05-16T20:02:21Z","started_at":"2026-05-16T14:15:06Z","closed_at":"2026-05-16T20:02:21Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-xfi","title":"Implement OpenCDE document picker (select-documents flow)","description":"The OpenCDE Documents API defines a browser-redirect flow for document discovery. Implement POST /documents/1.0/select-documents which returns a select_documents_url. That URL serves a small HTML page: owner/repo selector then file-tree browser. When user selects an IFC file, generate the document_id and redirect to the callback_url with document_ids[] params.","status":"open","priority":3,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-29T20:38:16Z","created_by":"Bruno Postle","updated_at":"2026-04-29T20:38:16Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-xfi","title":"Implement OpenCDE document picker (select-documents flow)","description":"The OpenCDE Documents API defines a browser-redirect flow for document discovery. Implement POST /documents/1.0/select-documents which returns a select_documents_url. That URL serves a small HTML page: owner/repo selector then file-tree browser. When user selects an IFC file, generate the document_id and redirect to the callback_url with document_ids[] params.","status":"open","priority":3,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-29T20:38:16Z","created_by":"Bruno Postle","updated_at":"2026-04-29T20:38:16Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-bil","title":"Implement OpenCDE OAuth2 proxy routes","description":"The OpenCDE Foundation API requires OAuth2 endpoints. Forgejo already has OAuth2 support — proxy through to it. Routes needed: GET /foundation/1.0/oauth2/auth_url (return Forgejo OAuth2 auth URL), POST /foundation/1.0/oauth2/token (proxy code exchange to Forgejo token endpoint), POST /foundation/1.0/oauth2/token_refresh (proxy refresh). Requires a one-time step of registering an OAuth2 app in Forgejo admin and storing client_id/client_secret as env vars.","status":"open","priority":3,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-29T20:38:15Z","created_by":"Bruno Postle","updated_at":"2026-04-29T20:38:15Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-bil","title":"Implement OpenCDE OAuth2 proxy routes","description":"The OpenCDE Foundation API requires OAuth2 endpoints. Forgejo already has OAuth2 support — proxy through to it. Routes needed: GET /foundation/1.0/oauth2/auth_url (return Forgejo OAuth2 auth URL), POST /foundation/1.0/oauth2/token (proxy code exchange to Forgejo token endpoint), POST /foundation/1.0/oauth2/token_refresh (proxy refresh). Requires a one-time step of registering an OAuth2 app in Forgejo admin and storing client_id/client_secret as env vars.","status":"in_progress","priority":3,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-29T20:38:15Z","created_by":"Bruno Postle","updated_at":"2026-06-07T08:42:43Z","started_at":"2026-06-07T08:42:43Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-k3v","title":"Add GET /documents/1.0/document-metadata/{document_id}","description":"The OpenCDE Documents API spec includes a GET endpoint for individual file metadata. Implement it: decode the document_id, fetch the file's latest info from Forgejo (name, size, last-modified), return as a metadata response.","status":"closed","priority":3,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-29T20:38:13Z","created_by":"Bruno Postle","updated_at":"2026-04-29T21:25:07Z","started_at":"2026-04-29T21:22:34Z","closed_at":"2026-04-29T21:25:07Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-k3v","title":"Add GET /documents/1.0/document-metadata/{document_id}","description":"The OpenCDE Documents API spec includes a GET endpoint for individual file metadata. Implement it: decode the document_id, fetch the file's latest info from Forgejo (name, size, last-modified), return as a metadata response.","status":"closed","priority":3,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-29T20:38:13Z","created_by":"Bruno Postle","updated_at":"2026-04-29T21:25:07Z","started_at":"2026-04-29T21:22:34Z","closed_at":"2026-04-29T21:25:07Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-ye6","title":"Add BCF topic filtering query params","description":"The BCF 3.0 REST spec defines query parameters for GET /topics (topic_status, topic_type, priority, assignee, etc.). Currently we return all topics unfiltered. Translate BCF filter params to Forgejo issues API params (state, labels, assignee) and pass them through.","status":"closed","priority":3,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-29T20:38:00Z","created_by":"Bruno Postle","updated_at":"2026-04-29T21:25:06Z","started_at":"2026-04-29T21:22:21Z","closed_at":"2026-04-29T21:25:06Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-ye6","title":"Add BCF topic filtering query params","description":"The BCF 3.0 REST spec defines query parameters for GET /topics (topic_status, topic_type, priority, assignee, etc.). Currently we return all topics unfiltered. Translate BCF filter params to Forgejo issues API params (state, labels, assignee) and pass them through.","status":"closed","priority":3,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-29T20:38:00Z","created_by":"Bruno Postle","updated_at":"2026-04-29T21:25:06Z","started_at":"2026-04-29T21:22:21Z","closed_at":"2026-04-29T21:25:06Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-lwl","title":"Add OpenCDE Foundation API discovery endpoint","description":"Expose GET /foundation/versions on the Forgejo instance so OpenCDE clients can auto-discover which APIs the server supports. Returns JSON declaring BCF API 3.0 and Documents API with their base URLs and OAuth2 configuration.\n\n## Architecture\n\nStandalone Python daemon, proxied at /foundation/ on the Forgejo hostname. Static or config-driven JSON response — no Forgejo internals needed, no auth required (Foundation API discovery endpoint is public by spec). Likely a small route in the existing preview service.","status":"closed","priority":3,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-28T22:41:06Z","created_by":"Bruno Postle","updated_at":"2026-04-29T18:21:36Z","started_at":"2026-04-29T18:13:51Z","closed_at":"2026-04-29T18:21:36Z","close_reason":"Closed","dependency_count":0,"dependent_count":1,"comment_count":0} {"id":"ifcurl-lwl","title":"Add OpenCDE Foundation API discovery endpoint","description":"Expose GET /foundation/versions on the Forgejo instance so OpenCDE clients can auto-discover which APIs the server supports. Returns JSON declaring BCF API 3.0 and Documents API with their base URLs and OAuth2 configuration.\n\n## Architecture\n\nStandalone Python daemon, proxied at /foundation/ on the Forgejo hostname. Static or config-driven JSON response — no Forgejo internals needed, no auth required (Foundation API discovery endpoint is public by spec). Likely a small route in the existing preview service.","status":"closed","priority":3,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-28T22:41:06Z","created_by":"Bruno Postle","updated_at":"2026-04-29T18:21:36Z","started_at":"2026-04-29T18:13:51Z","closed_at":"2026-04-29T18:21:36Z","close_reason":"Closed","dependency_count":0,"dependent_count":1,"comment_count":0}
@ -137,6 +137,6 @@
{"id":"ifcurl-vam","title":"Phase 6: Bonsai offer ifc:// URL when saving linked model reference","description":"When saving an IFCDOCUMENTREFERENCE in Bonsai, offer the option to write the location as an ifc:// URL rather than a relative path. When a relative path would traverse above the repository root, prompt the user to convert it to an ifc:// URL. Requires Phase 4 federation (ifcurl-bqt).","status":"deferred","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-23T05:49:16Z","created_by":"Bruno Postle","updated_at":"2026-04-23T06:15:13Z","defer_until":"2026-12-01T00:00:00Z","dependencies":[{"issue_id":"ifcurl-vam","depends_on_id":"ifcurl-bqt","type":"blocks","created_at":"2026-04-23T06:49:23Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"id":"ifcurl-vam","title":"Phase 6: Bonsai offer ifc:// URL when saving linked model reference","description":"When saving an IFCDOCUMENTREFERENCE in Bonsai, offer the option to write the location as an ifc:// URL rather than a relative path. When a relative path would traverse above the repository root, prompt the user to convert it to an ifc:// URL. Requires Phase 4 federation (ifcurl-bqt).","status":"deferred","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-23T05:49:16Z","created_by":"Bruno Postle","updated_at":"2026-04-23T06:15:13Z","defer_until":"2026-12-01T00:00:00Z","dependencies":[{"issue_id":"ifcurl-vam","depends_on_id":"ifcurl-bqt","type":"blocks","created_at":"2026-04-23T06:49:23Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-9k2","title":"Phase 5: IFC Viewer integration (equivalent of Phase 4)","description":"Add ifc:// OS protocol handler and 'Copy view URL' button to the IFC Viewer desktop application, equivalent to the Bonsai Phase 4 integration. Depends on Phase 4 being complete as a reference implementation.","status":"deferred","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-23T05:49:08Z","created_by":"Bruno Postle","updated_at":"2026-04-23T06:15:12Z","defer_until":"2026-12-01T00:00:00Z","dependencies":[{"issue_id":"ifcurl-9k2","depends_on_id":"ifcurl-0oj","type":"blocks","created_at":"2026-04-23T06:49:14Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"id":"ifcurl-9k2","title":"Phase 5: IFC Viewer integration (equivalent of Phase 4)","description":"Add ifc:// OS protocol handler and 'Copy view URL' button to the IFC Viewer desktop application, equivalent to the Bonsai Phase 4 integration. Depends on Phase 4 being complete as a reference implementation.","status":"deferred","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-23T05:49:08Z","created_by":"Bruno Postle","updated_at":"2026-04-23T06:15:12Z","defer_until":"2026-12-01T00:00:00Z","dependencies":[{"issue_id":"ifcurl-9k2","depends_on_id":"ifcurl-0oj","type":"blocks","created_at":"2026-04-23T06:49:14Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-prc","title":"Phase 3c: 3D diff view for IFC PR merges","description":"After a PR merge completes, surface a 'View merge in 3D' button using the Phase 3b viewer. Use @thatopen/components highlighter to colour added, removed, and modified elements differently, driven by comparing the merged IFC against either parent. Requires Phase 3b viewer (ifcurl-i1s) to be working.","status":"closed","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-23T05:48:30Z","created_by":"Bruno Postle","updated_at":"2026-04-26T17:15:07Z","closed_at":"2026-04-26T17:15:07Z","close_reason":"Rendered diff already implemented in footer.tmpl Case 3; static PNG via /render_diff endpoint, not interactive 3D","dependencies":[{"issue_id":"ifcurl-prc","depends_on_id":"ifcurl-i1s","type":"blocks","created_at":"2026-04-23T06:48:36Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"id":"ifcurl-prc","title":"Phase 3c: 3D diff view for IFC PR merges","description":"After a PR merge completes, surface a 'View merge in 3D' button using the Phase 3b viewer. Use @thatopen/components highlighter to colour added, removed, and modified elements differently, driven by comparing the merged IFC against either parent. Requires Phase 3b viewer (ifcurl-i1s) to be working.","status":"closed","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-23T05:48:30Z","created_by":"Bruno Postle","updated_at":"2026-04-26T17:15:07Z","closed_at":"2026-04-26T17:15:07Z","close_reason":"Rendered diff already implemented in footer.tmpl Case 3; static PNG via /render_diff endpoint, not interactive 3D","dependencies":[{"issue_id":"ifcurl-prc","depends_on_id":"ifcurl-i1s","type":"blocks","created_at":"2026-04-23T06:48:36Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
{"_type":"memory","key":"torawurl-in-viewer-url-js-must-support-github","value":"toRawUrl in viewer-url.js must support GitHub, GitLab, Forgejo/Gitea (codeberg.org, self-hosted), and localhost. Tests in forgejo/tests/viewer-url.test.js cover all four. The localhost check (http vs https) is based on host.startsWith('localhost'), so subdomain.localhost gets https."}
{"_type":"memory","key":"forgejo-build-and-deploy-procedure-go-build-c","value":"Forgejo build and deploy procedure: go build -C /home/bruno/src/forgejo -tags 'sqlite sqlite_unlock_notify' -ldflags \"-X 'forgejo.org/modules/setting.StaticRootPath=/usr/share/forgejo'\" -o /home/bruno/src/forgejo/forgejo . — then sudo cp forgejo /usr/bin/forgejo. StaticRootPath=/usr/share/forgejo. CustomPath=/var/lib/forgejo/custom. Custom templates go to /var/lib/forgejo/custom/templates/custom/ (e.g. footer.tmpl → /var/lib/forgejo/custom/templates/custom/footer.tmpl). Custom static files go to /var/lib/forgejo/custom/public/assets/ (NOT /etc/forgejo/public/assets/) and are served at /assets/*."} {"_type":"memory","key":"forgejo-build-and-deploy-procedure-go-build-c","value":"Forgejo build and deploy procedure: go build -C /home/bruno/src/forgejo -tags 'sqlite sqlite_unlock_notify' -ldflags \"-X 'forgejo.org/modules/setting.StaticRootPath=/usr/share/forgejo'\" -o /home/bruno/src/forgejo/forgejo . — then sudo cp forgejo /usr/bin/forgejo. StaticRootPath=/usr/share/forgejo. CustomPath=/var/lib/forgejo/custom. Custom templates go to /var/lib/forgejo/custom/templates/custom/ (e.g. footer.tmpl → /var/lib/forgejo/custom/templates/custom/footer.tmpl). Custom static files go to /var/lib/forgejo/custom/public/assets/ (NOT /etc/forgejo/public/assets/) and are served at /assets/*."}
{"_type":"memory","key":"ifcurl-service-names-are-ifcurl-api-service-and","value":"ifcurl service names are ifcurl-api.service and ifcurl-render.service (not ifcurl.service). Install path for Python package: /opt/ifcurl/lib/python3.14/site-packages/ifcurl/. Python binary: /opt/ifcurl/bin/python3."} {"_type":"memory","key":"ifcurl-service-names-are-ifcurl-api-service-and","value":"ifcurl service names are ifcurl-api.service and ifcurl-render.service (not ifcurl.service). Install path for Python package: /opt/ifcurl/lib/python3.14/site-packages/ifcurl/. Python binary: /opt/ifcurl/bin/python3."}
{"_type":"memory","key":"torawurl-in-viewer-url-js-must-support-github","value":"toRawUrl in viewer-url.js must support GitHub, GitLab, Forgejo/Gitea (codeberg.org, self-hosted), and localhost. Tests in forgejo/tests/viewer-url.test.js cover all four. The localhost check (http vs https) is based on host.startsWith('localhost'), so subdomain.localhost gets https."}

157
ifcurl/foundation_api.py Normal file
View file

@ -0,0 +1,157 @@
# IFC URL — OpenCDE Foundation API 1.1 OAuth2 proxy routes
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
# SPDX-License-Identifier: LGPL-3.0-or-later
#
# This file is part of IFC URL.
#
# IFC URL is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IFC URL is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IFC URL. If not, see <http://www.gnu.org/licenses/>.
"""OpenCDE Foundation API 1.1 — OAuth2 proxy routes.
Routes are mounted at /foundation/1.1 in service.py.
Forgejo is used as the OAuth2 provider. A one-time setup step is required:
register an OAuth2 application in Forgejo admin settings (Admin Applications
OAuth2 Applications) and set the following environment variables:
IFCURL_OAUTH2_CLIENT_ID OAuth2 application client ID
IFCURL_OAUTH2_CLIENT_SECRET OAuth2 application client secret
The auth_url endpoint constructs and returns the Forgejo authorization URL.
The token and token_refresh endpoints proxy to Forgejo's token endpoint,
injecting the client credentials from env vars so callers never handle them.
"""
from __future__ import annotations
import os
from urllib.parse import quote
import httpx
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel
router = APIRouter(prefix="/foundation/1.1")
_FORGEJO_URL: str = os.environ.get("IFCURL_FORGEJO_URL", "http://localhost:3000")
_CLIENT_ID: str | None = os.environ.get("IFCURL_OAUTH2_CLIENT_ID")
_CLIENT_SECRET: str | None = os.environ.get("IFCURL_OAUTH2_CLIENT_SECRET")
def _credentials() -> tuple[str, str]:
"""Return (client_id, client_secret) or raise 503 if not configured."""
if not _CLIENT_ID or not _CLIENT_SECRET:
raise HTTPException(
status_code=503,
detail=(
"OAuth2 not configured — set IFCURL_OAUTH2_CLIENT_ID "
"and IFCURL_OAUTH2_CLIENT_SECRET"
),
)
return _CLIENT_ID, _CLIENT_SECRET
# ---------------------------------------------------------------------------
# GET /foundation/1.1/oauth2/auth_url
# ---------------------------------------------------------------------------
@router.get("/oauth2/auth_url")
def oauth2_auth_url(request: Request, redirect_uri: str | None = None) -> JSONResponse:
"""Return the Forgejo OAuth2 authorization URL.
The client should redirect the user to the returned URL to begin the
authorization code flow. The optional ``redirect_uri`` query parameter
is forwarded to Forgejo; it must match a URI registered with the OAuth2
application.
"""
client_id, _ = _credentials()
params = f"client_id={quote(client_id, safe='')}&response_type=code"
if redirect_uri:
params += f"&redirect_uri={quote(redirect_uri, safe='')}"
return JSONResponse(
{"url": f"{_FORGEJO_URL}/login/oauth/authorize?{params}", "expires_in": 3600}
)
# ---------------------------------------------------------------------------
# POST /foundation/1.1/oauth2/token
# ---------------------------------------------------------------------------
class TokenRequest(BaseModel):
code: str
redirect_uri: str | None = None
@router.post("/oauth2/token")
def oauth2_token(body: TokenRequest) -> JSONResponse:
"""Exchange an authorization code for an access + refresh token pair.
Proxies to Forgejo's token endpoint, injecting the stored client
credentials. The caller never sees or handles client_id/client_secret.
"""
client_id, client_secret = _credentials()
payload: dict = {
"client_id": client_id,
"client_secret": client_secret,
"code": body.code,
"grant_type": "authorization_code",
}
if body.redirect_uri:
payload["redirect_uri"] = body.redirect_uri
return _proxy_token(payload)
# ---------------------------------------------------------------------------
# POST /foundation/1.1/oauth2/token_refresh
# ---------------------------------------------------------------------------
class RefreshRequest(BaseModel):
refresh_token: str
@router.post("/oauth2/token_refresh")
def oauth2_token_refresh(body: RefreshRequest) -> JSONResponse:
"""Exchange a refresh token for a new access + refresh token pair.
Proxies to Forgejo's token endpoint with grant_type=refresh_token.
"""
client_id, client_secret = _credentials()
payload: dict = {
"client_id": client_id,
"client_secret": client_secret,
"refresh_token": body.refresh_token,
"grant_type": "refresh_token",
}
return _proxy_token(payload)
# ---------------------------------------------------------------------------
# Internal
# ---------------------------------------------------------------------------
def _proxy_token(payload: dict) -> JSONResponse:
"""POST payload to Forgejo's token endpoint and return the JSON response."""
try:
resp = httpx.post(
f"{_FORGEJO_URL}/login/oauth/access_token",
json=payload,
headers={"Accept": "application/json"},
timeout=10,
)
except httpx.TransportError as exc:
raise HTTPException(status_code=503, detail=f"Forgejo unreachable: {exc}") from exc
if resp.status_code != 200:
raise HTTPException(status_code=resp.status_code, detail=resp.text)
return JSONResponse(resp.json())

View file

@ -54,6 +54,7 @@ from pydantic import BaseModel
from ifcurl.auth import get_token_for_host from ifcurl.auth import get_token_for_host
from ifcurl.bcf_api import router as bcf_router from ifcurl.bcf_api import router as bcf_router
from ifcurl.documents_api import router as documents_router from ifcurl.documents_api import router as documents_router
from ifcurl.foundation_api import router as foundation_router
from ifcurl.git import diff_text as git_diff_text from ifcurl.git import diff_text as git_diff_text
from ifcurl.git import fetch_ifc from ifcurl.git import fetch_ifc
from ifcurl.render_service import _sandboxed_clash, _sandboxed_diff, _sandboxed_pipeline, _sandboxed_query, _sandboxed_select from ifcurl.render_service import _sandboxed_clash, _sandboxed_diff, _sandboxed_pipeline, _sandboxed_query, _sandboxed_select
@ -212,6 +213,7 @@ app = FastAPI(
) )
app.include_router(bcf_router) app.include_router(bcf_router)
app.include_router(documents_router) app.include_router(documents_router)
app.include_router(foundation_router)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Rate limiting (in-process, per client IP, sliding window) # Rate limiting (in-process, per client IP, sliding window)

View file

@ -0,0 +1,203 @@
"""Tests for ifcurl.foundation_api — OpenCDE Foundation API 1.1 OAuth2 routes."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
import ifcurl.foundation_api as foundation_api
from ifcurl.service import _rate_hits, app
client = TestClient(app)
@pytest.fixture(autouse=True)
def reset_rate_limit():
_rate_hits.clear()
yield
_rate_hits.clear()
@pytest.fixture()
def with_credentials(monkeypatch):
monkeypatch.setattr(foundation_api, "_CLIENT_ID", "test-client-id")
monkeypatch.setattr(foundation_api, "_CLIENT_SECRET", "test-client-secret")
monkeypatch.setattr(foundation_api, "_FORGEJO_URL", "http://forgejo.example.com")
# ---------------------------------------------------------------------------
# GET /foundation/1.1/oauth2/auth_url
# ---------------------------------------------------------------------------
class TestAuthUrl:
def test_returns_200(self, with_credentials):
r = client.get("/foundation/1.1/oauth2/auth_url")
assert r.status_code == 200
def test_returns_url_and_expires_in(self, with_credentials):
r = client.get("/foundation/1.1/oauth2/auth_url")
data = r.json()
assert "url" in data
assert "expires_in" in data
def test_url_contains_client_id(self, with_credentials):
r = client.get("/foundation/1.1/oauth2/auth_url")
assert "test-client-id" in r.json()["url"]
def test_url_contains_response_type_code(self, with_credentials):
r = client.get("/foundation/1.1/oauth2/auth_url")
assert "response_type=code" in r.json()["url"]
def test_url_points_to_forgejo_authorize(self, with_credentials):
r = client.get("/foundation/1.1/oauth2/auth_url")
assert r.json()["url"].startswith("http://forgejo.example.com/login/oauth/authorize")
def test_redirect_uri_forwarded(self, with_credentials):
r = client.get(
"/foundation/1.1/oauth2/auth_url",
params={"redirect_uri": "https://app.example.com/callback"},
)
assert "redirect_uri=" in r.json()["url"]
assert "app.example.com" in r.json()["url"]
def test_no_redirect_uri_omits_param(self, with_credentials):
r = client.get("/foundation/1.1/oauth2/auth_url")
assert "redirect_uri" not in r.json()["url"]
def test_503_when_not_configured(self, monkeypatch):
monkeypatch.setattr(foundation_api, "_CLIENT_ID", None)
monkeypatch.setattr(foundation_api, "_CLIENT_SECRET", None)
r = client.get("/foundation/1.1/oauth2/auth_url")
assert r.status_code == 503
# ---------------------------------------------------------------------------
# POST /foundation/1.1/oauth2/token
# ---------------------------------------------------------------------------
def _mock_forgejo_token_ok():
resp = MagicMock()
resp.status_code = 200
resp.json.return_value = {
"access_token": "ACCESS",
"token_type": "bearer",
"expires_in": 3600,
"refresh_token": "REFRESH",
}
return resp
class TestToken:
def test_returns_200(self, with_credentials):
with patch("ifcurl.foundation_api.httpx.post", return_value=_mock_forgejo_token_ok()):
r = client.post("/foundation/1.1/oauth2/token", json={"code": "abc123"})
assert r.status_code == 200
def test_returns_access_token(self, with_credentials):
with patch("ifcurl.foundation_api.httpx.post", return_value=_mock_forgejo_token_ok()):
r = client.post("/foundation/1.1/oauth2/token", json={"code": "abc123"})
assert r.json()["access_token"] == "ACCESS"
def test_proxies_authorization_code_grant(self, with_credentials):
mock_post = MagicMock(return_value=_mock_forgejo_token_ok())
with patch("ifcurl.foundation_api.httpx.post", mock_post):
client.post("/foundation/1.1/oauth2/token", json={"code": "abc123"})
payload = mock_post.call_args.kwargs["json"]
assert payload["grant_type"] == "authorization_code"
assert payload["code"] == "abc123"
assert payload["client_id"] == "test-client-id"
assert payload["client_secret"] == "test-client-secret"
def test_redirect_uri_forwarded(self, with_credentials):
mock_post = MagicMock(return_value=_mock_forgejo_token_ok())
with patch("ifcurl.foundation_api.httpx.post", mock_post):
client.post(
"/foundation/1.1/oauth2/token",
json={"code": "abc123", "redirect_uri": "https://app.example.com/cb"},
)
payload = mock_post.call_args.kwargs["json"]
assert payload["redirect_uri"] == "https://app.example.com/cb"
def test_no_redirect_uri_omitted(self, with_credentials):
mock_post = MagicMock(return_value=_mock_forgejo_token_ok())
with patch("ifcurl.foundation_api.httpx.post", mock_post):
client.post("/foundation/1.1/oauth2/token", json={"code": "abc123"})
payload = mock_post.call_args.kwargs["json"]
assert "redirect_uri" not in payload
def test_forgejo_error_propagated(self, with_credentials):
err_resp = MagicMock()
err_resp.status_code = 401
err_resp.text = "invalid_grant"
with patch("ifcurl.foundation_api.httpx.post", return_value=err_resp):
r = client.post("/foundation/1.1/oauth2/token", json={"code": "bad"})
assert r.status_code == 401
def test_forgejo_unreachable_returns_503(self, with_credentials):
import httpx as _httpx
with patch("ifcurl.foundation_api.httpx.post", side_effect=_httpx.ConnectError("refused")):
r = client.post("/foundation/1.1/oauth2/token", json={"code": "abc123"})
assert r.status_code == 503
def test_503_when_not_configured(self, monkeypatch):
monkeypatch.setattr(foundation_api, "_CLIENT_ID", None)
monkeypatch.setattr(foundation_api, "_CLIENT_SECRET", None)
r = client.post("/foundation/1.1/oauth2/token", json={"code": "abc123"})
assert r.status_code == 503
def test_missing_code_returns_422(self, with_credentials):
r = client.post("/foundation/1.1/oauth2/token", json={})
assert r.status_code == 422
# ---------------------------------------------------------------------------
# POST /foundation/1.1/oauth2/token_refresh
# ---------------------------------------------------------------------------
class TestTokenRefresh:
def test_returns_200(self, with_credentials):
with patch("ifcurl.foundation_api.httpx.post", return_value=_mock_forgejo_token_ok()):
r = client.post(
"/foundation/1.1/oauth2/token_refresh",
json={"refresh_token": "REFRESH"},
)
assert r.status_code == 200
def test_proxies_refresh_token_grant(self, with_credentials):
mock_post = MagicMock(return_value=_mock_forgejo_token_ok())
with patch("ifcurl.foundation_api.httpx.post", mock_post):
client.post(
"/foundation/1.1/oauth2/token_refresh",
json={"refresh_token": "REFRESH"},
)
payload = mock_post.call_args.kwargs["json"]
assert payload["grant_type"] == "refresh_token"
assert payload["refresh_token"] == "REFRESH"
assert payload["client_id"] == "test-client-id"
assert payload["client_secret"] == "test-client-secret"
def test_forgejo_error_propagated(self, with_credentials):
err_resp = MagicMock()
err_resp.status_code = 401
err_resp.text = "invalid_grant"
with patch("ifcurl.foundation_api.httpx.post", return_value=err_resp):
r = client.post(
"/foundation/1.1/oauth2/token_refresh",
json={"refresh_token": "expired"},
)
assert r.status_code == 401
def test_503_when_not_configured(self, monkeypatch):
monkeypatch.setattr(foundation_api, "_CLIENT_ID", None)
monkeypatch.setattr(foundation_api, "_CLIENT_SECRET", None)
r = client.post(
"/foundation/1.1/oauth2/token_refresh",
json={"refresh_token": "REFRESH"},
)
assert r.status_code == 503
def test_missing_refresh_token_returns_422(self, with_credentials):
r = client.post("/foundation/1.1/oauth2/token_refresh", json={})
assert r.status_code == 422