diff --git a/backend/.env.example b/backend/.env.example index 02f3478e..b4bb6b29 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 # =================================== diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 781878a0..9dde1cfb 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -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 = ["*"] diff --git a/backend/src/infrastructure/auth/oauth.py b/backend/src/infrastructure/auth/oauth.py index f13ae981..6dd0309f 100644 --- a/backend/src/infrastructure/auth/oauth.py +++ b/backend/src/infrastructure/auth/oauth.py @@ -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 @@ -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:", @@ -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]}, ) diff --git a/backend/src/infrastructure/auth/routes.py b/backend/src/infrastructure/auth/routes.py index 86975207..5d8f75c4 100644 --- a/backend/src/infrastructure/auth/routes.py +++ b/backend/src/infrastructure/auth/routes.py @@ -1,4 +1,5 @@ from typing import Annotated, Any +from urllib.parse import urlencode from crudauth import Principal from crudauth.exceptions import UnauthorizedException @@ -11,7 +12,14 @@ from ..dependencies import AsyncSessionDep, OAuth2FormDep from ..logging import get_logger from .dependencies import get_current_principal, get_optional_principal -from .oauth import OAUTH_STATE_TTL_SECONDS, oauth_account_service, oauth_providers, oauth_state_storage +from .oauth import ( + OAUTH_STATE_TTL_SECONDS, + oauth_account_service, + oauth_end_session_endpoints, + oauth_post_logout_redirect_uri, + oauth_providers, + oauth_state_storage, +) from .setup import auth as crud_auth logger = get_logger() @@ -75,9 +83,16 @@ async def login( This endpoint: - Invalidates the active session in the storage backend - Clears all session-related cookies from the client + - For sessions started via an OIDC provider with a known end-session + endpoint (e.g. Zitadel), additionally returns a `logout_url` After logout, the user will need to authenticate again to access protected resources. Any existing session tokens will no longer be valid. + + The local session is always terminated; `logout_url` is optional + extra: navigating the browser there also ends the identity + provider's own SSO session (OIDC RP-initiated logout) and then + redirects back to the registered post-logout URI. """, responses={200: {"description": "Logout successful, session terminated"}, 401: {"description": "Not authenticated"}}, response_description="Confirmation of successful logout", @@ -86,12 +101,33 @@ async def logout( response: Response, principal: Annotated[Principal, Depends(get_current_principal)], ) -> dict[str, str]: - """Logout endpoint to terminate the session and clear cookies (CSRF-protected).""" + """Logout endpoint to terminate the session and clear cookies (CSRF-protected). + + For OIDC sessions (provider present in ``oauth_end_session_endpoints``) the + response also carries ``logout_url`` - the provider's end-session URL with + the stashed ``id_token`` as hint - so the client can terminate the IdP's SSO + session too. The session metadata must be read before revoking, after which + it is gone. + """ session_id = principal.metadata.get("session_id") + logout_url: str | None = None if session_id: + session = await crud_auth.sessions.validate_session(session_id) + if session is not None: + provider = session.metadata.get("oauth_provider") + id_token = session.metadata.get("id_token") + end_session_endpoint = oauth_end_session_endpoints.get(provider) if provider else None + if end_session_endpoint and id_token: + params = { + "id_token_hint": id_token, + "post_logout_redirect_uri": oauth_post_logout_redirect_uri, + } + logout_url = f"{end_session_endpoint}?{urlencode(params)}" await crud_auth.sessions.revoke(session_id, owner_id=principal.user_id) crud_auth.sessions.clear_session_cookies(response) + if logout_url: + return {"message": "Logged out successfully", "logout_url": logout_url} return {"message": "Logged out successfully"} @@ -137,6 +173,142 @@ async def refresh_csrf_token( return {"csrf_token": csrf_token} +async def _initiate_oauth(provider_name: str, redirect_uri: str | None) -> dict[str, str]: + """Build a provider authorization URL and persist its CSRF state + PKCE verifier. + + Shared by every provider's login route; the only per-provider input is the name, + which selects the configured provider and is stamped into the stored state so the + callback can reject a mismatched provider. + """ + provider = oauth_providers.get(provider_name) + if provider is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail=f"OAuth provider '{provider_name}' is not configured" + ) + try: + auth_data = provider.get_authorization_url() + state_obj = OAuthState( + state=auth_data["state"], + provider=provider_name, + redirect_to=redirect_uri, + code_verifier=auth_data.get("code_verifier"), + ) + await oauth_state_storage.create(state_obj, session_id=auth_data["state"], expiration=OAUTH_STATE_TTL_SECONDS) + return {"url": auth_data["url"]} + except Exception as e: + display = provider_name.title() + logger.error(f"Error initiating {display} OAuth: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to initiate {display} login" + ) + + +async def _complete_oauth( + provider_name: str, + request: Request, + response: Response, + db: AsyncSessionDep, + code: str, + state: str, + response_format: str, +): + """Verify callback state, exchange the code, link/create the user, and start a session. + + Shared by every provider's callback route. ``response_format`` switches between a + 302 redirect (browser flow) and a JSON body (mobile/SPA), matching the original + Google handler's two-format contract. + """ + provider = oauth_providers.get(provider_name) + if provider is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail=f"OAuth provider '{provider_name}' is not configured" + ) + + state_data = await oauth_state_storage.get(state, OAuthState) + + if not state_data: + logger.warning(f"Invalid OAuth state in callback: {state}") + if response_format == "json": + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid OAuth state") + return RedirectResponse( + url=f"/login?error=oauth_error&provider={provider_name}&reason=invalid_state", + status_code=status.HTTP_302_FOUND, + ) + + if state_data.provider != provider_name: + logger.warning(f"Provider mismatch in OAuth callback: expected {provider_name}, got {state_data.provider}") + if response_format == "json": + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Provider mismatch") + return RedirectResponse( + url=f"/login?error=oauth_error&provider={provider_name}&reason=provider_mismatch", + status_code=status.HTTP_302_FOUND, + ) + + try: + token_data = await provider.exchange_code(code, code_verifier=state_data.code_verifier) + user_info_raw = await provider.get_user_info(token_data["access_token"]) + user_info = await provider.process_user_info(user_info_raw) + + user, is_new_user = await oauth_account_service.get_or_create_user(user_info, db) + user_id = crud_auth.repo.user_id(user) + username = crud_auth.repo.get(user, "username") + + session_metadata: dict[str, Any] = { + "login_type": "oauth", + "oauth_provider": provider_name, + "username": username, + "is_new_user": is_new_user, + } + # Stash the id_token so /logout can build an OIDC RP-initiated logout URL + # (id_token_hint); only for providers with an end_session endpoint, to + # keep the other sessions lean. + if provider_name in oauth_end_session_endpoints and token_data.get("id_token"): + session_metadata["id_token"] = token_data["id_token"] + + session_id, csrf_token = await crud_auth.sessions.create_session( + request, + user_id=user_id, + metadata=session_metadata, + ) + crud_auth.sessions.set_session_cookies(response, session_id, csrf_token) + + await oauth_state_storage.delete(state) + + if response_format == "json": + return { + "success": True, + "user": { + "id": user_id, + "username": username, + "email": crud_auth.repo.get(user, "email"), + "is_new_user": is_new_user, + }, + "csrf_token": csrf_token, + } + + redirect_to = str(state_data.redirect_to) if state_data.redirect_to else "/" + # The cookies must ride the returned response itself: FastAPI does not + # merge headers set on the injected ``response`` into a directly-returned + # Response, so relying on it silently drops the session on the browser + # (redirect) flow - only the json flow would get the cookies. + redirect = RedirectResponse(url=redirect_to, status_code=status.HTTP_302_FOUND) + crud_auth.sessions.set_session_cookies(redirect, session_id, csrf_token) + return redirect + + except Exception as e: + logger.error(f"Error in {provider_name.title()} OAuth callback: {str(e)}", exc_info=True) + + if response_format == "json": + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"OAuth authentication failed: {str(e)}" + ) + + return RedirectResponse( + url=f"/login?error=oauth_error&provider={provider_name}", + status_code=status.HTTP_302_FOUND, + ) + + @router.get( "/oauth/google", summary="Initiate Google OAuth Login", @@ -166,19 +338,7 @@ async def oauth_google_login( redirect_uri: str | None = Query(None), ) -> dict[str, str]: """Initiate the Google OAuth flow: build the authorization URL and stash state + PKCE.""" - try: - auth_data = oauth_providers["google"].get_authorization_url() - state_obj = OAuthState( - state=auth_data["state"], - provider=OAuthProvider.GOOGLE.value, - redirect_to=redirect_uri, - code_verifier=auth_data.get("code_verifier"), - ) - await oauth_state_storage.create(state_obj, session_id=auth_data["state"], expiration=OAUTH_STATE_TTL_SECONDS) - return {"url": auth_data["url"]} - except Exception as e: - logger.error(f"Error initiating Google OAuth: {str(e)}", exc_info=True) - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to initiate Google login") + return await _initiate_oauth(OAuthProvider.GOOGLE.value, redirect_uri) @router.get( @@ -220,77 +380,67 @@ async def oauth_google_callback( response_format: str = Query("redirect", description="Response format, either 'redirect' or 'json'"), ): """Handle the Google OAuth callback: verify state, link/create the user, start a session.""" - state_data = await oauth_state_storage.get(state, OAuthState) - - if not state_data: - logger.warning(f"Invalid OAuth state in callback: {state}") - if response_format == "json": - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid OAuth state") - return RedirectResponse( - url=f"/login?error=oauth_error&provider={OAuthProvider.GOOGLE.value}&reason=invalid_state", - status_code=status.HTTP_302_FOUND, - ) - - if state_data.provider != OAuthProvider.GOOGLE.value: - logger.warning(f"Provider mismatch in OAuth callback: expected google, got {state_data.provider}") - if response_format == "json": - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Provider mismatch") - return RedirectResponse( - url=f"/login?error=oauth_error&provider={OAuthProvider.GOOGLE.value}&reason=provider_mismatch", - status_code=status.HTTP_302_FOUND, - ) - - try: - provider = oauth_providers["google"] - token_data = await provider.exchange_code(code, code_verifier=state_data.code_verifier) - user_info_raw = await provider.get_user_info(token_data["access_token"]) - user_info = await provider.process_user_info(user_info_raw) - - user, is_new_user = await oauth_account_service.get_or_create_user(user_info, db) - user_id = crud_auth.repo.user_id(user) - username = crud_auth.repo.get(user, "username") + return await _complete_oauth(OAuthProvider.GOOGLE.value, request, response, db, code, state, response_format) - session_id, csrf_token = await crud_auth.sessions.create_session( - request, - user_id=user_id, - metadata={ - "login_type": "oauth", - "oauth_provider": OAuthProvider.GOOGLE.value, - "username": username, - "is_new_user": is_new_user, - }, - ) - crud_auth.sessions.set_session_cookies(response, session_id, csrf_token) - await oauth_state_storage.delete(state) +@router.get( + "/oauth/zitadel", + summary="Initiate Zitadel OAuth Login", + description=""" + Starts the OpenID Connect (OIDC) authentication flow with Zitadel. - if response_format == "json": - return { - "success": True, - "user": { - "id": user_id, - "username": username, - "email": crud_auth.repo.get(user, "email"), - "is_new_user": is_new_user, - }, - "csrf_token": csrf_token, - } + Builds the Zitadel authorization URL (with a CSRF ``state`` and a PKCE + challenge) for the client to redirect to. After the user authenticates, + Zitadel redirects back to this app's callback endpoint. - redirect_to = str(state_data.redirect_to) if state_data.redirect_to else "/" - return RedirectResponse(url=redirect_to, status_code=status.HTTP_302_FOUND) + An optional ``redirect_uri`` controls where the user lands once the whole + flow completes. Returns 404 if Zitadel is not configured. + """, + responses={ + 200: {"description": "Authorization URL generated successfully"}, + 404: {"description": "Zitadel provider is not configured"}, + 500: {"description": "Failed to initiate Zitadel login"}, + }, + response_description="The Zitadel authorization URL to redirect the user to", +) +async def oauth_zitadel_login( + request: Request, + redirect_uri: str | None = Query(None), +) -> dict[str, str]: + """Initiate the Zitadel OIDC flow: build the authorization URL and stash state + PKCE.""" + return await _initiate_oauth(OAuthProvider.ZITADEL.value, redirect_uri) - except Exception as e: - logger.error(f"Error in Google OAuth callback: {str(e)}", exc_info=True) - if response_format == "json": - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"OAuth authentication failed: {str(e)}" - ) +@router.get( + "/oauth/callback/zitadel", + summary="Zitadel OAuth Callback Handler", + description=""" + Processes the OIDC callback from Zitadel. - return RedirectResponse( - url=f"/login?error=oauth_error&provider={OAuthProvider.GOOGLE.value}", - status_code=status.HTTP_302_FOUND, - ) + Validates the ``state`` (CSRF), exchanges the authorization code (PKCE) + for tokens, fetches the userinfo profile, links or creates the user, and + establishes a session. Supports ``redirect`` (default) and ``json`` + response formats, the latter for mobile apps or SPAs. + """, + responses={ + 200: {"description": "Authentication successful (JSON response)"}, + 302: {"description": "Authentication successful (redirect response)"}, + 400: {"description": "Invalid OAuth state or other parameter"}, + 404: {"description": "Zitadel provider is not configured"}, + 500: {"description": "Server error during authentication"}, + }, + response_description="Authentication result with session cookies set", +) +async def oauth_zitadel_callback( + request: Request, + response: Response, + db: AsyncSessionDep, + code: str = Query(...), + state: str = Query(...), + response_format: str = Query("redirect", description="Response format, either 'redirect' or 'json'"), +): + """Handle the Zitadel OIDC callback: verify state, link/create the user, start a session.""" + return await _complete_oauth(OAuthProvider.ZITADEL.value, request, response, db, code, state, response_format) @router.get("/check-auth") diff --git a/backend/src/infrastructure/config/settings.py b/backend/src/infrastructure/config/settings.py index e91a6d3c..47d37edb 100644 --- a/backend/src/infrastructure/config/settings.py +++ b/backend/src/infrastructure/config/settings.py @@ -244,6 +244,13 @@ class AuthSettings(BaseSettings): OAUTH_GOOGLE_CLIENT_SECRET: str = config("OAUTH_GOOGLE_CLIENT_SECRET", default="") OAUTH_GITHUB_CLIENT_ID: str = config("OAUTH_GITHUB_CLIENT_ID", default="") OAUTH_GITHUB_CLIENT_SECRET: str = config("OAUTH_GITHUB_CLIENT_SECRET", default="") + # Zitadel (generic OIDC). ISSUER is the instance base URL; the authorize/token/userinfo + # endpoints are derived from it via OIDC discovery paths in ZitadelOAuthProvider. + # SECRET set -> confidential client (Zitadel app auth method POST); empty -> public + # client (auth method PKCE), where the token exchange omits client authentication. + OAUTH_ZITADEL_CLIENT_ID: str = config("OAUTH_ZITADEL_CLIENT_ID", default="") + OAUTH_ZITADEL_CLIENT_SECRET: str = config("OAUTH_ZITADEL_CLIENT_SECRET", default="") + OAUTH_ZITADEL_ISSUER: str = config("OAUTH_ZITADEL_ISSUER", default="") OAUTH_REDIRECT_BASE_URL: str = config("OAUTH_REDIRECT_BASE_URL", default="http://localhost:8000") diff --git a/backend/src/modules/user/enums.py b/backend/src/modules/user/enums.py index 33b2c660..bb542d39 100644 --- a/backend/src/modules/user/enums.py +++ b/backend/src/modules/user/enums.py @@ -13,3 +13,4 @@ class OAuthProvider(StrEnum): GOOGLE = "google" GITHUB = "github" + ZITADEL = "zitadel" diff --git a/backend/src/modules/user/models.py b/backend/src/modules/user/models.py index 8c0d7eec..89a5c42c 100644 --- a/backend/src/modules/user/models.py +++ b/backend/src/modules/user/models.py @@ -26,7 +26,9 @@ class User(Base, TimestampMixin, SoftDeleteMixin): ) name: Mapped[str] = mapped_column(String(30)) - username: Mapped[str] = mapped_column(String(20), unique=True, index=True) + # 32 = crudauth's OAuth username generator cap (USERNAME_MAX_LENGTH); a narrower + # column rejects OAuth signups whose sanitized preferred_username exceeds it. + username: Mapped[str] = mapped_column(String(32), unique=True, index=True) email: Mapped[str] = mapped_column(String(50), unique=True, index=True) hashed_password: Mapped[str] = mapped_column(String(100)) @@ -43,6 +45,7 @@ class User(Base, TimestampMixin, SoftDeleteMixin): google_id: Mapped[str | None] = mapped_column(String(50), unique=True, index=True, default=None) github_id: Mapped[str | None] = mapped_column(String(50), unique=True, index=True, default=None) + zitadel_id: Mapped[str | None] = mapped_column(String(50), unique=True, index=True, default=None) oauth_provider: Mapped[str | None] = mapped_column(String(20), default=None) email_verified: Mapped[bool] = mapped_column(default=False) oauth_created_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None) diff --git a/backend/src/modules/user/schemas.py b/backend/src/modules/user/schemas.py index 4473cee6..20415d1d 100644 --- a/backend/src/modules/user/schemas.py +++ b/backend/src/modules/user/schemas.py @@ -5,12 +5,20 @@ from ..common.schemas import PersistentDeletion, TimestampSchema +# Kept in one place because every user schema has to agree with the ``username`` +# column (``String(32)``) *and* with what crudauth's OAuth username generator +# emits: it caps at 32 and sanitizes to lowercase alphanumerics plus underscores. +# A narrower rule here does not reject the signup - crudauth writes the row +# directly - it makes the resulting user unreadable through ``UserRead``. +USERNAME_MAX_LENGTH = 32 +USERNAME_PATTERN = r"^[a-z0-9_]+$" + class UserBase(BaseModel): name: Annotated[str, Field(min_length=2, max_length=30, examples=["User Userson"])] username: Annotated[ str, - Field(min_length=2, max_length=20, pattern=r"^[a-z0-9]+$", examples=["userson"]), + Field(min_length=2, max_length=USERNAME_MAX_LENGTH, pattern=USERNAME_PATTERN, examples=["userson"]), ] email: Annotated[EmailStr, Field(examples=["user.userson@example.com"])] @@ -31,6 +39,7 @@ class User(TimestampSchema, UserBase, PersistentDeletion): google_id: str | None = None github_id: str | None = None + zitadel_id: str | None = None oauth_provider: str | None = None email_verified: bool = False oauth_created_at: datetime | None = None @@ -44,7 +53,7 @@ class UserRead(BaseModel): name: Annotated[str, Field(min_length=2, max_length=30, examples=["User Userson"])] username: Annotated[ str, - Field(min_length=2, max_length=20, pattern=r"^[a-z0-9]+$", examples=["userson"]), + Field(min_length=2, max_length=USERNAME_MAX_LENGTH, pattern=USERNAME_PATTERN, examples=["userson"]), ] email: Annotated[EmailStr, Field(examples=["user.userson@example.com"])] profile_image_url: str @@ -72,6 +81,7 @@ class UserCreate(UserBase): ] google_id: str | None = None github_id: str | None = None + zitadel_id: str | None = None oauth_provider: str | None = None email_verified: bool = False oauth_created_at: datetime | None = None @@ -86,6 +96,7 @@ class UserCreateInternal(UserBase): hashed_password: str google_id: str | None = None github_id: str | None = None + zitadel_id: str | None = None oauth_provider: str | None = None email_verified: bool = False oauth_created_at: datetime | None = None @@ -105,8 +116,8 @@ class UserUpdate(BaseModel): str | None, Field( min_length=2, - max_length=20, - pattern=r"^[a-z0-9]+$", + max_length=USERNAME_MAX_LENGTH, + pattern=USERNAME_PATTERN, examples=["userberg"], default=None, ), @@ -122,6 +133,7 @@ class UserUpdate(BaseModel): ] google_id: str | None = None github_id: str | None = None + zitadel_id: str | None = None oauth_provider: str | None = None email_verified: bool | None = None oauth_updated_at: datetime | None = None @@ -163,8 +175,12 @@ class UserAnonymize(BaseModel): profile_image_url: str | None = None tier_id: int | None = None is_superuser: bool = False + # Every provider link has to be listed here, not just nulled at the call site: + # a field absent from this schema cannot be cleared, so the provider account + # would keep resolving to the anonymized row on the next login. google_id: str | None = None github_id: str | None = None + zitadel_id: str | None = None oauth_provider: str | None = None email_verified: bool = False oauth_created_at: datetime | None = None diff --git a/backend/src/modules/user/service.py b/backend/src/modules/user/service.py index 110f872d..99473eac 100644 --- a/backend/src/modules/user/service.py +++ b/backend/src/modules/user/service.py @@ -441,6 +441,7 @@ async def anonymize_user(self, user_id: int, db: AsyncSession) -> None: is_superuser=False, google_id=None, github_id=None, + zitadel_id=None, oauth_provider=None, email_verified=False, oauth_created_at=None, diff --git a/backend/tests/integration/auth/test_endpoints.py b/backend/tests/integration/auth/test_endpoints.py index f423047c..643dcba7 100644 --- a/backend/tests/integration/auth/test_endpoints.py +++ b/backend/tests/integration/auth/test_endpoints.py @@ -340,3 +340,107 @@ async def test_check_auth_user_not_found(client: AsyncClient): assert response.json()["message"] == "User not found" finally: app.dependency_overrides = original_deps + + +@pytest.mark.asyncio +async def test_oauth_callback_redirect_sets_session_cookies(client: AsyncClient): + """The browser (redirect) callback flow must carry the session cookies on the 302. + + FastAPI does not merge headers set on the injected ``response`` into a + directly-returned ``RedirectResponse``, so the cookies must be set on the + redirect itself. The json flow is covered by + ``test_oauth_callback_success_creates_user``; this pins the redirect flow. + """ + valid_state = OAuthState( + state="redirect-state", + provider="google", + redirect_to="/docs", + code_verifier="test-code-verifier", + ) + mock_storage = MagicMock() + mock_storage.get = AsyncMock(return_value=valid_state) + mock_storage.delete = AsyncMock(return_value=None) + + mock_provider = MagicMock() + mock_provider.exchange_code = AsyncMock(return_value={"access_token": "tok"}) + mock_provider.get_user_info = AsyncMock(return_value={}) + mock_provider.process_user_info = AsyncMock( + return_value=OAuthUserInfo( + provider="google", + provider_user_id="google-uid-redirect", + email="redirect_flow@example.com", + email_verified=True, + name="Redirect Flow", + ) + ) + + with ( + patch(f"{ROUTES}.oauth_state_storage", mock_storage), + patch(f"{ROUTES}.oauth_providers", {"google": mock_provider}), + ): + response = await client.get( + "/api/v1/auth/oauth/callback/google", + params={"code": "test-code", "state": "redirect-state"}, + ) + + assert response.status_code == 302 + assert response.headers["location"] == "/docs" + set_cookie = response.headers.get_list("set-cookie") + assert any(c.startswith("session_id=") for c in set_cookie), set_cookie + assert any(c.startswith("csrf_token=") for c in set_cookie), set_cookie + + +@pytest.mark.asyncio +async def test_oidc_logout_returns_end_session_url(client: AsyncClient): + """An OIDC session's logout carries ``logout_url`` (RP-initiated logout). + + The callback stashes the provider's ``id_token`` in the session metadata; + ``/logout`` then returns the end-session URL with ``id_token_hint`` so the + client can also terminate the IdP's own SSO session. Password sessions keep + the old response shape (no ``logout_url``) - covered by + ``test_login_then_logout``. + """ + valid_state = OAuthState( + state="zitadel-state", + provider="zitadel", + redirect_to="/", + code_verifier="test-code-verifier", + ) + mock_storage = MagicMock() + mock_storage.get = AsyncMock(return_value=valid_state) + mock_storage.delete = AsyncMock(return_value=None) + + mock_provider = MagicMock() + mock_provider.exchange_code = AsyncMock(return_value={"access_token": "tok", "id_token": "idtok-abc"}) + mock_provider.get_user_info = AsyncMock(return_value={}) + mock_provider.process_user_info = AsyncMock( + return_value=OAuthUserInfo( + provider="zitadel", + provider_user_id="zitadel-uid-123", + email="oidc_logout@example.com", + email_verified=True, + name="OIDC Logout User", + ) + ) + + end_session = "https://idp.example.com/oidc/v1/end_session" + with ( + patch(f"{ROUTES}.oauth_state_storage", mock_storage), + patch(f"{ROUTES}.oauth_providers", {"zitadel": mock_provider}), + patch(f"{ROUTES}.oauth_end_session_endpoints", {"zitadel": end_session}), + ): + callback = await client.get( + "/api/v1/auth/oauth/callback/zitadel", + params={"code": "test-code", "state": "zitadel-state", "response_format": "json"}, + ) + assert callback.status_code == 200 + csrf_token = callback.json()["csrf_token"] + + logout = await client.post("/api/v1/auth/logout", headers={"X-CSRF-Token": csrf_token}) + + assert logout.status_code == 200 + body = logout.json() + assert body["message"] == "Logged out successfully" + assert body["logout_url"].startswith(f"{end_session}?") + assert "id_token_hint=idtok-abc" in body["logout_url"] + assert "post_logout_redirect_uri=" in body["logout_url"] diff --git a/uv.lock b/uv.lock index b53e0c3d..458431dc 100644 --- a/uv.lock +++ b/uv.lock @@ -536,7 +536,7 @@ wheels = [ [[package]] name = "crudauth" version = "0.6.0" -source = { registry = "https://pypi.org/simple" } +source = { git = "https://github.com/carlosplanchon/crudauth?branch=feature%2Fzitadel-oauth#59f2351eba3963186cb9366ee314a615083ab256" } dependencies = [ { name = "bcrypt" }, { name = "email-validator" }, @@ -546,10 +546,6 @@ dependencies = [ { name = "python-multipart" }, { name = "sqlalchemy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e4/b9/5c698178e4d53e8e113bd35a45eff185ec7316e7775d84ab4a4e306b97f6/crudauth-0.6.0.tar.gz", hash = "sha256:25e6056584a8631b4b032725e405ff05a5513bda19e9c5131a0b8685f465fc2e", size = 103061, upload-time = "2026-06-22T02:46:23.655Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/da/11/535ee9a8f2a21ab302a249f3e37d153b4376e77503b94b9c984f099eb79a/crudauth-0.6.0-py3-none-any.whl", hash = "sha256:9951cdbd8d7c8bee33b1b789349fb565718118752dfd24779614ce2b0d5e4bf0", size = 139738, upload-time = "2026-06-22T02:46:24.869Z" }, -] [package.optional-dependencies] all = [ @@ -689,7 +685,7 @@ requires-dist = [ { name = "aiosqlite", specifier = ">=0.21.0" }, { name = "alembic", specifier = ">=1.16.4" }, { name = "asyncpg", specifier = ">=0.30.0" }, - { name = "crudauth", extras = ["all"], specifier = ">=0.6.0,<0.7.0" }, + { name = "crudauth", extras = ["all"], git = "https://github.com/carlosplanchon/crudauth?branch=feature%2Fzitadel-oauth" }, { name = "faker", specifier = ">=37.1.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.115.8" }, { name = "fastcrud", specifier = ">=0.21.0" },