Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
e185f5c
feat(auth): mPass SSO via oauth2-proxy ForwardAuth — backend + frontend
awais786 Apr 9, 2026
22e1047
fix(auth): address PR review — remove debug log, fix rd URL, gate SSO…
awais786 Apr 9, 2026
2a208d9
fix(auth): split comma-separated string in _coerce_bypass_paths
awais786 Apr 9, 2026
5f5517b
fix(auth): SSO-aware signout + unit tests for SignOutAuthEndpoint
awais786 Apr 9, 2026
56d8ddb
fix(auth): replace Django form-post signOut with oauth2-proxy sign_out
awais786 Apr 9, 2026
ccf4831
refactor(auth): comment out native auth routes for mPass SSO
awais786 Apr 10, 2026
a1842f3
chore: remove ghcr-sso CI workflow
awais786 Apr 10, 2026
6671935
fix(auth): review fixes — restore Django session teardown on signOut …
awais786 Apr 10, 2026
0057102
fix: adding logout fix.
awais786 Apr 10, 2026
1ba3ab1
fix(sso): redirect logout to platform landing page instead of app origin
awais786 Apr 12, 2026
8e28b87
chore: remove cognito spec docs — code is source of truth
awais786 Apr 13, 2026
1a3f733
ci: gate PR checks on foss-main and disable copyright check
awais786 Apr 14, 2026
e7d423b
fix(ci): unblock ruff, codespell, and unit tests
awais786 Apr 14, 2026
c76fd06
chore(propel): fix oxfmt class ordering in button helper
awais786 Apr 14, 2026
3a5d5b2
ci: disable check:types job
awais786 Apr 14, 2026
f1339c9
feat(auth): synthesize email from username when no email claim
awais786 Apr 14, 2026
9896a4f
feat(auth): set Plane username from SSO email local part
awais786 Apr 14, 2026
c98a85c
refactor(auth): drop username-collision fallback
awais786 Apr 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .codespellrc
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[codespell]
# Ref: https://github.com/codespell-project/codespell#using-a-config-file
skip = .git*,*.svg,i18n,*-lock.yaml,*.css,.codespellrc,migrations,*.js,*.map,*.mjs
skip = .git*,*.svg,i18n,*-lock.yaml,*.css,.codespellrc,migrations,*.js,*.map,*.mjs,*/tlds.ts
check-hidden = true
# ignore all CamelCase and camelCase
ignore-regex = \b[A-Za-z][a-z]+[A-Z][a-zA-Z]+\b
ignore-words-list = tread
ignore-words-list = tread,nd,donot
4 changes: 2 additions & 2 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ name: "CodeQL"
on:
workflow_dispatch:
push:
branches: ["preview", "canary", "master"]
branches: ["foss-main"]
pull_request:
branches: ["preview", "canary", "master"]
branches: ["foss-main"]

jobs:
analyze:
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/codespell.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ name: Codespell

on:
push:
branches: [preview]
branches: [foss-main]
pull_request:
branches: [preview]
branches: [foss-main]

permissions:
contents: read
Expand Down
9 changes: 0 additions & 9 deletions .github/workflows/copyright-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,6 @@ name: Copy Right Check

on:
workflow_dispatch:
pull_request:
branches:
- "preview"
types:
- "opened"
- "synchronize"
- "ready_for_review"
- "review_requested"
- "reopened"

jobs:
license-check:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/pull-request-build-lint-api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ on:
workflow_dispatch:
pull_request:
branches:
- "preview"
- "foss-main"
types:
- "opened"
- "synchronize"
Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/pull-request-build-lint-web-apps.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ on:
workflow_dispatch:
pull_request:
branches:
- "preview"
- "foss-main"
types:
- "opened"
- "synchronize"
Expand Down Expand Up @@ -162,6 +162,9 @@ jobs:
# Type check depends on build artifacts
check-types:
name: check:types
# Disabled: upstream apps/space imports non-existent CoreRootStore.
# Runtime unaffected (type-only imports). Re-enable once upstream fixes.
if: false
runs-on: ubuntu-latest
needs: build
timeout-minutes: 15
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,6 @@ build/
.react-router/
temp/
scripts/

# SSO design specs (implemented, code is source of truth)
docs/cognito_*.md
6 changes: 6 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,9 @@ MINIO_ENDPOINT_SSL=0

# API key rate limit
API_KEY_RATE_LIMIT="60/minute"

# mPass proxy auth
# Optional: comma-separated paths to skip proxy auth (default: /god-mode,/api/instances)
# MPASS_BYPASS_PATHS=/god-mode,/api/instances
# Required for 3-layer logout (Django → oauth2-proxy → Cognito)
# MPASS_SIGNOUT_URL=https://foss-auth.local.moneta.dev/oauth2/sign_out?rd=https%3A%2F%2Fcognito.example.com%2Flogout
112 changes: 112 additions & 0 deletions apps/api/plane/authentication/middleware/proxy_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.

from uuid import uuid4

from django.conf import settings
from django.contrib.auth.hashers import make_password
from django.db import IntegrityError

from plane.authentication.middleware.proxy_auth_utils import (
_coerce_bypass_paths,
_is_bypass_path,
_normalise_email,
)
from plane.authentication.utils.login import user_login
from plane.db.models import Profile, User

# Security note: X-Auth-Request-* header spoofing is not a concern because the
# backend port is not exposed outside the internal Docker network. All traffic
# must pass through Traefik, which calls oauth2-proxy ForwardAuth and overwrites
# these headers before forwarding to the app. If the backend port is ever
# exposed directly (e.g. for debugging), remove it before deploying to
# production — a client with direct access could spoof X-Auth-Request-Email
# and impersonate any account.

_NEW_USER_FLAGS = {
"is_password_autoset": True,
"is_email_verified": True,
}


class ProxyAuthMiddleware:
"""
Django middleware for mPass proxy authentication.

oauth2-proxy sets X-Auth-Request-Email and X-Auth-Request-User on every
request that has passed OIDC validation. This middleware reads those
headers, finds or creates the corresponding Plane user, and establishes a
native Django session — so the rest of the app sees a fully authenticated
request.user just as it would after a normal login.

To disable, remove this class from the MIDDLEWARE list in settings.
"""

def __init__(self, get_response):
self.get_response = get_response
self.bypass_paths = _coerce_bypass_paths(
getattr(settings, "MPASS_BYPASS_PATHS", None)
)

def __call__(self, request):
# Layer 2 session already valid — nothing to do.
if request.user.is_authenticated:
return self.get_response(request)

# Bypass paths use their own auth (god-mode local login, instance admin).
# TODO(mpass): Keep OPTIONS bypass at the proxy layer; add an app-level
# fallback here only if preflight routing becomes inconsistent.
if _is_bypass_path(request.path, self.bypass_paths):
return self.get_response(request)

email = (request.META.get("HTTP_X_AUTH_REQUEST_EMAIL") or "").strip()
if email and "@" not in email:
# Header holds a bare username (user_id_claim=cognito:username). Synth email.
domain = getattr(settings, "SMB_NAME", "")
email = f"{email}@{domain}.com" if domain else ""
if not email:
username = (request.META.get("HTTP_X_AUTH_REQUEST_USER") or "").strip()
domain = getattr(settings, "SMB_NAME", "")
if username and domain:
email = f"{username}@{domain}.com"
if not email:
return self.get_response(request)

email = _normalise_email(email)
if not email:
return self.get_response(request)

user = self._resolve_user(email)

# Respect deactivated accounts — mPass authentication does not
# override an explicit Plane account suspension.
if not user.is_active:
return self.get_response(request)

user_login(request=request, user=user, is_app=True)
return self.get_response(request)

def _resolve_user(self, email):
username_hint = email.split("@")[0] or uuid4().hex
try:
user, created = User.objects.get_or_create(
email=email,
defaults={
"username": username_hint,
"password": make_password(None),
**_NEW_USER_FLAGS,
},
)
except IntegrityError:
# Concurrent email insert race — fall back to get().
try:
user = User.objects.get(email=email)
except User.DoesNotExist:
raise
created = False

if created:
Profile.objects.get_or_create(user=user)

return user
22 changes: 22 additions & 0 deletions apps/api/plane/authentication/middleware/proxy_auth_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.

_DEFAULT_BYPASS_PATHS = ["/god-mode", "/api/instances"]


def _normalise_email(email: str) -> str:
return email.strip().lower()


def _is_bypass_path(path: str, bypass_paths: list) -> bool:
return any(path == p or path.startswith(p.rstrip("/") + "/") for p in bypass_paths)


def _coerce_bypass_paths(setting) -> list:
if not setting:
return list(_DEFAULT_BYPASS_PATHS)
if isinstance(setting, str):
paths = [p.strip() for p in setting.split(",") if p.strip()]
return paths if paths else list(_DEFAULT_BYPASS_PATHS)
return list(setting)
3 changes: 3 additions & 0 deletions apps/api/plane/authentication/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
Loading
Loading