mirror of
https://github.com/brunopostle/ifcurl.git
synced 2026-07-12 02:08:13 +00:00
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:
parent
d975658515
commit
48169ff94b
4 changed files with 364 additions and 2 deletions
|
|
@ -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
157
ifcurl/foundation_api.py
Normal 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())
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
203
tests/test_foundation_api.py
Normal file
203
tests/test_foundation_api.py
Normal 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
|
||||||
Loading…
Add table
Reference in a new issue