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:
parent
d950222749
commit
712612865d
10 changed files with 490 additions and 232 deletions
|
|
@ -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
3
.gitignore
vendored
|
|
@ -113,3 +113,6 @@ build/
|
|||
.react-router/
|
||||
temp/
|
||||
scripts/
|
||||
|
||||
# binarybeachio: Cloudflare Wrangler local dev cache (when used for *.binarybeach.io DNS work)
|
||||
.wrangler/
|
||||
|
|
|
|||
160
BINARYBEACHIO.md
160
BINARYBEACHIO.md
|
|
@ -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 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.<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`.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
271
apps/api/plane/authentication/views/app/trusted.py
Normal file
271
apps/api/plane/authentication/views/app/trusted.py
Normal 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={},
|
||||
)
|
||||
)
|
||||
|
|
@ -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}` : ``}`);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue