Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 10 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,16 @@ OAUTH_GOOGLE_CLIENT_SECRET=
OAUTH_GITHUB_CLIENT_ID=
OAUTH_GITHUB_CLIENT_SECRET=

# Zitadel (OIDC). ISSUER is your instance base URL, e.g. https://auth.example.com
# (authorize/token/userinfo endpoints are derived from it). Create a WEB app with
# redirect URI {OAUTH_REDIRECT_BASE_URL}/api/v1/auth/oauth/callback/zitadel and
# auth method POST (confidential: set the secret) or PKCE (public: leave it empty).
# For the SSO logout_url returned by /logout, also register {OAUTH_REDIRECT_BASE_URL}/
# as a Post Logout URI in the Zitadel app.
OAUTH_ZITADEL_CLIENT_ID=
OAUTH_ZITADEL_CLIENT_SECRET=
OAUTH_ZITADEL_ISSUER=

# ===================================
# Application Settings
# ===================================
Expand Down
7 changes: 7 additions & 0 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ dev = [
"pytest-xdist[psutil]>=3.8.0",
]

# TEMPORARY local override: install crudauth from the fork branch carrying the
# Zitadel support (GenericOIDCProvider + public-client token exchange) until it
# lands upstream in benavlabs/crudauth and a release is published. Do not ship
# this override in PRs to the upstream boilerplate.
[tool.uv.sources]
crudauth = { git = "https://github.com/carlosplanchon/crudauth", branch = "feature/zitadel-oauth" }

[tool.setuptools.packages.find]
where = ["src"]
include = ["*"]
Expand Down
38 changes: 35 additions & 3 deletions backend/src/infrastructure/auth/oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
the route handlers in ``routes.py``.
"""

from crudauth.oauth import OAuthAccountService, OAuthProviderFactory
from crudauth.oauth import GenericOIDCProvider, OAuthAccountService, OAuthProviderFactory
from crudauth.storage import get_session_storage

from ..config.settings import settings
Expand All @@ -27,11 +27,42 @@ def _build_provider(name: str, client_id: str, client_secret: str):
)


# Only Google has a wired route; add a "github" entry here (and its routes) to enable it.
# Add a provider here (and wire its routes in routes.py) to enable it.
oauth_providers = {
"google": _build_provider("google", settings.OAUTH_GOOGLE_CLIENT_ID, settings.OAUTH_GOOGLE_CLIENT_SECRET),
}

# OIDC RP-initiated logout: provider name -> end_session endpoint. ``/logout``
# uses this to hand the client a ``logout_url`` that also terminates the IdP's
# own SSO session; the ``id_token`` needed as the hint is stashed in the session
# metadata at callback time. The post-logout target must be registered with the
# IdP as a "Post Logout URI".
oauth_end_session_endpoints: dict[str, str] = {}
oauth_post_logout_redirect_uri = f"{_redirect_base}/"

# Zitadel is a generic OIDC provider keyed on an issuer, which the factory's
# create_provider cannot pass, so it is constructed directly. The endpoints are
# Zitadel's standard layout under the issuer (confirm against
# {issuer}/.well-known/openid-configuration; GenericOIDCProvider.from_discovery
# resolves them dynamically instead, but is async and this module builds at
# import time). The secret is a mode switch, not a requirement: set ->
# confidential client (Zitadel app auth method POST); empty -> public client
# (auth method PKCE), where crudauth omits client auth from the token exchange.
if settings.OAUTH_ZITADEL_ISSUER and settings.OAUTH_ZITADEL_CLIENT_ID:
_zitadel_issuer = settings.OAUTH_ZITADEL_ISSUER.rstrip("/")
oauth_providers["zitadel"] = GenericOIDCProvider(
settings.OAUTH_ZITADEL_CLIENT_ID,
settings.OAUTH_ZITADEL_CLIENT_SECRET,
f"{_redirect_base}/api/v1/auth/oauth/callback/zitadel",
scopes=["openid", "profile", "email"],
authorize_endpoint=f"{_zitadel_issuer}/oauth/v2/authorize",
token_endpoint=f"{_zitadel_issuer}/oauth/v2/token",
userinfo_endpoint=f"{_zitadel_issuer}/oidc/v1/userinfo",
provider_name="zitadel",
issuer=_zitadel_issuer,
)
oauth_end_session_endpoints["zitadel"] = f"{_zitadel_issuer}/oidc/v1/end_session"

oauth_state_storage = get_session_storage(
"redis" if _use_redis else "memory",
prefix="oauth_state:",
Expand All @@ -41,5 +72,6 @@ def _build_provider(name: str, client_id: str, client_secret: str):

oauth_account_service = OAuthAccountService(
repo=auth.repo,
new_user_fields=lambda ctx: {"name": ctx.suggested_name},
# suggested_name is the provider's full name, unbounded; User.name is String(30).
new_user_fields=lambda ctx: {"name": ctx.suggested_name[:30]},
)
Loading
Loading