From 712612865d4dbef315c77f9d941f3d48a1df4444 Mon Sep 17 00:00:00 2001 From: binarybeach Date: Sun, 3 May 2026 20:24:48 -1000 Subject: [PATCH] =?UTF-8?q?binarybeachio:=20Bucket-4=20trusted-JWT=20auth?= =?UTF-8?q?=20=E2=80=94=20replaces=20in-place=20github.py=20patch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrates this fork to the binarybeachio platform-architecture pivot: oauth2-proxy at the edge enforces a Zitadel session, the auth-bridge mints a short-lived RS256 JWT, and a NEW additive endpoint at /auth/sign-in-trusted/ verifies the JWT, claims its jti against shared-redis (single-use replay protection, fail-closed), find-or-creates the User, and starts a Django session via user_login(). Net surface vs. upstream-clean: 1 new view file + 1 url path + 1 exports __init__ entry + 7 reserved error codes (6000-6099 range). github.py and the GitHub-button rebrand patch are reverted to upstream — sign-in entry-point UX is now driven by Traefik redirectregex on /sign-in* in infrastructure/plane/docker-compose.yml. Replay protection contract: jti claim minted by bridge, consumed via Redis SETNX with ttl = exp - now + 30s. Documented at binarybeachio/docs/architecture/bridge-jwt-replay-protection.md. Public-key transport: BB_BRIDGE_PUBLIC_KEY_URL env points at the in-cluster bridge's /.well-known/bb-bridge.pub.pem (avoids the env-PEM corruption issue Coolify has with backslash-escaped keys). Endpoint is implicitly disabled (404) when env unset — vanilla upstream behavior preserved. Storage patches (Patch 2) unchanged. Brand asset preserved (dormant). Pre-migration source state preserved on branch pre-migration-2026-05-04. Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.bb-local.example | 22 +- .gitignore | 3 + BINARYBEACHIO.md | 160 ++++++----- .../api/plane/authentication/adapter/error.py | 11 + .../authentication/provider/oauth/github.py | 205 +++++-------- apps/api/plane/authentication/urls.py | 5 + .../plane/authentication/views/__init__.py | 4 + .../plane/authentication/views/app/trusted.py | 271 ++++++++++++++++++ apps/web/core/hooks/oauth/core.tsx | 20 +- docker-compose.bb-local.yml | 21 +- 10 files changed, 490 insertions(+), 232 deletions(-) create mode 100644 apps/api/plane/authentication/views/app/trusted.py diff --git a/.env.bb-local.example b/.env.bb-local.example index 75b764580..335d9476c 100644 --- a/.env.bb-local.example +++ b/.env.bb-local.example @@ -1,13 +1,17 @@ # bb-plane-fork local-test env — copy to `.env.bb-local` and fill in. # Gitignored. Used by docker-compose.bb-local.yml. -# Zitadel OIDC client created at https://auth.binarybeach.io/ui/console/ -# (Project → Add Application → Web → Code flow). Redirect URIs to register: -# http://localhost:8888/auth/github/callback/ -# https://pm.binarybeach.io/auth/github/callback/ -GITHUB_CLIENT_ID=__paste-from-zitadel__ -GITHUB_CLIENT_SECRET=__paste-from-zitadel__ +# Bucket-4 trusted-JWT endpoint (apps/api/plane/authentication/views/app/trusted.py). +# Activated when this URL is set; unset → endpoint returns 404 (regression-safe +# default; vanilla upstream behavior preserved out of the box). +# +# Production points at the in-cluster bridge service: +# http://auth-bridge-:3000/.well-known/bb-bridge.pub.pem +# Local dev typically points at a manually-served PEM (e.g. via `python3 -m http.server`) +# or at the production bridge for read-only key fetch testing: +# https://bridge.binarybeach.io/.well-known/bb-bridge.pub.pem +BB_BRIDGE_PUBLIC_KEY_URL= -# Zitadel host. Setting this activates the OIDC code path in our patched -# GitHubOAuthProvider. Override here if testing against a different Zitadel. -ZITADEL_DOMAIN=auth.binarybeach.io +# When BB_BRIDGE_PUBLIC_KEY_URL is unset, the trusted endpoint is disabled and +# Plane behaves like upstream-vanilla (email+password sign-in, the four +# stock OAuth providers). That's the right default for purely-local hacking. diff --git a/.gitignore b/.gitignore index 1514af2a7..0e9a5a3c5 100644 --- a/.gitignore +++ b/.gitignore @@ -113,3 +113,6 @@ build/ .react-router/ temp/ scripts/ + +# binarybeachio: Cloudflare Wrangler local dev cache (when used for *.binarybeach.io DNS work) +.wrangler/ diff --git a/BINARYBEACHIO.md b/BINARYBEACHIO.md index 8645bc958..fcbb4ba58 100644 --- a/BINARYBEACHIO.md +++ b/BINARYBEACHIO.md @@ -27,87 +27,94 @@ update/ — short-lived integration branch when pulling a new upstream | Upstream repo | https://github.com/makeplane/plane | | Upstream default branch | `preview` | | Currently integrated upstream version | **v1.3.0** (release commit `cf696d2`) | -| License | AGPL-3.0-only (we MUST publish source of any deployed customizations — push-mirror to GitHub satisfies this) | +| License | AGPL-3.0-only (we MUST publish source of any deployed customizations — public Forgejo + push-mirror to GitHub satisfies this) | -## Why we forked +## Why we forked (post-2026-05-04 platform-architecture pivot) -Plane's first-party OIDC support is gated behind the **Pro/Business commercial edition** (Pro tier minimum 25 users = $338+/mo). The community edition's `/god-mode/authentication/oidc` page is a frontend stub — the backend handler returns 404 (verified 2026-04-29 against pm.binarybeach.io). +Plane's first-party OIDC support is gated behind the **Pro/Business commercial edition** (Pro tier minimum 25 users = $338+/mo). The community edition's `/god-mode/authentication/oidc` page is a frontend stub — the backend handler returns 404. Plane CE has GitHub/GitLab/Gitea/Google OAuth providers but no native OIDC, no SAML, and no trusted-proxy-header auth. -We don't want to pay $338/mo for a single binarybeachio operator's SSO. We DO want every self-hosted service to authenticate users via the same Zitadel IdP (the break-glass admin convention from `binarybeachio/docs/architecture/self-hosting-infrastructure.md` §6.1 demands it). +We integrate Plane into the binarybeachio platform via the **architecture's Bucket 4 pattern**: a single additive trusted-JWT endpoint that the platform's auth-bridge calls after oauth2-proxy validates a Zitadel session at the edge. See: -Plane's backend has working **community-edition** GitHub OAuth (`/auth/github/...`). We repurpose that flow to point at Zitadel by env-driving the four GitHub URL constants and switching the userinfo claim mapping to OIDC standard. This is described in detail in `apps/api/plane/authentication/provider/oauth/github.py`'s header comment. +- `binarybeachio/docs/architecture/01-platform-architecture.md` for the bucket taxonomy and bridge contract +- `binarybeachio/docs/architecture/bridge-jwt-replay-protection.md` for the JWT replay-protection contract +- `binarybeachio/docs/services/plane/migration-plan.md` for the full per-service migration write-up + +The previous shape of this fork (in-place patching of `provider/oauth/github.py` to repurpose GitHub OAuth as Zitadel OIDC) was reverted on 2026-05-04 in favor of the Bucket-4 additive endpoint, which has a smaller fork surface, fewer hot files to track on upstream merges, and centralizes OIDC handling in the auth-bridge instead of duplicating it per-app. The pre-revert source state is preserved on the `pre-migration-2026-05-04` branch for reference. ## What's customized (the inventory — keep current) -Touch surface is intentionally minimal. Three logical patch groups across the repo. +Three logical patch groups across the repo. Touch surface is intentionally minimal. + +### Patch 1: Bucket-4 trusted-JWT entry-point (additive — 1 new file + 2 line additions) -### Patch 1: Zitadel OIDC (repurpose GitHub OAuth) | File | Change | Risk on upgrade | |---|---|---| -| `apps/api/plane/authentication/provider/oauth/github.py` | Repurposed entire file: env-drive endpoint URLs (default to `$ZITADEL_DOMAIN`'s OIDC endpoints, fall back to GitHub when `ZITADEL_DOMAIN` unset). Switch claim mapping to OIDC standard. Drop `__get_email` (OIDC userinfo includes email). Fix upstream's `expires_in` epoch-vs-duration bug. Drop `is_user_in_organization` (Zitadel handles authz). Add `prompt=select_account` for explicit account chooser. | **Medium.** This file rarely changes upstream. If Plane refactors the OauthAdapter base class signatures, our patched constructor must follow. | +| `apps/api/plane/authentication/views/app/trusted.py` | **New file**. Django `View` that validates a bridge-issued RS256 JWT, atomically claims its `jti` in shared-redis (replay protection), find-or-creates the User, and calls `user_login(request, user, is_app=True)` to set the Django session cookie. PEM is fetched at runtime from `BB_BRIDGE_PUBLIC_KEY_URL` (avoids the env-PEM corruption issue Coolify has with backslash-escaped keys). Endpoint is implicitly disabled (returns 404) when the env is unset. | **Low.** Depends only on `User` model, `user_login`, `post_user_auth_workflow`, and `get_safe_redirect_url` — all stable upstream APIs. PyJWT and `requests` are existing deps. | +| `apps/api/plane/authentication/urls.py` | 1-line addition appending `path("sign-in-trusted/", TrustedSignInEndpoint.as_view(), name="sign-in-trusted")` to the urlpatterns list. | **Low.** Pure append; no existing routes modified. | +| `apps/api/plane/authentication/views/__init__.py` | 1-line addition exporting `TrustedSignInEndpoint`. | **Low.** Pure append. | +| `apps/api/plane/authentication/adapter/error.py` | Adds 7 error codes in the 6000–6099 range (reserved for fork additions). Pure dict-additions; no existing entries renumbered. | **None.** | + +The full bridge ↔ Plane contract: + +- Bridge mints `RS256` JWT signed with `BRIDGE_SIGNING_KEY` (private). Claims: `iss=bb-bridge`, `aud=plane`, `iat`, `exp` (now+60s), `jti` (UUIDv4), `sub`, `email`, `first_name`, `last_name`, `tenant`. +- Bridge 302s the user's browser to `https://pm..binarybeach.io/auth/sign-in-trusted/?token=&next_path=`. +- Plane's view: fetches public key from `BB_BRIDGE_PUBLIC_KEY_URL` (cached 5 min), verifies signature + claims, atomically `SETNX bb_bridge_jti:` in shared-redis with TTL = `exp - now + 30s`, find-or-creates User by email, calls `user_login()`, 302s to `next_path`. +- Replay protection is **fail closed**: if shared-redis is unavailable, the request is rejected. Operator break-glass uses the email+password sign-in (vanilla upstream code) which doesn't depend on either Redis or the bridge. + +### Patch 2: Presigned PUT for uploads (R2/B2 don't implement PostObject) -### Patch 2: Brand label + logo | File | Change | Risk on upgrade | |---|---|---| -| `apps/web/core/hooks/oauth/core.tsx` | Cosmetic: rename "GitHub" button text to "BinaryBeach.io"; swap GitHub light/dark logo imports for our brand mark. Backend ID/route unchanged. | **Low.** Pure cosmetic; rebases trivially. | -| `apps/web/app/assets/logos/binarybeach-logo.png` | New asset. | **None.** | - -### Patch 3: Presigned PUT for uploads (R2/B2 don't implement PostObject) -| File | Change | Risk on upgrade | -|---|---|---| -| `apps/api/plane/settings/storage.py` | `S3Storage.generate_presigned_post(...)` rewritten to mint a presigned PUT URL via `generate_presigned_url(HttpMethod="PUT")`. Method name preserved for caller compat. Returns `{url, method:"PUT", fields:{Content-Type, key}}`. | **Medium.** If Plane's upload flow changes upstream (e.g. refactors to per-app storage backends, switches away from POST), conflict surface grows. | -| `apps/api/plane/utils/openapi/responses.py` | OpenAPI example response updated to reflect PUT shape. | **Low.** | -| `apps/api/plane/tests/unit/settings/test_storage.py` | 2 tests retargeted to assert `generate_presigned_url` boto3 call instead of `generate_presigned_post`. | **Low.** | +| `apps/api/plane/settings/storage.py` | `S3Storage.generate_presigned_post(...)` rewritten to mint a presigned PUT URL via `generate_presigned_url(HttpMethod="PUT")`. Method name preserved for caller compat. | **Medium.** If Plane's upload flow changes upstream, conflict surface grows. Candidate for upstream PR. | +| `apps/api/plane/utils/openapi/responses.py` | OpenAPI example response updated to PUT shape. | **Low.** | +| `apps/api/plane/tests/unit/settings/test_storage.py` | 2 tests retargeted to assert `generate_presigned_url` boto3 call. | **Low.** | | `packages/types/src/file.ts` | `TFileSignedURLResponse.upload_data` adds `method?: "PUT" \| "POST"`, drops AWS POST-form-data fields. | **Low.** | | `packages/services/src/file/helper.ts` | `generateFileUploadPayload(...)` returns a `TFileUploadRequest` descriptor; dispatches PUT/POST. | **Medium.** | -| `packages/services/src/file/file-upload.service.ts` + `apps/web/core/services/file-upload.service.ts` | `uploadFile(...)` signature changes from `(url, FormData, progress?)` to `(payload, progress?)`. Uses `axios.request({method, url, data, headers})`. | **Medium.** | +| `packages/services/src/file/file-upload.service.ts` + `apps/web/core/services/file-upload.service.ts` | `uploadFile(...)` signature changed to `(payload, progress?)`. Uses `axios.request({method, url, data, headers})`. | **Medium.** | | `apps/web/core/services/file.service.ts`, `apps/web/core/services/issue/issue_attachment.service.ts`, `packages/services/src/file/sites-file.service.ts` | 5 caller sites updated to pass `TFileUploadRequest` to `uploadFile`. | **Low.** | -The full decision record (why we patched, tradeoffs accepted, rollback procedure) lives at `binarybeachio/docs/features/storage-upload-flow.md`. Patch 3 can be reverted independently of Patches 1 and 2 — find the commit titled "binarybeachio: presigned PUT for uploads" and `git revert `. +Decision record at `binarybeachio/docs/features/storage-upload-flow.md`. Patch 2 is independent of Patch 1 — `git revert ` undoes it cleanly. + +### Patch 3: Brand asset (kept as dormant; entry-point UX is Traefik-driven) + +| File | Change | Risk on upgrade | +|---|---|---| +| `apps/web/app/assets/logos/binarybeach-logo.png` | New asset. Currently unreferenced; preserved for future AGPL §13 footer-link addition or other branding work. | **None.** | + +The previous fork's GitHub-button rebrand patch (`apps/web/core/hooks/oauth/core.tsx`) was reverted on 2026-05-04. Sign-in entry-point UX is now driven by a Traefik `redirectregex` middleware applied to the per-tenant Plane router that 302s `/sign-in*`, `/sign-up*`, `/accounts/sign-in*` to `https://bridge.binarybeach.io/handoff?app=plane&tenant=&...`. Pure infrastructure config; no source modification needed for the redirect. Files **not** changed (deliberately): -- `apps/api/plane/authentication/views/app/github.py` — view layer, unchanged. Routes still `/auth/github/`. -- `apps/api/plane/authentication/views/space/github.py` — public-share OAuth, unchanged. -- `apps/api/plane/authentication/urls.py` — URL routing unchanged. -- `apps/admin/...` — god-mode UI still says "GitHub" provider; only the operator (us) sees it, not worth the patch surface. -- `apps/space/...` — public sharing site OAuth, not a priority for v1. - -## Cross-fork conventions adopted - -This fork pulls in binarybeachio's [session lifecycle convention](https://git.binarybeach.io/binarybeach/binarybeachio-platform/src/branch/main/docs/features/session-lifecycle.md) — 15-min idle timeout, slide-on-activity. Applied automatically by `bootstrap.py` at deploy. To override for this fork specifically, set `SESSION_COOKIE_AGE` / `ADMIN_SESSION_COOKIE_AGE` / `SESSION_SAVE_EVERY_REQUEST` in `infrastructure/plane/.env` over in the binarybeachio repo (per-app .env beats convention). - -Local-test stack (`docker-compose.bb-local.yml`) hard-codes the same values inline since cross-repo file references in compose are awkward; this is a documented, accepted small duplication. +- `apps/api/plane/authentication/provider/oauth/github.py` — upstream-clean. Vanilla GitHub OAuth still works if configured via god-mode UI. +- `apps/api/plane/authentication/views/app/github.py` and the gitlab/gitea/google equivalents — all upstream-clean. +- `apps/admin/...` — god-mode UI unchanged. +- `apps/space/...` — public-share OAuth unchanged. Authenticated public boards continue to use email+password sign-in (vanilla upstream). When a tenant needs SSO for shared boards, add a sibling `views/space/trusted.py` (estimated ~80 LOC, mirrors the app/ view). ## Required runtime config -Set these env vars on the patched `plane-backend` container (binarybeachio sets them in `infrastructure/plane/.env`): +Set on the patched `plane-backend` container (binarybeachio sets these in `infrastructure/plane/.env`): ```bash -# Pin our Zitadel host — this enables the OIDC code path. Without it, the -# patched provider falls back to vanilla GitHub OAuth (deliberate). -ZITADEL_DOMAIN=auth.binarybeach.io - -# Optional explicit overrides if endpoints differ from Zitadel defaults. -# Defaults derive from ZITADEL_DOMAIN: /oauth/v2/{authorize,token}, /oidc/v1/userinfo. -# OIDC_AUTH_URL= -# OIDC_TOKEN_URL= -# OIDC_USERINFO_URL= - -# Existing Plane env vars (kept names — backend still calls them GITHUB_*) -GITHUB_CLIENT_ID= -GITHUB_CLIENT_SECRET= +# Activates the trusted-JWT endpoint. URL points at the in-cluster bridge +# service's public-key endpoint. Unset → endpoint returns 404 (regression-safe). +BB_BRIDGE_PUBLIC_KEY_URL=http://auth-bridge-:3000/.well-known/bb-bridge.pub.pem ``` -And in Plane's god-mode admin UI (`/god-mode/authentication/github`): -- Toggle GitHub OAuth ON -- Paste the same client_id/secret (god-mode DB rows shadow env vars at runtime — both must agree) +Bridge-side configuration (in `binarybeachio/infrastructure/auth-bridge/.env`): -In Zitadel: -- Create OIDC Web application -- Redirect URI: `https://pm.binarybeach.io/auth/github/callback/` (production) and `http://localhost/auth/github/callback/` (local test) -- Auth method: client_secret_post (Plane sends creds in body) -- Grant types: Authorization Code + Refresh Token -- Response types: code +```bash +ADAPTER_PLANE_BINARYBEACH_BASE_URL=https://pm.binarybeach.binarybeach.io +# BRIDGE_SIGNING_KEY is loaded centrally by bridge-key.ts; the matching +# public key is served at /.well-known/bb-bridge.pub.pem and consumed by +# Plane via BB_BRIDGE_PUBLIC_KEY_URL. +``` + +Plane god-mode admin UI (`/god-mode/authentication/...`): +- All four upstream OAuth providers (GitHub/GitLab/Gitea/Google) can be left disabled. The trusted-JWT entry-point is the SSO front door. +- Email+password sign-in remains available as the break-glass admin path. Per-tenant `bb-admin` user is seeded with a permanent password from `_shared/.env.bb-admin`. + +## Cross-fork conventions adopted + +This fork pulls in binarybeachio's [session lifecycle convention](https://git.binarybeach.io/binarybeach/binarybeachio-platform/src/branch/main/docs/features/session-lifecycle.md) — 15-min idle timeout, slide-on-activity. Applied automatically by `bootstrap.py` at deploy. To override for this fork specifically, set `SESSION_COOKIE_AGE` / `ADMIN_SESSION_COOKIE_AGE` / `SESSION_SAVE_EVERY_REQUEST` in `infrastructure/plane/.env` (per-app .env beats convention). ## Refresh from upstream — the procedure @@ -123,8 +130,14 @@ git push origin upstream # Integration branch git switch main git switch -c update/v1.4.0 -git merge upstream # resolve any conflicts (likely in github.py) -# Run all tests, hand-test the OIDC flow against staging Zitadel +git merge upstream # likely conflict-free since fork is additive + +# Hand-test: +# 1. Local stack via docker-compose.bb-local.yml — confirm sign-in works. +# 2. Trusted endpoint with a hand-minted JWT (helper script TBD; for now, +# mint via a node REPL using bridge-key.ts:signBridgeJwt). +# 3. Vanilla email+password regression test. + # Once happy: git switch main git merge --ff-only update/v1.4.0 @@ -142,29 +155,27 @@ Per binarybeachio architecture doc §7.4 ("only rebuild what we touched"), this | Image | Customized? | Source | Build target | |---|---|---|---| -| `plane-backend` | YES | `apps/api/Dockerfile.api` | `git.binarybeach.io/binarybeach/plane-backend:v1.3.0-mine.` | -| `plane-frontend` (aka web) | YES | `apps/web/Dockerfile.web` | `git.binarybeach.io/binarybeach/plane-frontend:v1.3.0-mine.` | +| `plane-backend` | YES (Patch 1 + Patch 2) | `apps/api/Dockerfile.api` | `git.binarybeach.io/binarybeach/plane-backend:v1.3.0-mine.` | +| `plane-frontend` (aka web) | YES (Patch 2 frontend bits only) | `apps/web/Dockerfile.web` | `git.binarybeach.io/binarybeach/plane-frontend:v1.3.0-mine.` | | `plane-space` | no | upstream `makeplane/plane-space:v1.3.0` | (no rebuild) | | `plane-admin` | no | upstream `makeplane/plane-admin:v1.3.0` | (no rebuild) | | `plane-live` | no | upstream `makeplane/plane-live:v1.3.0` | (no rebuild) | | `plane-proxy` | no | upstream `makeplane/plane-proxy:v1.3.0` | (no rebuild) | -The binarybeachio compose file at `infrastructure/plane/docker-compose.yml` mixes our patched images with upstream-vanilla images for the four we don't touch. - Tag scheme per architecture §6 #7: `-mine.`. Push immutable tag + `:latest`: ```bash # from C:\Users\maxwe\GitHubRepos\bb-plane-fork -docker build -t git.binarybeach.io/binarybeach/plane-backend:v1.3.0-mine.1 \ +docker build -t git.binarybeach.io/binarybeach/plane-backend:v1.3.0-mine.2 \ -t git.binarybeach.io/binarybeach/plane-backend:latest \ -f apps/api/Dockerfile.api . -docker push git.binarybeach.io/binarybeach/plane-backend:v1.3.0-mine.1 +docker push git.binarybeach.io/binarybeach/plane-backend:v1.3.0-mine.2 docker push git.binarybeach.io/binarybeach/plane-backend:latest -docker build -t git.binarybeach.io/binarybeach/plane-frontend:v1.3.0-mine.1 \ +docker build -t git.binarybeach.io/binarybeach/plane-frontend:v1.3.0-mine.2 \ -t git.binarybeach.io/binarybeach/plane-frontend:latest \ -f apps/web/Dockerfile.web . -docker push git.binarybeach.io/binarybeach/plane-frontend:v1.3.0-mine.1 +docker push git.binarybeach.io/binarybeach/plane-frontend:v1.3.0-mine.2 docker push git.binarybeach.io/binarybeach/plane-frontend:latest ``` @@ -174,18 +185,15 @@ docker push git.binarybeach.io/binarybeach/plane-frontend:latest Plane is AGPL-3.0-only. The license requires us to provide the source of any modified version we deploy or offer over a network. Our compliance: -1. **Forgejo source** — `git.binarybeach.io/binarybeach/bb-plane-fork` is a public-readable repository (Forgejo `DEFAULT_PRIVATE=public`). -2. **GitHub mirror** — push-mirror to `github.com/binarybeachllc/bb-plane-fork` provides off-site backup AND a publicly-discoverable source location even if Forgejo is unreachable. -3. **In-product source link** — TODO: add a footer link in our customized `apps/web` to https://git.binarybeach.io/binarybeach/bb-plane-fork. AGPL §13 requires "prominent" notice to network users; a footer suffices. - -The TODO in #3 is tracked in the parent binarybeachio repo's compliance log when we get there. Not a v1 blocker — Plane already includes upstream license notices and our changes preserve them. +1. **Forgejo source** — `git.binarybeach.io/binarybeach/bb-plane-fork` is publicly readable. +2. **GitHub mirror** — push-mirror to `github.com/binarybeachllc/bb-plane-fork`. +3. **In-product source link** — TODO. AGPL §13 requires "prominent" notice to network users; a footer suffices. Tracked separately. ## Test plan (manual, until we have CI) -1. **Local build smoke**: both images build cleanly with current Dockerfiles. -2. **Local stack**: `docker compose -f docker-compose-local.yml up -d` (using patched images), pointed at hosted Zitadel. -3. **OIDC flow**: visit `http://localhost`, click "Continue with binarybeach.io", redirected to `auth.binarybeach.io`, log in as Zitadel user, redirected back, account auto-provisioned in Plane, signed in. -4. **New-user flow**: sign in with a Zitadel user that doesn't yet exist in Plane → Plane auto-creates the account. -5. **Re-login**: sign out, sign in again with same Zitadel user → matched by email, same Plane user. -6. **Fallback**: unset `ZITADEL_DOMAIN` env var, restart backend, try GitHub OAuth flow with real GitHub creds → should still work (regression check that we didn't break upstream behavior). -7. **Production deploy**: bump tag in binarybeachio `infrastructure/plane/docker-compose.yml` → `py infrastructure/_shared/bootstrap.py` → verify on `pm.binarybeach.io`. +1. **Local build smoke**: both images build cleanly. +2. **Local stack**: `docker compose -f docker-compose.bb-local.yml --env-file .env.bb-local up -d` (with `BB_BRIDGE_PUBLIC_KEY_URL` unset) → vanilla email+password sign-in works (regression check). +3. **Trusted-JWT happy path**: with `BB_BRIDGE_PUBLIC_KEY_URL` pointing at production bridge, hand-mint a JWT (claims: `iss=bb-bridge`, `aud=plane`, valid `exp`, fresh `jti`, valid email), `GET /auth/sign-in-trusted/?token=&next_path=/`, expect 302 to `/` with sessionid cookie set. +4. **Trusted-JWT replay rejection**: hit the same URL with the same token twice. First → 302 + sessionid. Second → 302 to error redirect with `TRUSTED_JWT_TOKEN_REPLAYED`. +5. **Trusted-JWT disabled regression**: unset `BB_BRIDGE_PUBLIC_KEY_URL`, hit `/auth/sign-in-trusted/`, expect 404. +6. **Production deploy**: bump tag in `binarybeachio/infrastructure/plane/docker-compose.yml` → `py infrastructure/_shared/bootstrap.py` → verify on `pm.binarybeach.binarybeach.io`. diff --git a/apps/api/plane/authentication/adapter/error.py b/apps/api/plane/authentication/adapter/error.py index f91565df2..ceeab8e90 100644 --- a/apps/api/plane/authentication/adapter/error.py +++ b/apps/api/plane/authentication/adapter/error.py @@ -71,6 +71,17 @@ AUTHENTICATION_ERROR_CODES = { "RATE_LIMIT_EXCEEDED": 5900, # Unknown "AUTHENTICATION_FAILED": 5999, + # binarybeachio fork addition (Bucket-4 trusted-JWT entry-point) — see + # views/app/trusted.py and BINARYBEACHIO.md. Codes 6000-6099 are reserved + # for fork additions to keep them outside the upstream-allocated 5000-5999 + # range and reduce upstream-merge collision risk. + "TRUSTED_JWT_ENDPOINT_DISABLED": 6000, + "TRUSTED_JWT_TOKEN_MISSING": 6001, + "TRUSTED_JWT_TOKEN_INVALID": 6002, + "TRUSTED_JWT_TOKEN_EXPIRED": 6003, + "TRUSTED_JWT_TOKEN_REPLAYED": 6004, + "TRUSTED_JWT_REPLAY_STORE_DOWN": 6005, + "TRUSTED_JWT_KEY_FETCH_FAILED": 6006, } diff --git a/apps/api/plane/authentication/provider/oauth/github.py b/apps/api/plane/authentication/provider/oauth/github.py index b98f54fc6..363cd722e 100644 --- a/apps/api/plane/authentication/provider/oauth/github.py +++ b/apps/api/plane/authentication/provider/oauth/github.py @@ -1,41 +1,14 @@ # Copyright (c) 2023-present Plane Software, Inc. and contributors # SPDX-License-Identifier: AGPL-3.0-only # See the LICENSE file for details. -# -# binarybeachio fork — see BINARYBEACHIO.md at repo root. -# This file is patched to repurpose Plane's "GitHub" OAuth provider as a -# generic OIDC provider, so we can point /auth/github/ at our self-hosted -# Zitadel instance without paying for Plane Pro/Business edition's first-party -# OIDC support. -# -# Touch points kept stable to minimize merge conflicts on Plane upgrades: -# - class name `GitHubOAuthProvider` (callers import it by name) -# - `provider = "github"` (DB rows keyed on this string) -# - env var names `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` -# - URL routes `/auth/github/...` (frontend hardcodes these) -# -# What changed: -# - `auth_url` / `token_url` / `userinfo_url` are now read from env, default -# to the Zitadel instance at $ZITADEL_DOMAIN. If `ZITADEL_DOMAIN` is unset -# the original GitHub URLs apply, so vanilla GitHub OAuth still works as a -# fallback (lets us re-test against upstream behavior without reverting). -# - Scope flipped from "read:user user:email" to "openid email profile" when -# pointed at Zitadel (or any OIDC IdP). -# - `__get_email` removed — standard OIDC userinfo includes `email` directly. -# - User claim mapping switched to OIDC standard: sub, name, given_name, -# family_name, email, picture. -# - Fixed upstream bug where `expires_in` (a duration in seconds) was being -# passed to datetime.fromtimestamp() (which expects an epoch timestamp). -# - Dropped `is_user_in_organization` — Zitadel handles authorization itself -# via project grants/roles. The `GITHUB_ORGANIZATION_ID` env stays read -# (no-op) to avoid breaking deployments that have it set. # Python imports import os -from datetime import timedelta +from datetime import datetime from urllib.parse import urlencode -from django.utils import timezone +import pytz +import requests from plane.authentication.adapter.error import ( AUTHENTICATION_ERROR_CODES, @@ -47,30 +20,15 @@ from plane.authentication.adapter.oauth import OauthAdapter from plane.license.utils.instance_value import get_configuration_value -def _zitadel_default(path: str) -> str | None: - """Build a Zitadel endpoint URL from $ZITADEL_DOMAIN if set.""" - domain = os.environ.get("ZITADEL_DOMAIN") - return f"https://{domain}{path}" if domain else None - - class GitHubOAuthProvider(OauthAdapter): - # Endpoint URLs — env-driven. Defaults derived from $ZITADEL_DOMAIN if set, - # falling back to GitHub.com to preserve upstream behavior when unset. - token_url = os.environ.get("OIDC_TOKEN_URL") or ( - _zitadel_default("/oauth/v2/token") or "https://github.com/login/oauth/access_token" - ) - userinfo_url = os.environ.get("OIDC_USERINFO_URL") or ( - _zitadel_default("/oidc/v1/userinfo") or "https://api.github.com/user" - ) - _auth_url_base = os.environ.get("OIDC_AUTH_URL") or ( - _zitadel_default("/oauth/v2/authorize") or "https://github.com/login/oauth/authorize" - ) + token_url = "https://github.com/login/oauth/access_token" + userinfo_url = "https://api.github.com/user" + org_membership_url = "https://api.github.com/orgs" provider = "github" + scope = "read:user user:email" - # Scopes — OIDC standard when ZITADEL_DOMAIN is set; GitHub-flavored otherwise - # to match unpatched upstream behavior for fallback testing. - scope = "openid email profile" if os.environ.get("ZITADEL_DOMAIN") else "read:user user:email" + organization_scope = "read:org" def __init__(self, request, code=None, state=None, callback=None): GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, GITHUB_ORGANIZATION_ID = get_configuration_value([ @@ -96,13 +54,11 @@ class GitHubOAuthProvider(OauthAdapter): client_id = GITHUB_CLIENT_ID client_secret = GITHUB_CLIENT_SECRET - # Read but unused — kept for API compatibility with deployments that - # had this set under upstream Plane. Authorization in our setup is - # handled by Zitadel project grants, not client-side org membership. self.organization_id = GITHUB_ORGANIZATION_ID - # Build redirect_uri — must match what's registered with the IdP. - # Plane's frontend hardcodes /auth/github/callback/ so we keep that path. + if self.organization_id: + self.scope += f" {self.organization_scope}" + redirect_uri = f"""{"https" if request.is_secure() else "http"}://{request.get_host()}/auth/github/callback/""" url_params = { "client_id": client_id, @@ -110,21 +66,7 @@ class GitHubOAuthProvider(OauthAdapter): "scope": self.scope, "state": state, } - # OIDC requires response_type=code; GitHub OAuth tolerates it. - # `prompt=select_account` makes Zitadel show its account chooser even - # when only one session exists — the user explicitly chooses which - # identity to use rather than being silently passed through. Without - # this, the OIDC default is "session exists → log in immediately," - # which is technically correct SSO but is an unfamiliar UX coming - # from Google/GitHub style flows that always show a picker. - # Override per-request by setting `OIDC_PROMPT=` (empty) or another - # value (`login` to force re-auth, `consent` to force consent screen). - if os.environ.get("ZITADEL_DOMAIN"): - url_params["response_type"] = "code" - prompt = os.environ.get("OIDC_PROMPT", "select_account") - if prompt: - url_params["prompt"] = prompt - auth_url = f"{self._auth_url_base}?{urlencode(url_params)}" + auth_url = f"https://github.com/login/oauth/authorize?{urlencode(url_params)}" super().__init__( request, self.provider, @@ -141,84 +83,93 @@ class GitHubOAuthProvider(OauthAdapter): def set_token_data(self): data = { - "grant_type": "authorization_code", "client_id": self.client_id, "client_secret": self.client_secret, "code": self.code, "redirect_uri": self.redirect_uri, } token_response = self.get_user_token(data=data, headers={"Accept": "application/json"}) - - # Fix upstream bug: `expires_in` is a duration (seconds) per RFC 6749, - # not an epoch timestamp. Compute absolute expiry correctly. - expires_in = token_response.get("expires_in") - access_token_expired_at = ( - timezone.now() + timedelta(seconds=int(expires_in)) if expires_in else None - ) - # `refresh_token_expired_at` is non-standard; some IdPs return it as - # absolute, some as duration. Zitadel doesn't return it at all. Keep the - # original interpretation as-epoch for backward-compat with upstream. - refresh_expired_raw = token_response.get("refresh_token_expired_at") - if refresh_expired_raw: - from datetime import datetime - import pytz - refresh_token_expired_at = datetime.fromtimestamp(refresh_expired_raw, tz=pytz.utc) - else: - refresh_token_expired_at = None - super().set_token_data({ "access_token": token_response.get("access_token"), "refresh_token": token_response.get("refresh_token", None), - "access_token_expired_at": access_token_expired_at, - "refresh_token_expired_at": refresh_token_expired_at, + "access_token_expired_at": ( + datetime.fromtimestamp(token_response.get("expires_in"), tz=pytz.utc) + if token_response.get("expires_in") + else None + ), + "refresh_token_expired_at": ( + datetime.fromtimestamp(token_response.get("refresh_token_expired_at"), tz=pytz.utc) + if token_response.get("refresh_token_expired_at") + else None + ), "id_token": token_response.get("id_token", ""), }) - def set_user_data(self): - user_info_response = self.get_user_response() - - # Claim mapping. When ZITADEL_DOMAIN is set, use OIDC standard claims; - # otherwise fall back to GitHub's quirky shape (no email in userinfo, - # `name` instead of `given_name`/`family_name`). - if os.environ.get("ZITADEL_DOMAIN"): - email = user_info_response.get("email") - if not email: + def __get_email(self, headers): + try: + # Github does not provide email in user response + emails_url = "https://api.github.com/user/emails" + emails_response = requests.get(emails_url, headers=headers).json() + # Ensure the response is a list before iterating + if not isinstance(emails_response, list): + self.logger.error("Unexpected response format from GitHub emails API") raise AuthenticationException( error_code=AUTHENTICATION_ERROR_CODES["GITHUB_OAUTH_PROVIDER_ERROR"], error_message="GITHUB_OAUTH_PROVIDER_ERROR", ) - super().set_user_data({ - "email": email, - "user": { - "provider_id": user_info_response.get("sub"), - "email": email, - "avatar": user_info_response.get("picture"), - "first_name": user_info_response.get("given_name") or user_info_response.get("name", "").split(" ", 1)[0], - "last_name": user_info_response.get("family_name") or (user_info_response.get("name", "").split(" ", 1)[1] if " " in user_info_response.get("name", "") else ""), - "is_password_autoset": True, - }, - }) - return + email = next((email["email"] for email in emails_response if email["primary"]), None) + if not email: + self.logger.error("No primary email found for user") + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["GITHUB_OAUTH_PROVIDER_ERROR"], + error_message="GITHUB_OAUTH_PROVIDER_ERROR", + ) + return email + except requests.RequestException: + self.logger.warning( + "Error getting email from GitHub", + ) + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["GITHUB_OAUTH_PROVIDER_ERROR"], + error_message="GITHUB_OAUTH_PROVIDER_ERROR", + ) - # Fallback: vanilla GitHub OAuth — keep upstream behavior. Email comes - # from a separate /user/emails call. - import requests + def is_user_in_organization(self, github_username): + headers = {"Authorization": f"Bearer {self.token_data.get('access_token')}"} + response = requests.get( + f"{self.org_membership_url}/{self.organization_id}/memberships/{github_username}", + headers=headers, + ) + return response.status_code == 200 # 200 means the user is a member + + def set_user_data(self): + user_info_response = self.get_user_response() headers = { "Authorization": f"Bearer {self.token_data.get('access_token')}", "Accept": "application/json", } - emails_response = requests.get("https://api.github.com/user/emails", headers=headers).json() - if not isinstance(emails_response, list): - raise AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES["GITHUB_OAUTH_PROVIDER_ERROR"], - error_message="GITHUB_OAUTH_PROVIDER_ERROR", - ) - email = next((e["email"] for e in emails_response if e["primary"]), None) - if not email: - raise AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES["GITHUB_OAUTH_PROVIDER_ERROR"], - error_message="GITHUB_OAUTH_PROVIDER_ERROR", - ) + + if self.organization_id: + if not self.is_user_in_organization(user_info_response.get("login")): + self.logger.warning( + "User is not in organization", + extra={ + "organization_id": self.organization_id, + "user_login": user_info_response.get("login"), + }, + ) + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["GITHUB_USER_NOT_IN_ORG"], + error_message="GITHUB_USER_NOT_IN_ORG", + ) + + email = self.__get_email(headers=headers) + self.logger.debug( + "Email found", + extra={ + "email": email, + }, + ) super().set_user_data({ "email": email, "user": { diff --git a/apps/api/plane/authentication/urls.py b/apps/api/plane/authentication/urls.py index 4bec07db0..9af65c118 100644 --- a/apps/api/plane/authentication/urls.py +++ b/apps/api/plane/authentication/urls.py @@ -44,6 +44,8 @@ from .views import ( GiteaOauthInitiateEndpoint, GiteaCallbackSpaceEndpoint, GiteaOauthInitiateSpaceEndpoint, + # binarybeachio fork addition — see views/app/trusted.py. + TrustedSignInEndpoint, ) urlpatterns = [ @@ -150,4 +152,7 @@ urlpatterns = [ GiteaCallbackSpaceEndpoint.as_view(), name="space-gitea-callback", ), + # binarybeachio fork addition — Bucket-4 trusted-JWT entry-point. + # See views/app/trusted.py and BINARYBEACHIO.md. + path("sign-in-trusted/", TrustedSignInEndpoint.as_view(), name="sign-in-trusted"), ] diff --git a/apps/api/plane/authentication/views/__init__.py b/apps/api/plane/authentication/views/__init__.py index a9c816ae9..97a3bb893 100644 --- a/apps/api/plane/authentication/views/__init__.py +++ b/apps/api/plane/authentication/views/__init__.py @@ -41,3 +41,7 @@ from .space.password_management import ( ResetPasswordSpaceEndpoint, ) from .app.password_management import ForgotPasswordEndpoint, ResetPasswordEndpoint + +# binarybeachio fork addition (Bucket-4 trusted-JWT entry-point) — see +# views/app/trusted.py and BINARYBEACHIO.md. +from .app.trusted import TrustedSignInEndpoint diff --git a/apps/api/plane/authentication/views/app/trusted.py b/apps/api/plane/authentication/views/app/trusted.py new file mode 100644 index 000000000..4fa611c98 --- /dev/null +++ b/apps/api/plane/authentication/views/app/trusted.py @@ -0,0 +1,271 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. +# +# binarybeachio fork addition — see BINARYBEACHIO.md at repo root. +# +# Bucket-4 trusted-JWT entry-point. Validates a short-lived RS256 JWT signed +# by the binarybeachio auth-bridge (private key BRIDGE_SIGNING_KEY), enforces +# single-use replay protection via shared-redis SETNX (per the contract in +# `binarybeachio/docs/architecture/bridge-jwt-replay-protection.md`), then +# finds-or-creates the corresponding User and starts a Django session via +# the existing user_login() helper. +# +# Endpoint behavior when not configured: +# - If BB_BRIDGE_PUBLIC_KEY_URL env is unset → 404 (endpoint disabled). +# Vanilla upstream behavior is preserved out-of-the-box; the trusted-JWT +# entry-point only exists in deployments that explicitly opt in. +# +# Public-key transport: +# - Fetched at request time from BB_BRIDGE_PUBLIC_KEY_URL (typically +# `http://auth-bridge-:3000/.well-known/bb-bridge.pub.pem`). +# - Cached in-process for 5 minutes; auto-refreshed on signature failure +# to handle bridge key rotation transparently. +# - This sidesteps the env-PEM corruption issue: putting RSA PEMs through +# Coolify's .env writer escapes backslashes (`\n` → `\\n`), which +# corrupts the multi-line PEM. HTTP fetch never traverses that path. +# See bb-activepieces-fork/.../trusted-jwt-verifier.ts module-doc for +# the original write-up. +# +# Replay protection: +# - Bridge mints with a UUIDv4 `jti` claim. +# - This view atomically SETNX `bb_bridge_jti:` in shared-redis with +# TTL = (exp - now) + 30s clock-skew tolerance. +# - Fail closed: if Redis is unavailable, REJECT. Auth correctness > +# auth availability; break-glass admin (email+password) covers operator +# access during a Redis outage. + +import logging +import os +import time +from typing import Optional, Tuple +from urllib.parse import urlparse + +import jwt as pyjwt +import redis +import requests +from django.http import HttpResponseRedirect, HttpResponseNotFound +from django.views import View + +from plane.authentication.adapter.error import ( + AUTHENTICATION_ERROR_CODES, + AuthenticationException, +) +from plane.authentication.utils.host import base_host +from plane.authentication.utils.login import user_login +from plane.authentication.utils.redirection_path import get_redirection_path +from plane.authentication.utils.user_auth_workflow import post_user_auth_workflow +from plane.db.models import User +from plane.settings.redis import redis_instance +from plane.utils.path_validator import get_safe_redirect_url + +log = logging.getLogger("plane.authentication.trusted") + +# Audience the bridge sets in JWTs minted for Plane (signBridgeJwt(..., audience: 'plane')). +_EXPECTED_AUDIENCE = "plane" +# Issuer the bridge sets (every adapter shares this). +_EXPECTED_ISSUER = "bb-bridge" +# Replay-store key prefix per bridge-jwt-replay-protection.md. +_JTI_KEY_PREFIX = "bb_bridge_jti:" +# Clock-skew tolerance applied to exp/iat checks. +_CLOCK_SKEW_SECONDS = 30 +# Public-key cache (in-process). Keyed on URL so test/dev with multiple +# bridges per process is safe. _key_cache: {url: (pem, fetched_at_epoch)}. +_KEY_CACHE_TTL_SECONDS = 5 * 60 +_key_cache: dict[str, Tuple[str, float]] = {} + + +def _bridge_public_key_url() -> Optional[str]: + """Returns the configured bridge public-key URL, or None if disabled. + + The endpoint is implicitly disabled (returns 404) when this env is unset — + the regression-safe default for builds shipped without the bridge wired up. + """ + return os.environ.get("BB_BRIDGE_PUBLIC_KEY_URL") or None + + +def _fetch_bridge_public_key(url: str, *, force_refresh: bool = False) -> str: + """Fetch (and cache) the bridge's public key PEM. Refetches on signature + failure or after the cache TTL elapses. Falls back to stale cache if a + refresh fails — temporarily-unreachable bridge shouldn't brick logins.""" + now = time.time() + cached = _key_cache.get(url) + if not force_refresh and cached and (now - cached[1]) < _KEY_CACHE_TTL_SECONDS: + return cached[0] + try: + resp = requests.get(url, timeout=3.0, headers={"accept": "application/x-pem-file"}) + resp.raise_for_status() + pem = resp.text + if "-----BEGIN PUBLIC KEY-----" not in pem: + raise ValueError(f"non-PEM body from {url} (first 80: {pem[:80]!r})") + _key_cache[url] = (pem, now) + return pem + except Exception as exc: + if cached: + log.warning("bridge public-key fetch failed; using stale cache", extra={"url": url, "err": str(exc)}) + return cached[0] + raise + + +def _consume_jti(jti: str, exp_epoch: int) -> Tuple[bool, Optional[str]]: + """Atomically mark a `jti` consumed in shared-redis. Returns (first_use, error_code). + + - (True, None) → not previously consumed; admit the request. + - (False, code) → either already consumed (TRUSTED_JWT_TOKEN_REPLAYED) or + the replay store is unavailable (TRUSTED_JWT_REPLAY_STORE_DOWN). Either + way, REJECT the request (fail closed). + + TTL = (exp - now) + 30s clock-skew tolerance, with a 30s minimum floor for + edge cases where exp is already past at consumption time (signature still + valid under clock-skew tolerance). + """ + if not jti or not exp_epoch: + return False, "TRUSTED_JWT_TOKEN_INVALID" + try: + client = redis_instance() + except Exception as exc: + log.error("replay store init failed", extra={"err": str(exc)}) + return False, "TRUSTED_JWT_REPLAY_STORE_DOWN" + try: + ttl = max(int(exp_epoch - time.time()) + _CLOCK_SKEW_SECONDS, 30) + # SET key value NX EX ttl -- returns True on first-set, None if already set. + ok = client.set(_JTI_KEY_PREFIX + jti, "1", nx=True, ex=ttl) + if ok is None: + return False, "TRUSTED_JWT_TOKEN_REPLAYED" + return True, None + except redis.RedisError as exc: + log.error("replay store SETNX failed", extra={"err": str(exc), "jti": jti}) + return False, "TRUSTED_JWT_REPLAY_STORE_DOWN" + + +def _redirect_with_error(request, error_code: str, error_message: str, next_path: str) -> HttpResponseRedirect: + """Surface the failure as a Plane-style redirect to the host with error params.""" + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES[error_code], + error_message=error_message, + ) + return HttpResponseRedirect( + get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=exc.get_error_dict(), + ) + ) + + +def _verify_with_retry(token: str, public_key_url: str) -> dict: + """Verify the JWT, refetching the bridge key once on signature failure to + transparently handle bridge key rotation. Other verify failures (expired, + wrong issuer/audience, malformed) do NOT trigger a refetch — those are + tampering or clock issues, not key drift.""" + pem = _fetch_bridge_public_key(public_key_url) + try: + return pyjwt.decode( + token, + pem, + algorithms=["RS256"], + audience=_EXPECTED_AUDIENCE, + issuer=_EXPECTED_ISSUER, + leeway=_CLOCK_SKEW_SECONDS, + options={"require": ["exp", "iat", "sub", "email", "jti"]}, + ) + except pyjwt.InvalidSignatureError: + log.warning("trusted-jwt signature failed; refetching bridge key", extra={"url": public_key_url}) + pem = _fetch_bridge_public_key(public_key_url, force_refresh=True) + return pyjwt.decode( + token, + pem, + algorithms=["RS256"], + audience=_EXPECTED_AUDIENCE, + issuer=_EXPECTED_ISSUER, + leeway=_CLOCK_SKEW_SECONDS, + options={"require": ["exp", "iat", "sub", "email", "jti"]}, + ) + + +class TrustedSignInEndpoint(View): + """GET /auth/sign-in-trusted/?token=&next_path= + + The bridge 302s the browser here after a successful oauth2-proxy session + is established. We verify the JWT, claim its `jti` to prevent replay, + find-or-create the User, and call user_login() to set the Django session + cookie. Then 302 the user to next_path on the same host. + """ + + def get(self, request): + public_key_url = _bridge_public_key_url() + if not public_key_url: + # Endpoint disabled — bridge not wired up in this deployment. + return HttpResponseNotFound() + + # Validate next_path on every exit — even error redirects honor it so + # the user lands somewhere sensible. get_safe_redirect_url further + # constrains to the trusted base host. + next_path = request.GET.get("next_path") or "/" + + token = request.GET.get("token") + if not token: + return _redirect_with_error(request, "TRUSTED_JWT_TOKEN_MISSING", "TRUSTED_JWT_TOKEN_MISSING", next_path) + + try: + claims = _verify_with_retry(token, public_key_url) + except pyjwt.ExpiredSignatureError: + return _redirect_with_error(request, "TRUSTED_JWT_TOKEN_EXPIRED", "TRUSTED_JWT_TOKEN_EXPIRED", next_path) + except pyjwt.InvalidTokenError as e: + log.warning("trusted-jwt invalid", extra={"err_class": e.__class__.__name__}) + return _redirect_with_error(request, "TRUSTED_JWT_TOKEN_INVALID", f"TRUSTED_JWT_TOKEN_INVALID: {e.__class__.__name__}", next_path) + except Exception as e: + log.error("trusted-jwt key fetch failed", extra={"err": str(e)}) + return _redirect_with_error(request, "TRUSTED_JWT_KEY_FETCH_FAILED", "TRUSTED_JWT_KEY_FETCH_FAILED", next_path) + + # Replay enforcement — atomic SETNX in shared-redis. Fail closed. + first_use, replay_err = _consume_jti(claims.get("jti", ""), int(claims.get("exp", 0))) + if not first_use: + log.warning( + "trusted-jwt rejected by replay-store", + extra={"jti": claims.get("jti"), "sub": claims.get("sub"), "code": replay_err}, + ) + return _redirect_with_error(request, replay_err or "TRUSTED_JWT_TOKEN_REPLAYED", replay_err or "TRUSTED_JWT_TOKEN_REPLAYED", next_path) + + email = (claims.get("email") or "").strip().lower() + if not email: + return _redirect_with_error(request, "TRUSTED_JWT_TOKEN_INVALID", "TRUSTED_JWT_TOKEN_NO_EMAIL", next_path) + + # Find-or-create. Plane's User model uses email as a unique natural key; + # other OAuth providers do the same lookup via the OauthAdapter base. + # We mirror that behavior here without going through OauthAdapter — this + # endpoint is a NEW entry-point, not a fifth OAuth provider. + user, created = User.objects.get_or_create( + email=email, + defaults={ + "first_name": claims.get("first_name") or claims.get("given_name") or "", + "last_name": claims.get("last_name") or claims.get("family_name") or "", + "is_password_autoset": True, + }, + ) + + # Plane's existing post-auth workflow (default workspace, invitations, etc.) + post_user_auth_workflow(user=user, is_signup=created, request=request) + + # Set Django session cookie via the existing helper. + user_login(request=request, user=user, is_app=True) + + log.info( + "trusted-jwt sign-in", + extra={ + "jti": claims.get("jti"), + "sub": claims.get("sub"), + "email": email, + "tenant": claims.get("tenant"), + "created": created, + }, + ) + + target = next_path or get_redirection_path(user=user) + return HttpResponseRedirect( + get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=target, + params={}, + ) + ) diff --git a/apps/web/core/hooks/oauth/core.tsx b/apps/web/core/hooks/oauth/core.tsx index bfc7d7ba4..1614883fe 100644 --- a/apps/web/core/hooks/oauth/core.tsx +++ b/apps/web/core/hooks/oauth/core.tsx @@ -11,9 +11,8 @@ import { API_BASE_URL } from "@plane/constants"; import type { TOAuthConfigs, TOAuthOption } from "@plane/types"; // assets import giteaLogo from "@/app/assets/logos/gitea-logo.svg?url"; -// binarybeachio fork: swapped GitHub logo imports for our brand logo. Same -// asset for light and dark theme (the orange/teal palette reads on both). -import BinarybeachLogo from "@/app/assets/logos/binarybeach-logo.png?url"; +import GithubLightLogo from "@/app/assets/logos/github-black.png?url"; +import GithubDarkLogo from "@/app/assets/logos/github-dark.svg?url"; import gitlabLogo from "@/app/assets/logos/gitlab-logo.svg?url"; import googleLogo from "@/app/assets/logos/google-logo.svg?url"; // hooks @@ -47,13 +46,16 @@ export const useCoreOAuthConfig = (oauthActionText: string): TOAuthConfigs => { enabled: config?.is_google_enabled, }, { - // binarybeachio fork — this OAuth slot is repurposed as our Zitadel SSO - // entry point (the backend's GitHubOAuthProvider was patched to point at - // Zitadel — see provider/oauth/github.py). Branding is rebranded here; - // backend identifiers (route, env vars, DB provider key) stay "github". id: "github", - text: `${oauthActionText} with BinaryBeach.io`, - icon: Binary Beach, + text: `${oauthActionText} with GitHub`, + icon: ( + GitHub Logo + ), onClick: () => { window.location.assign(`${API_BASE_URL}/auth/github/${next_path ? `?next_path=${next_path}` : ``}`); }, diff --git a/docker-compose.bb-local.yml b/docker-compose.bb-local.yml index f243a43a0..2512cca18 100644 --- a/docker-compose.bb-local.yml +++ b/docker-compose.bb-local.yml @@ -20,13 +20,12 @@ # # Watch logs # docker compose -f docker-compose.bb-local.yml logs -f api worker # -# # Visit http://localhost:8888 — log in with break-glass admin or click -# # "Continue with binarybeach.io" to test the Zitadel OIDC flow +# # Visit http://localhost:8888 — log in with email+password (break-glass-style) +# # or, with BB_BRIDGE_PUBLIC_KEY_URL set, exercise the trusted-JWT endpoint +# # by hand: GET http://localhost:8888/auth/sign-in-trusted/?token= # # Required env (.env.bb-local — gitignored): -# GITHUB_CLIENT_ID= -# GITHUB_CLIENT_SECRET= -# ZITADEL_DOMAIN=auth.binarybeach.io # already defaulted in compose +# BB_BRIDGE_PUBLIC_KEY_URL= # leave unset for vanilla email+password testing # --------------------------------------------------------------------------- x-db-env: &db-env @@ -93,12 +92,12 @@ x-app-env: &app-env API_KEY_RATE_LIMIT: 60/minute MINIO_ENDPOINT_SSL: 0 LIVE_SERVER_SECRET_KEY: bb-local-test-live-secret-do-not-reuse - # === binarybeachio fork: OIDC via Zitadel === - # ZITADEL_DOMAIN being set activates the OIDC code path in our patched - # GitHubOAuthProvider. URLs default to https://${ZITADEL_DOMAIN}/oauth/v2/... - ZITADEL_DOMAIN: ${ZITADEL_DOMAIN:-auth.binarybeach.io} - GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID} - GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET} + # === binarybeachio fork: Bucket-4 trusted-JWT entry-point === + # When BB_BRIDGE_PUBLIC_KEY_URL is set, /auth/sign-in-trusted/ is enabled + # and verifies bridge-issued JWTs against the URL-served PEM. When unset, + # the endpoint returns 404 and Plane behaves like upstream-vanilla. + # See apps/api/plane/authentication/views/app/trusted.py. + BB_BRIDGE_PUBLIC_KEY_URL: ${BB_BRIDGE_PUBLIC_KEY_URL:-} # === binarybeachio session-lifecycle convention (15 min idle, slide-on-activity) === # Canonical: binarybeachio/infrastructure/_shared/.env.session-convention SESSION_COOKIE_AGE: ${SESSION_COOKIE_AGE:-900}