binarybeachio: Bucket-4 trusted-JWT auth — replaces in-place github.py patch

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) <noreply@anthropic.com>
This commit is contained in:
binarybeach 2026-05-03 20:24:48 -10:00
parent d950222749
commit 712612865d
10 changed files with 490 additions and 232 deletions

View file

@ -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-<uuid>: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.

3
.gitignore vendored
View file

@ -113,3 +113,6 @@ build/
.react-router/
temp/
scripts/
# binarybeachio: Cloudflare Wrangler local dev cache (when used for *.binarybeach.io DNS work)
.wrangler/

View file

@ -27,87 +27,94 @@ update/<v> — 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 60006099 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.<tenant>.binarybeach.io/auth/sign-in-trusted/?token=<jwt>&next_path=<rd>`.
- Plane's view: fetches public key from `BB_BRIDGE_PUBLIC_KEY_URL` (cached 5 min), verifies signature + claims, atomically `SETNX bb_bridge_jti:<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 <sha>`.
Decision record at `binarybeachio/docs/features/storage-upload-flow.md`. Patch 2 is independent of Patch 1 — `git revert <storage-PUT sha>` 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=<slug>&...`. 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=<zitadel-app-client-id>
GITHUB_CLIENT_SECRET=<zitadel-app-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-<uuid>: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.<n>` |
| `plane-frontend` (aka web) | YES | `apps/web/Dockerfile.web` | `git.binarybeach.io/binarybeach/plane-frontend:v1.3.0-mine.<n>` |
| `plane-backend` | YES (Patch 1 + Patch 2) | `apps/api/Dockerfile.api` | `git.binarybeach.io/binarybeach/plane-backend:v1.3.0-mine.<n>` |
| `plane-frontend` (aka web) | YES (Patch 2 frontend bits only) | `apps/web/Dockerfile.web` | `git.binarybeach.io/binarybeach/plane-frontend:v1.3.0-mine.<n>` |
| `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: `<upstream-version>-mine.<n>`. 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=<jwt>&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`.

View file

@ -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,
}

View file

@ -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": {

View file

@ -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"),
]

View file

@ -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

View file

@ -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-<uuid>: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:<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=<jwt>&next_path=<rel-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={},
)
)

View file

@ -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: <img src={BinarybeachLogo} height={18} width={18} alt="Binary Beach" />,
text: `${oauthActionText} with GitHub`,
icon: (
<img
src={resolvedTheme === "dark" ? GithubDarkLogo : GithubLightLogo}
height={18}
width={18}
alt="GitHub Logo"
/>
),
onClick: () => {
window.location.assign(`${API_BASE_URL}/auth/github/${next_path ? `?next_path=${next_path}` : ``}`);
},

View file

@ -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=<jwt>
#
# Required env (.env.bb-local — gitignored):
# GITHUB_CLIENT_ID=<zitadel-app-client-id>
# GITHUB_CLIENT_SECRET=<zitadel-app-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}