diff --git a/.env.demo b/.env.demo index 59f17f94..92daffd8 100644 --- a/.env.demo +++ b/.env.demo @@ -175,6 +175,33 @@ AUTHENTIK_CLIENT_SECRET= AUTHENTIK_CONFIG_URL= OAUTH_PROVIDER_NAME="Authentik SSO" +# **Social Login Providers** +# Enable one or more social login providers to let users sign in with existing accounts. +# Each provider requires separate OAuth credentials. See docs/SocialLoginSetup.md for details. + +# Google Sign-In (https://console.cloud.google.com/apis/credentials) +# SOCIAL_AUTH_GOOGLE_ENABLED=false +# SOCIAL_AUTH_GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com +# SOCIAL_AUTH_GOOGLE_CLIENT_SECRET=your-google-client-secret + +# Microsoft Sign-In / Azure AD (https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps) +# SOCIAL_AUTH_MICROSOFT_ENABLED=false +# SOCIAL_AUTH_MICROSOFT_CLIENT_ID=your-microsoft-application-id +# SOCIAL_AUTH_MICROSOFT_CLIENT_SECRET=your-microsoft-client-secret +# SOCIAL_AUTH_MICROSOFT_TENANT=common # common | organizations | consumers | + +# Apple Sign-In (https://developer.apple.com/account/resources) +# SOCIAL_AUTH_APPLE_ENABLED=false +# SOCIAL_AUTH_APPLE_CLIENT_ID=com.example.docuelevate +# SOCIAL_AUTH_APPLE_TEAM_ID=ABCDE12345 +# SOCIAL_AUTH_APPLE_KEY_ID=FGHIJ67890 +# SOCIAL_AUTH_APPLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----" + +# Dropbox Sign-In (https://www.dropbox.com/developers/apps) +# SOCIAL_AUTH_DROPBOX_ENABLED=false +# SOCIAL_AUTH_DROPBOX_CLIENT_ID=your-dropbox-app-key +# SOCIAL_AUTH_DROPBOX_CLIENT_SECRET=your-dropbox-app-secret + # **AI/ML Services** # Select your AI provider: openai | azure | anthropic | gemini | ollama | openrouter | portkey | litellm AI_PROVIDER=openai diff --git a/app/auth.py b/app/auth.py index 8811df94..aba42fb5 100644 --- a/app/auth.py +++ b/app/auth.py @@ -38,6 +38,9 @@ templates = Jinja2Templates(directory=str(templates_dir)) OAUTH_CONFIGURED = False OAUTH_PROVIDER_NAME = "Single Sign-On" +# Social login providers that are enabled and registered +SOCIAL_PROVIDERS: dict[str, dict[str, str]] = {} + if AUTH_ENABLED and settings.authentik_client_id and settings.authentik_client_secret: oauth.register( name="authentik", @@ -49,6 +52,68 @@ if AUTH_ENABLED and settings.authentik_client_id and settings.authentik_client_s OAUTH_CONFIGURED = True OAUTH_PROVIDER_NAME = settings.oauth_provider_name or "Authentik SSO" +# --- Social Login Providers --------------------------------------------------- +if AUTH_ENABLED and settings.social_auth_google_enabled: + if settings.social_auth_google_client_id and settings.social_auth_google_client_secret: + oauth.register( + name="google", + client_id=settings.social_auth_google_client_id, + client_secret=settings.social_auth_google_client_secret, + server_metadata_url="https://accounts.google.com/.well-known/openid-configuration", + client_kwargs={"scope": "openid profile email"}, + ) + SOCIAL_PROVIDERS["google"] = {"name": "Google", "icon": "fab fa-google", "color": "red"} + logger.info("Social login provider registered: Google") + else: + logger.warning("SOCIAL_AUTH_GOOGLE_ENABLED=true but client ID/secret not configured") + +if AUTH_ENABLED and settings.social_auth_microsoft_enabled: + if settings.social_auth_microsoft_client_id and settings.social_auth_microsoft_client_secret: + tenant = settings.social_auth_microsoft_tenant or "common" + oauth.register( + name="microsoft", + client_id=settings.social_auth_microsoft_client_id, + client_secret=settings.social_auth_microsoft_client_secret, + server_metadata_url=f"https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration", + client_kwargs={"scope": "openid profile email"}, + ) + SOCIAL_PROVIDERS["microsoft"] = {"name": "Microsoft", "icon": "fab fa-microsoft", "color": "blue"} + logger.info("Social login provider registered: Microsoft (tenant=%s)", tenant) + else: + logger.warning("SOCIAL_AUTH_MICROSOFT_ENABLED=true but client ID/secret not configured") + +if AUTH_ENABLED and settings.social_auth_apple_enabled: + if settings.social_auth_apple_client_id and settings.social_auth_apple_team_id: + oauth.register( + name="apple", + client_id=settings.social_auth_apple_client_id, + server_metadata_url="https://appleid.apple.com/.well-known/openid-configuration", + client_kwargs={ + "scope": "openid name email", + "response_mode": "form_post", + }, + ) + SOCIAL_PROVIDERS["apple"] = {"name": "Apple", "icon": "fab fa-apple", "color": "gray"} + logger.info("Social login provider registered: Apple") + else: + logger.warning("SOCIAL_AUTH_APPLE_ENABLED=true but client ID/team ID not configured") + +if AUTH_ENABLED and settings.social_auth_dropbox_enabled: + if settings.social_auth_dropbox_client_id and settings.social_auth_dropbox_client_secret: + oauth.register( + name="dropbox", + client_id=settings.social_auth_dropbox_client_id, + client_secret=settings.social_auth_dropbox_client_secret, + authorize_url="https://www.dropbox.com/oauth2/authorize", + access_token_url="https://api.dropboxapi.com/oauth2/token", + userinfo_endpoint="https://api.dropboxapi.com/2/users/get_current_account", + client_kwargs={"token_endpoint_auth_method": "client_secret_post"}, + ) + SOCIAL_PROVIDERS["dropbox"] = {"name": "Dropbox", "icon": "fab fa-dropbox", "color": "blue"} + logger.info("Social login provider registered: Dropbox") + else: + logger.warning("SOCIAL_AUTH_DROPBOX_ENABLED=true but client ID/secret not configured") + router = APIRouter() @@ -194,6 +259,7 @@ async def login(request: Request): "message": request.query_params.get("message"), "show_oauth": OAUTH_CONFIGURED, "oauth_provider_name": OAUTH_PROVIDER_NAME, + "social_providers": SOCIAL_PROVIDERS, "app_version": settings.version, "csrf_token": getattr(request.state, "csrf_token", ""), # "Create account" link is only shown when multi-user mode AND local signup are both enabled @@ -211,6 +277,149 @@ async def oauth_login(request: Request): return await oauth.authentik.authorize_redirect(request, redirect_uri) +async def social_login(request: Request, provider: str): + """Initiate a social login flow for the given provider. + + Args: + request: The current FastAPI request. + provider: One of the registered social provider keys (google, microsoft, apple, dropbox). + + Returns: + A redirect to the provider's authorization page, or back to /login on error. + """ + if provider not in SOCIAL_PROVIDERS: + return RedirectResponse(url="/login?error=Unknown+social+provider", status_code=status.HTTP_302_FOUND) + + redirect_uri = request.url_for("social_callback", provider=provider) + oauth_client = getattr(oauth, provider, None) + if oauth_client is None: + return RedirectResponse(url="/login?error=Provider+not+configured", status_code=status.HTTP_302_FOUND) + + return await oauth_client.authorize_redirect(request, redirect_uri) + + +def _normalize_social_userinfo(provider: str, token: dict, raw_userinfo: dict | None) -> dict: + """Normalize the userinfo payload from different social providers into a common format. + + Returns a dict with keys: sub, email, name, preferred_username, picture. + + Args: + provider: The social provider key (google, microsoft, apple, dropbox). + token: The OAuth token response from the provider. Included for future + provider-specific claim extraction (e.g. ``id_token`` claims). + raw_userinfo: The raw userinfo dict (may be None for providers without standard OIDC userinfo). + + Returns: + A normalized user-data dict compatible with the session user format. + """ + userinfo: dict = raw_userinfo or {} + + if provider == "dropbox": + # Dropbox returns a non-standard userinfo response + email = userinfo.get("email", "") + name_info = userinfo.get("name", {}) + display_name = name_info.get("display_name", "") if isinstance(name_info, dict) else str(name_info) + return { + "sub": userinfo.get("account_id", email), + "email": email, + "name": display_name, + "preferred_username": email, + "picture": userinfo.get("profile_photo_url", ""), + } + + # Standard OIDC providers (Google, Microsoft, Apple) + return { + "sub": userinfo.get("sub", ""), + "email": userinfo.get("email", ""), + "name": userinfo.get("name", ""), + "preferred_username": userinfo.get("email", ""), + "picture": userinfo.get("picture", ""), + } + + +async def social_callback(request: Request, provider: str, db: Session = Depends(get_db)): + """Handle the OAuth callback from a social login provider. + + After the user authorizes with the social provider, this endpoint exchanges + the authorization code for tokens, extracts user information, creates or + updates the user profile, and establishes a session. + + Args: + request: The current FastAPI request. + provider: One of the registered social provider keys. + db: Database session (injected). + + Returns: + A redirect to the user's original destination or the upload page. + """ + if provider not in SOCIAL_PROVIDERS: + return RedirectResponse(url="/login?error=Unknown+social+provider", status_code=status.HTTP_302_FOUND) + + oauth_client = getattr(oauth, provider, None) + if oauth_client is None: + return RedirectResponse(url="/login?error=Provider+not+configured", status_code=status.HTTP_302_FOUND) + + try: + token = await oauth_client.authorize_access_token(request) + + # Try standard OIDC userinfo first, fall back to token-embedded userinfo + raw_userinfo = token.get("userinfo") + if not raw_userinfo: + try: + resp = await oauth_client.userinfo(token=token) + raw_userinfo = resp if isinstance(resp, dict) else resp.json() if hasattr(resp, "json") else {} + except Exception: + raw_userinfo = {} + + user_data = _normalize_social_userinfo(provider, token, raw_userinfo) + + if not user_data.get("email"): + return RedirectResponse( + url="/login?error=Could+not+retrieve+email+from+provider", + status_code=status.HTTP_302_FOUND, + ) + + # Add Gravatar if no picture provided + if not user_data.get("picture") and user_data.get("email"): + user_data["picture"] = get_gravatar_url(user_data["email"]) + + # Tag the login source for audit/debugging + user_data["auth_provider"] = provider + + # Social login users are never admin by default (admin must be granted + # via the Authentik/OIDC admin group or manually in the admin panel) + user_data["is_admin"] = False + + request.session["user"] = user_data + + # Auto-create or update UserProfile + _ensure_user_profile(db, user_data, is_admin=False) + + provider_name = SOCIAL_PROVIDERS[provider]["name"] + logger.info( + "[SECURITY] SOCIAL_LOGIN_SUCCESS provider=%s user=%s", provider_name, user_data.get("email", "unknown") + ) + + # Redirect first-time users to onboarding + user_id = ( + user_data.get("sub") or user_data.get("preferred_username") or user_data.get("email") or user_data.get("id") + ) + if user_id: + profile = db.query(_UserProfile).filter(_UserProfile.user_id == user_id).first() + if profile and not profile.onboarding_completed: + post_onboarding = request.session.pop("redirect_after_login", "/upload") + request.session["post_onboarding_redirect"] = post_onboarding + return RedirectResponse(url="/onboarding", status_code=status.HTTP_302_FOUND) + + redirect_url = request.session.pop("redirect_after_login", "/upload") + return RedirectResponse(url=redirect_url, status_code=status.HTTP_302_FOUND) + except Exception as e: + logger.warning("[SECURITY] SOCIAL_LOGIN_FAILURE provider=%s error=%s", provider, type(e).__name__) + return RedirectResponse( + url="/login?error=Social+login+failed.+Please+try+again.", status_code=status.HTTP_302_FOUND + ) + + def _ensure_user_profile(db: Session, user_data: dict, is_admin: bool = False) -> None: """Create or update a UserProfile row for *user_data*. @@ -503,6 +712,8 @@ if AUTH_ENABLED: router.add_api_route("/login", login, methods=["GET"]) router.add_api_route("/oauth-login", oauth_login, methods=["GET"]) router.add_api_route("/oauth-callback", oauth_callback, methods=["GET"]) + router.add_api_route("/social-login/{provider}", social_login, methods=["GET"]) + router.add_api_route("/social-callback/{provider}", social_callback, methods=["GET"]) router.add_api_route("/auth", auth, methods=["POST"]) router.add_api_route("/logout", logout, methods=["GET"]) diff --git a/app/config.py b/app/config.py index 7ffd15e1..d82143a1 100644 --- a/app/config.py +++ b/app/config.py @@ -166,12 +166,44 @@ class Settings(BaseSettings): ), ) - # Authentik + # Authentik / Generic OIDC authentik_client_id: Optional[str] = None authentik_client_secret: Optional[str] = None authentik_config_url: Optional[str] = None oauth_provider_name: Optional[str] = None # Name to display for the OAuth provider + # Social Login Providers + # Google OAuth2 + social_auth_google_enabled: bool = False + social_auth_google_client_id: Optional[str] = None + social_auth_google_client_secret: Optional[str] = None + + # Microsoft OAuth2 (Azure AD / Microsoft Entra ID) + social_auth_microsoft_enabled: bool = False + social_auth_microsoft_client_id: Optional[str] = None + social_auth_microsoft_client_secret: Optional[str] = None + social_auth_microsoft_tenant: str = Field( + default="common", + description=( + "Azure AD tenant ID or one of 'common', 'organizations', 'consumers'. " + "Use 'common' to allow any Microsoft account and any Azure AD org. " + "Use a specific tenant ID (GUID) to restrict to a single organization. " + "Default: common." + ), + ) + + # Apple Sign-In + social_auth_apple_enabled: bool = False + social_auth_apple_client_id: Optional[str] = None + social_auth_apple_team_id: Optional[str] = None + social_auth_apple_key_id: Optional[str] = None + social_auth_apple_private_key: Optional[str] = None + + # Dropbox OAuth2 + social_auth_dropbox_enabled: bool = False + social_auth_dropbox_client_id: Optional[str] = None + social_auth_dropbox_client_secret: Optional[str] = None + # Local user signup allow_local_signup: bool = Field( default=False, diff --git a/app/utils/config_validator/validators.py b/app/utils/config_validator/validators.py index 36fa02e6..6ef8d52b 100644 --- a/app/utils/config_validator/validators.py +++ b/app/utils/config_validator/validators.py @@ -59,13 +59,43 @@ def validate_auth_config() -> list[str]: and getattr(settings, "authentik_config_url", None) ) - if not using_simple_auth and not using_oidc: - issues.append("Neither simple authentication nor OIDC are properly configured") + # Check if any social login provider is enabled + using_social_login = any( + getattr(settings, f"social_auth_{p}_enabled", False) for p in ("google", "microsoft", "apple", "dropbox") + ) + + if not using_simple_auth and not using_oidc and not using_social_login: + issues.append("Neither simple authentication, OIDC, nor social login are properly configured") # If using OIDC, check for provider name if using_oidc and not getattr(settings, "oauth_provider_name", None): issues.append("OAUTH_PROVIDER_NAME is not configured but OIDC is enabled") + # Validate individual social login provider configs + if getattr(settings, "social_auth_google_enabled", False): + if not getattr(settings, "social_auth_google_client_id", None): + issues.append("SOCIAL_AUTH_GOOGLE_CLIENT_ID is required when Google login is enabled") + if not getattr(settings, "social_auth_google_client_secret", None): + issues.append("SOCIAL_AUTH_GOOGLE_CLIENT_SECRET is required when Google login is enabled") + + if getattr(settings, "social_auth_microsoft_enabled", False): + if not getattr(settings, "social_auth_microsoft_client_id", None): + issues.append("SOCIAL_AUTH_MICROSOFT_CLIENT_ID is required when Microsoft login is enabled") + if not getattr(settings, "social_auth_microsoft_client_secret", None): + issues.append("SOCIAL_AUTH_MICROSOFT_CLIENT_SECRET is required when Microsoft login is enabled") + + if getattr(settings, "social_auth_apple_enabled", False): + if not getattr(settings, "social_auth_apple_client_id", None): + issues.append("SOCIAL_AUTH_APPLE_CLIENT_ID is required when Apple login is enabled") + if not getattr(settings, "social_auth_apple_team_id", None): + issues.append("SOCIAL_AUTH_APPLE_TEAM_ID is required when Apple login is enabled") + + if getattr(settings, "social_auth_dropbox_enabled", False): + if not getattr(settings, "social_auth_dropbox_client_id", None): + issues.append("SOCIAL_AUTH_DROPBOX_CLIENT_ID is required when Dropbox login is enabled") + if not getattr(settings, "social_auth_dropbox_client_secret", None): + issues.append("SOCIAL_AUTH_DROPBOX_CLIENT_SECRET is required when Dropbox login is enabled") + return issues diff --git a/app/utils/settings_service.py b/app/utils/settings_service.py index 78188b9e..956045b6 100644 --- a/app/utils/settings_service.py +++ b/app/utils/settings_service.py @@ -182,6 +182,153 @@ SETTING_METADATA = { "required": False, "restart_required": True, }, + # Social Login Providers + "social_auth_google_enabled": { + "category": "Social Login", + "description": ( + "Enable Google Sign-In. Requires SOCIAL_AUTH_GOOGLE_CLIENT_ID and " + "SOCIAL_AUTH_GOOGLE_CLIENT_SECRET from the Google Cloud Console." + ), + "type": "boolean", + "sensitive": False, + "required": False, + "restart_required": True, + "help_link": "https://console.cloud.google.com/apis/credentials", + "help_link_label": "Google Cloud Console", + }, + "social_auth_google_client_id": { + "category": "Social Login", + "description": "Google OAuth2 client ID from the Google Cloud Console.", + "type": "string", + "sensitive": False, + "required": False, + "restart_required": True, + }, + "social_auth_google_client_secret": { + "category": "Social Login", + "description": "Google OAuth2 client secret from the Google Cloud Console.", + "type": "string", + "sensitive": True, + "required": False, + "restart_required": True, + }, + "social_auth_microsoft_enabled": { + "category": "Social Login", + "description": ( + "Enable Microsoft Sign-In (Azure AD / Microsoft Entra ID). Requires " + "SOCIAL_AUTH_MICROSOFT_CLIENT_ID and SOCIAL_AUTH_MICROSOFT_CLIENT_SECRET " + "from Azure App Registrations." + ), + "type": "boolean", + "sensitive": False, + "required": False, + "restart_required": True, + "help_link": "https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade", + "help_link_label": "Azure Portal", + }, + "social_auth_microsoft_client_id": { + "category": "Social Login", + "description": "Microsoft OAuth2 application (client) ID from Azure App Registrations.", + "type": "string", + "sensitive": False, + "required": False, + "restart_required": True, + }, + "social_auth_microsoft_client_secret": { + "category": "Social Login", + "description": "Microsoft OAuth2 client secret from Azure App Registrations.", + "type": "string", + "sensitive": True, + "required": False, + "restart_required": True, + }, + "social_auth_microsoft_tenant": { + "category": "Social Login", + "description": ( + "Azure AD tenant ID or one of 'common', 'organizations', 'consumers'. " + "Use 'common' to allow any Microsoft account. Use a specific GUID to " + "restrict to a single organization." + ), + "type": "string", + "sensitive": False, + "required": False, + "restart_required": True, + }, + "social_auth_apple_enabled": { + "category": "Social Login", + "description": ( + "Enable Sign in with Apple. Requires an Apple Developer account with " + "a Services ID configured for Sign in with Apple." + ), + "type": "boolean", + "sensitive": False, + "required": False, + "restart_required": True, + "help_link": "https://developer.apple.com/account/resources/identifiers/list/serviceId", + "help_link_label": "Apple Developer Portal", + }, + "social_auth_apple_client_id": { + "category": "Social Login", + "description": "Apple Services ID (e.g. com.example.docuelevate).", + "type": "string", + "sensitive": False, + "required": False, + "restart_required": True, + }, + "social_auth_apple_team_id": { + "category": "Social Login", + "description": "Apple Developer Team ID (10-character alphanumeric string).", + "type": "string", + "sensitive": False, + "required": False, + "restart_required": True, + }, + "social_auth_apple_key_id": { + "category": "Social Login", + "description": "Apple Sign-In private key ID from the Apple Developer Portal.", + "type": "string", + "sensitive": False, + "required": False, + "restart_required": True, + }, + "social_auth_apple_private_key": { + "category": "Social Login", + "description": ( + "Apple Sign-In private key (PEM format). Generate this in the Apple Developer Portal. " + "Paste the entire key content including BEGIN/END headers." + ), + "type": "string", + "sensitive": True, + "required": False, + "restart_required": True, + }, + "social_auth_dropbox_enabled": { + "category": "Social Login", + "description": ( + "Enable Dropbox Sign-In. Uses the same Dropbox App you may already have " + "configured for storage, or a separate one." + ), + "type": "boolean", + "sensitive": False, + "required": False, + "restart_required": True, + }, + "social_auth_dropbox_client_id": { + "category": "Social Login", + "description": "Dropbox OAuth2 App Key from the Dropbox App Console.", + "type": "string", + "sensitive": False, + "required": False, + "restart_required": True, + }, + "social_auth_dropbox_client_secret": { + "category": "Social Login", + "description": "Dropbox OAuth2 App Secret from the Dropbox App Console.", + "type": "string", + "sensitive": True, + "required": False, + "restart_required": True, + }, # AI Services "openai_api_key": { "category": "AI Services", diff --git a/docs/AuthenticationSetup.md b/docs/AuthenticationSetup.md index 5cc9a7b4..320e6191 100644 --- a/docs/AuthenticationSetup.md +++ b/docs/AuthenticationSetup.md @@ -20,10 +20,11 @@ For a complete list of configuration options, see the [Configuration Guide](Conf ## Authentication Methods -DocuElevate supports two primary authentication methods: +DocuElevate supports multiple authentication methods that can be used independently or together: 1. **Simple Authentication** - Basic username/password authentication managed by DocuElevate 2. **OpenID Connect** - Integration with identity providers like Authentik, Keycloak, or Auth0 +3. **Social Login** - Sign in with Google, Microsoft, Apple, or Dropbox accounts (see [Social Login Setup Guide](SocialLoginSetup.md)) ## Session Security @@ -189,3 +190,11 @@ If you encounter issues with authentication: - For most providers, you can visit the `/.well-known/openid-configuration` endpoint to verify their settings For more general configuration issues, see the [Configuration Troubleshooting Guide](ConfigurationTroubleshooting.md). + +## Social Login + +DocuElevate supports social login with Google, Microsoft, Apple, and Dropbox. Social login allows users to authenticate using their existing accounts with these providers, without needing a separate DocuElevate password. + +Social login can be used alongside any other authentication method (simple auth, OIDC, local signup). Each social provider is independently configured. + +For detailed setup instructions, prerequisites, and provider-specific configuration, see the **[Social Login Setup Guide](SocialLoginSetup.md)**. diff --git a/docs/ConfigurationGuide.md b/docs/ConfigurationGuide.md index 398aed82..4026f299 100644 --- a/docs/ConfigurationGuide.md +++ b/docs/ConfigurationGuide.md @@ -336,6 +336,28 @@ Credentials are encrypted at rest using Fernet encryption. | `AUTHENTIK_CONFIG_URL` | Configuration URL for Authentik OpenID Connect. | | `OAUTH_PROVIDER_NAME` | Display name for the OAuth provider button. | +### Social Login Providers + +Social login lets users sign in with their existing Google, Microsoft, Apple, or Dropbox accounts. Each provider is independently enabled and configured. For detailed setup instructions see the [Social Login Setup Guide](SocialLoginSetup.md). + +| **Variable** | **Description** | **Default** | +|---|---|---| +| `SOCIAL_AUTH_GOOGLE_ENABLED` | Enable Google Sign-In. | `false` | +| `SOCIAL_AUTH_GOOGLE_CLIENT_ID` | Google OAuth2 client ID from the Google Cloud Console. | *(empty)* | +| `SOCIAL_AUTH_GOOGLE_CLIENT_SECRET` | Google OAuth2 client secret. | *(empty)* | +| `SOCIAL_AUTH_MICROSOFT_ENABLED` | Enable Microsoft Sign-In (Azure AD / Microsoft Entra ID). | `false` | +| `SOCIAL_AUTH_MICROSOFT_CLIENT_ID` | Microsoft application (client) ID from Azure App Registrations. | *(empty)* | +| `SOCIAL_AUTH_MICROSOFT_CLIENT_SECRET` | Microsoft client secret. | *(empty)* | +| `SOCIAL_AUTH_MICROSOFT_TENANT` | Azure AD tenant: `common`, `organizations`, `consumers`, or a tenant GUID. | `common` | +| `SOCIAL_AUTH_APPLE_ENABLED` | Enable Sign in with Apple. | `false` | +| `SOCIAL_AUTH_APPLE_CLIENT_ID` | Apple Services ID (e.g. `com.example.docuelevate`). | *(empty)* | +| `SOCIAL_AUTH_APPLE_TEAM_ID` | Apple Developer Team ID. | *(empty)* | +| `SOCIAL_AUTH_APPLE_KEY_ID` | Apple Sign-In private key ID. | *(empty)* | +| `SOCIAL_AUTH_APPLE_PRIVATE_KEY` | Apple Sign-In private key (PEM format). | *(empty)* | +| `SOCIAL_AUTH_DROPBOX_ENABLED` | Enable Dropbox Sign-In. | `false` | +| `SOCIAL_AUTH_DROPBOX_CLIENT_ID` | Dropbox OAuth2 App Key. | *(empty)* | +| `SOCIAL_AUTH_DROPBOX_CLIENT_SECRET` | Dropbox OAuth2 App Secret. | *(empty)* | + ### Multi-User Mode When multi-user mode is enabled, each authenticated user gets their own isolated document space. diff --git a/docs/SocialLoginSetup.md b/docs/SocialLoginSetup.md new file mode 100644 index 00000000..5b4523ba --- /dev/null +++ b/docs/SocialLoginSetup.md @@ -0,0 +1,375 @@ +# Social Login Setup Guide + +This guide explains how to configure social login providers (Google, Microsoft, Apple, Dropbox) for DocuElevate. Social login lets your users sign in with their existing accounts, reducing friction and eliminating the need for separate passwords. + +## Overview + +DocuElevate supports four social login providers: + +| Provider | Protocol | Best For | +|----------|----------|----------| +| **Google** | OAuth2 / OpenID Connect | Consumers and Google Workspace organizations | +| **Microsoft** | OAuth2 / OpenID Connect | Microsoft 365 / Azure AD organizations and personal Microsoft accounts | +| **Apple** | OAuth2 / OpenID Connect | iOS/macOS users, privacy-focused users | +| **Dropbox** | OAuth2 | Teams already using Dropbox as a storage destination | + +Each provider is **independently enabled** — you can use one, several, or all of them at the same time. Social login works alongside any other DocuElevate authentication method (simple auth, OIDC/Authentik, local signup). + +## Prerequisites + +Before configuring any social login provider, ensure: + +1. **Authentication is enabled**: `AUTH_ENABLED=true` in your `.env` file +2. **Session secret is set**: `SESSION_SECRET` must be a random string of at least 32 characters +3. **HTTPS is configured**: All social login providers require HTTPS redirect URIs in production. Use a reverse proxy (Traefik, Nginx, Caddy) with a valid TLS certificate +4. **External hostname is set**: `EXTERNAL_HOSTNAME` must match your public domain (e.g., `docuelevate.example.com`) + +> **Note:** Social login users are regular (non-admin) users by default. To grant admin access, use the Admin Panel (**Settings → User Management**) after the user's first login, or configure admin groups via Authentik/OIDC. + +## Callback URLs + +Each social login provider uses a callback URL to redirect users back to DocuElevate after authentication. The callback URL pattern is: + +``` +https:///social-callback/ +``` + +For example, if your DocuElevate instance is at `https://docuelevate.example.com`: + +| Provider | Callback URL | +|----------|-------------| +| Google | `https://docuelevate.example.com/social-callback/google` | +| Microsoft | `https://docuelevate.example.com/social-callback/microsoft` | +| Apple | `https://docuelevate.example.com/social-callback/apple` | +| Dropbox | `https://docuelevate.example.com/social-callback/dropbox` | + +--- + +## Google Sign-In + +### 1. Create OAuth Credentials in Google Cloud Console + +1. Go to the [Google Cloud Console](https://console.cloud.google.com/) +2. Create a new project (or select an existing one) +3. Navigate to **APIs & Services → Credentials** +4. Click **Create Credentials → OAuth client ID** +5. If prompted, configure the **OAuth consent screen** first: + - **User Type**: External (or Internal for Google Workspace) + - **App name**: DocuElevate + - **User support email**: Your email + - **Authorized domains**: Your domain (e.g., `example.com`) + - **Scopes**: Add `email`, `profile`, and `openid` +6. Back on the Credentials page, create an **OAuth 2.0 Client ID**: + - **Application type**: Web application + - **Name**: DocuElevate + - **Authorized redirect URIs**: `https://docuelevate.example.com/social-callback/google` +7. Note the **Client ID** and **Client Secret** + +### 2. Configure DocuElevate + +Add to your `.env` file: + +```bash +SOCIAL_AUTH_GOOGLE_ENABLED=true +SOCIAL_AUTH_GOOGLE_CLIENT_ID=123456789-abcdefg.apps.googleusercontent.com +SOCIAL_AUTH_GOOGLE_CLIENT_SECRET=GOCSPX-your-secret-here +``` + +### 3. Restart DocuElevate + +```bash +docker compose restart api worker +``` + +### Google-Specific Notes + +- **Google Workspace**: If you want to restrict sign-in to users in your Google Workspace organization, set the OAuth consent screen to "Internal" +- **Verification**: Google may require app verification if you're using External user type and requesting sensitive scopes. For small teams (<100 users), you can add test users instead +- **Unified Auth**: If you also use Google Drive as a storage destination, users who sign in with Google will already be authenticated with a Google identity — simplifying the Google Drive integration experience + +--- + +## Microsoft Sign-In (Azure AD / Microsoft Entra ID) + +### 1. Register an Application in Azure + +1. Go to the [Azure Portal](https://portal.azure.com/) +2. Navigate to **Microsoft Entra ID → App registrations** +3. Click **New registration** +4. Fill in: + - **Name**: DocuElevate + - **Supported account types**: Choose based on your needs: + - *Accounts in this organizational directory only* — single-tenant (your org only) + - *Accounts in any organizational directory* — multi-tenant + - *Accounts in any organizational directory and personal Microsoft accounts* — broadest reach + - **Redirect URI**: Select **Web** and enter `https://docuelevate.example.com/social-callback/microsoft` +5. Click **Register** +6. Note the **Application (client) ID** +7. Navigate to **Certificates & secrets → New client secret** +8. Add a description and expiration, then click **Add** +9. Note the **Value** (this is your client secret — it's only shown once!) + +### 2. Configure API Permissions + +1. In your app registration, go to **API permissions** +2. Ensure these permissions are present (they're usually added by default): + - `openid` + - `profile` + - `email` +3. Click **Grant admin consent** if you're a tenant admin + +### 3. Configure DocuElevate + +Add to your `.env` file: + +```bash +SOCIAL_AUTH_MICROSOFT_ENABLED=true +SOCIAL_AUTH_MICROSOFT_CLIENT_ID=12345678-abcd-efgh-ijkl-123456789012 +SOCIAL_AUTH_MICROSOFT_CLIENT_SECRET=your~client~secret~value +SOCIAL_AUTH_MICROSOFT_TENANT=common +``` + +**Tenant options:** + +| Value | Who Can Sign In | +|-------|----------------| +| `common` | Any Microsoft account (personal + any Azure AD organization) | +| `organizations` | Any Azure AD organization (work/school accounts only) | +| `consumers` | Personal Microsoft accounts only (outlook.com, hotmail.com, etc.) | +| `` | Only users in a specific Azure AD tenant (use the GUID from Azure Portal) | + +### 4. Restart DocuElevate + +```bash +docker compose restart api worker +``` + +### Microsoft-Specific Notes + +- **Client secret expiration**: Azure AD client secrets expire (max 2 years). Set a calendar reminder to rotate them before they expire +- **Conditional Access**: If your organization uses Azure AD Conditional Access policies, social login will respect them +- **Unified Auth**: If you also use OneDrive as a storage destination, users who sign in with Microsoft will already have a Microsoft identity — potentially simplifying OneDrive integration + +--- + +## Apple Sign-In + +Apple Sign-In requires an Apple Developer account ($99/year) and more setup than other providers. + +### 1. Configure in Apple Developer Portal + +1. Go to the [Apple Developer Portal](https://developer.apple.com/account/) +2. Navigate to **Certificates, Identifiers & Profiles → Identifiers** +3. Click **+** and select **App IDs** → Register an App ID: + - **Description**: DocuElevate + - **Bundle ID**: e.g., `com.example.docuelevate` + - Enable **Sign In with Apple** capability +4. Click **+** again and select **Services IDs**: + - **Description**: DocuElevate Web + - **Identifier**: e.g., `com.example.docuelevate.web` (this is your Client ID) + - Enable **Sign In with Apple** + - Click **Configure** next to Sign In with Apple: + - **Primary App ID**: Select the App ID created above + - **Domains**: `docuelevate.example.com` + - **Return URLs**: `https://docuelevate.example.com/social-callback/apple` +5. Click **Save** and **Continue** → **Register** +6. Navigate to **Keys** → Click **+** to create a new key: + - **Key Name**: DocuElevate Sign-In + - Enable **Sign In with Apple** + - Click **Configure** and select the App ID created above + - Click **Continue** → **Register** + - **Download the private key file** (`.p8`) — you can only download it once! + - Note the **Key ID** +7. Note your **Team ID** (shown in the top-right corner of the Developer Portal) + +### 2. Configure DocuElevate + +Add to your `.env` file: + +```bash +SOCIAL_AUTH_APPLE_ENABLED=true +SOCIAL_AUTH_APPLE_CLIENT_ID=com.example.docuelevate.web +SOCIAL_AUTH_APPLE_TEAM_ID=ABCDE12345 +SOCIAL_AUTH_APPLE_KEY_ID=FGHIJ67890 +SOCIAL_AUTH_APPLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY----- +MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQg... +...your key content here... +-----END PRIVATE KEY-----" +``` + +> **Tip:** You can also store the private key as a single line with `\n` for line breaks: +> ```bash +> SOCIAL_AUTH_APPLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIGTAgEAMBMG...\n-----END PRIVATE KEY-----" +> ``` + +### 3. Restart DocuElevate + +```bash +docker compose restart api worker +``` + +### Apple-Specific Notes + +- **Email relay**: Apple offers a "Hide My Email" feature that provides a relay email address (e.g., `abc123@privaterelay.appleid.com`). DocuElevate accepts these addresses +- **First login only**: Apple sends the user's name only on the very first authorization. If the user revokes and re-authorizes, their name may not be sent again +- **Developer account required**: You need an Apple Developer account ($99/year) to use Sign In with Apple +- **Key rotation**: Apple private keys don't expire, but if you suspect compromise, revoke the key in the Developer Portal and create a new one + +--- + +## Dropbox Sign-In + +### 1. Create a Dropbox App + +1. Go to the [Dropbox App Console](https://www.dropbox.com/developers/apps) +2. Click **Create app** +3. Choose: + - **API**: Scoped access + - **Access type**: Full Dropbox (or App folder, depending on your needs) + - **Name**: DocuElevate Auth (or reuse your existing Dropbox storage app) +4. In the app settings, go to the **OAuth 2** section: + - Add **Redirect URI**: `https://docuelevate.example.com/social-callback/dropbox` +5. Note the **App key** (this is your Client ID) and **App secret** (this is your Client Secret) + +> **Tip:** If you already have a Dropbox app configured for DocuElevate's storage integration, you can reuse the same app — just add the social login redirect URI. Alternatively, create a separate app for authentication to keep concerns separated. + +### 2. Configure DocuElevate + +Add to your `.env` file: + +```bash +SOCIAL_AUTH_DROPBOX_ENABLED=true +SOCIAL_AUTH_DROPBOX_CLIENT_ID=your_dropbox_app_key +SOCIAL_AUTH_DROPBOX_CLIENT_SECRET=your_dropbox_app_secret +``` + +### 3. Restart DocuElevate + +```bash +docker compose restart api worker +``` + +### Dropbox-Specific Notes + +- **Unified Auth**: If you also use Dropbox as a storage destination, authenticating via Dropbox establishes the user's Dropbox identity — making it easier to manage Dropbox storage integration +- **App review**: Dropbox may require app review for production apps with more than 50 users. See [Dropbox App Review](https://www.dropbox.com/developers/reference/developer-guide#app-review) +- **Personal vs. Business**: The same app works for both personal Dropbox and Dropbox Business accounts + +--- + +## Unified Authentication and Storage + +One of the key advantages of social login in DocuElevate is the potential for **unified authentication** — using the same identity for both signing in and accessing cloud storage destinations: + +| Social Login Provider | Related Storage Destination | Benefit | +|---|---|---| +| Google | Google Drive | User already has a Google identity for Drive integration | +| Microsoft | OneDrive | User already has a Microsoft identity for OneDrive integration | +| Dropbox | Dropbox | User already has a Dropbox identity for Dropbox integration | +| Apple | *(none)* | Provides a familiar, privacy-respecting login option | + +When a user signs in with a social provider that matches a configured storage destination, the administrator can leverage the same OAuth credentials or simplify the integration setup. Note that the storage integration credentials are configured separately in the admin settings — social login establishes the user's identity, not their storage permissions. + +## Combining Multiple Auth Methods + +DocuElevate supports running multiple authentication methods simultaneously: + +``` +┌──────────────────────────────────────────────────┐ +│ Login Page │ +├──────────────────────────────────────────────────┤ +│ Username / Password form (always shown) │ +│ │ +│ ─── Or continue with ─── │ +│ │ +│ [Authentik SSO] (if OIDC configured) │ +│ [Sign in with Google] (if Google enabled) │ +│ [Sign in with Microsoft] (if Microsoft enabled) │ +│ [Sign in with Apple] (if Apple enabled) │ +│ [Sign in with Dropbox] (if Dropbox enabled) │ +│ │ +│ [Create account] (if local signup enabled) │ +└──────────────────────────────────────────────────┘ +``` + +All methods create or update the same `UserProfile` record, so a user is consistently identified regardless of how they sign in. + +## Admin Management + +Social login users appear in the **Admin → User Management** panel like any other user. Admins can: + +- View which provider a user authenticated with +- Block or unblock social login users +- Set upload limits and subscription tiers +- Grant admin privileges (social login users are never automatically admin) + +## Security Considerations + +1. **HTTPS is required**: All social login providers require HTTPS callback URLs in production +2. **Credentials are sensitive**: Store client secrets securely — use environment variables, never commit them to source control +3. **Least privilege**: Only request the scopes you need (DocuElevate requests `openid`, `profile`, and `email`) +4. **Rotate secrets**: Set calendar reminders to rotate OAuth client secrets before they expire (especially Microsoft, which has a max 2-year expiration) +5. **Monitor logins**: Check the DocuElevate audit log for unusual login patterns +6. **Social login users are not admins**: Admin access must be explicitly granted by an existing admin + +## Troubleshooting + +### Common Issues + +1. **"Unknown social provider" error** + - The provider is not enabled or credentials are missing + - Check that `SOCIAL_AUTH__ENABLED=true` is set + - Verify client ID and secret are configured + +2. **"Could not retrieve email from provider" error** + - The provider didn't return an email address + - For Google: Ensure `email` scope is included (it is by default) + - For Apple: User may have chosen "Hide My Email" — this is expected and should still work + - For Dropbox: Ensure the app has permission to read the user's email + +3. **Redirect URI mismatch** + - The callback URL registered with the provider must exactly match what DocuElevate generates + - Check your `EXTERNAL_HOSTNAME` setting + - Ensure you're using HTTPS in production + - The callback URL format is: `https:///social-callback/` + +4. **"Social login failed" error** + - Check DocuElevate logs (`docker compose logs api`) for detailed error messages + - Verify the provider's OAuth app is not suspended or in development mode + - For Google: Check if the OAuth consent screen needs verification + - For Microsoft: Ensure admin consent was granted for the required permissions + +5. **User can't log in after changing provider settings** + - After changing social login configuration, restart DocuElevate: `docker compose restart api worker` + - Social login settings require a restart to take effect (`restart_required: true`) + +### Debug Checklist + +- [ ] `AUTH_ENABLED=true` is set +- [ ] `SESSION_SECRET` is at least 32 characters +- [ ] `EXTERNAL_HOSTNAME` matches your public domain +- [ ] Provider-specific `_ENABLED=true` is set +- [ ] Client ID and secret are correctly configured (no extra spaces) +- [ ] Callback URL is registered with the provider +- [ ] HTTPS is working on your domain +- [ ] DocuElevate has been restarted after configuration changes + +## Environment Variable Reference + +| Variable | Required | Description | +|---|---|---| +| `SOCIAL_AUTH_GOOGLE_ENABLED` | No | Enable Google Sign-In (`true`/`false`). Default: `false` | +| `SOCIAL_AUTH_GOOGLE_CLIENT_ID` | When Google enabled | Google OAuth2 client ID | +| `SOCIAL_AUTH_GOOGLE_CLIENT_SECRET` | When Google enabled | Google OAuth2 client secret | +| `SOCIAL_AUTH_MICROSOFT_ENABLED` | No | Enable Microsoft Sign-In (`true`/`false`). Default: `false` | +| `SOCIAL_AUTH_MICROSOFT_CLIENT_ID` | When Microsoft enabled | Azure AD application (client) ID | +| `SOCIAL_AUTH_MICROSOFT_CLIENT_SECRET` | When Microsoft enabled | Azure AD client secret | +| `SOCIAL_AUTH_MICROSOFT_TENANT` | No | Azure AD tenant. Default: `common` | +| `SOCIAL_AUTH_APPLE_ENABLED` | No | Enable Apple Sign-In (`true`/`false`). Default: `false` | +| `SOCIAL_AUTH_APPLE_CLIENT_ID` | When Apple enabled | Apple Services ID | +| `SOCIAL_AUTH_APPLE_TEAM_ID` | When Apple enabled | Apple Developer Team ID | +| `SOCIAL_AUTH_APPLE_KEY_ID` | When Apple enabled | Apple Sign-In key ID | +| `SOCIAL_AUTH_APPLE_PRIVATE_KEY` | When Apple enabled | Apple Sign-In private key (PEM) | +| `SOCIAL_AUTH_DROPBOX_ENABLED` | No | Enable Dropbox Sign-In (`true`/`false`). Default: `false` | +| `SOCIAL_AUTH_DROPBOX_CLIENT_ID` | When Dropbox enabled | Dropbox App Key | +| `SOCIAL_AUTH_DROPBOX_CLIENT_SECRET` | When Dropbox enabled | Dropbox App Secret | diff --git a/frontend/templates/login.html b/frontend/templates/login.html index bcfbe815..5faad44d 100644 --- a/frontend/templates/login.html +++ b/frontend/templates/login.html @@ -75,7 +75,8 @@ {% endif %} + {% if social_providers %} + + {% if not show_oauth %} +
+
+
+
+
+ Or continue with +
+
+ {% endif %} + +
+ {% endif %} +
Return to Home diff --git a/tests/test_config_validators.py b/tests/test_config_validators.py index 308f2e0b..c69b30a7 100644 --- a/tests/test_config_validators.py +++ b/tests/test_config_validators.py @@ -196,8 +196,12 @@ class TestValidateAuthConfig: mock_settings.authentik_client_id = None mock_settings.authentik_client_secret = None mock_settings.authentik_config_url = None + mock_settings.social_auth_google_enabled = False + mock_settings.social_auth_microsoft_enabled = False + mock_settings.social_auth_apple_enabled = False + mock_settings.social_auth_dropbox_enabled = False result = validate_auth_config() - assert "Neither simple authentication nor OIDC are properly configured" in result + assert "Neither simple authentication, OIDC, nor social login are properly configured" in result def test_auth_enabled_oidc_missing_provider_name(self): """Test validation when OIDC is configured but provider name is missing.""" @@ -210,6 +214,10 @@ class TestValidateAuthConfig: mock_settings.authentik_client_secret = "client_secret" mock_settings.authentik_config_url = "https://example.com" mock_settings.oauth_provider_name = None + mock_settings.social_auth_google_enabled = False + mock_settings.social_auth_microsoft_enabled = False + mock_settings.social_auth_apple_enabled = False + mock_settings.social_auth_dropbox_enabled = False result = validate_auth_config() assert "OAUTH_PROVIDER_NAME is not configured but OIDC is enabled" in result @@ -223,6 +231,10 @@ class TestValidateAuthConfig: mock_settings.authentik_client_id = None mock_settings.authentik_client_secret = None mock_settings.authentik_config_url = None + mock_settings.social_auth_google_enabled = False + mock_settings.social_auth_microsoft_enabled = False + mock_settings.social_auth_apple_enabled = False + mock_settings.social_auth_dropbox_enabled = False result = validate_auth_config() assert len(result) == 0 @@ -237,6 +249,10 @@ class TestValidateAuthConfig: mock_settings.authentik_client_secret = "client_secret" mock_settings.authentik_config_url = "https://example.com" mock_settings.oauth_provider_name = "Authentik" + mock_settings.social_auth_google_enabled = False + mock_settings.social_auth_microsoft_enabled = False + mock_settings.social_auth_apple_enabled = False + mock_settings.social_auth_dropbox_enabled = False result = validate_auth_config() assert len(result) == 0 diff --git a/tests/test_social_login.py b/tests/test_social_login.py new file mode 100644 index 00000000..54c81931 --- /dev/null +++ b/tests/test_social_login.py @@ -0,0 +1,505 @@ +"""Tests for social login functionality in app/auth.py.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import Request, status +from starlette.responses import RedirectResponse + + +@pytest.mark.unit +class TestSocialProviders: + """Tests for SOCIAL_PROVIDERS dictionary population.""" + + def test_social_providers_is_dict(self): + """Test that SOCIAL_PROVIDERS is a dict.""" + from app.auth import SOCIAL_PROVIDERS + + assert isinstance(SOCIAL_PROVIDERS, dict) + + def test_social_providers_empty_by_default(self): + """Test that no social providers are enabled by default (settings have enabled=False).""" + # In test environment, social login settings are not set, so the dict should be empty + from app.auth import SOCIAL_PROVIDERS + + # Since tests run with default settings (all social providers disabled), + # SOCIAL_PROVIDERS should be empty + assert isinstance(SOCIAL_PROVIDERS, dict) + + +@pytest.mark.unit +class TestSocialLogin: + """Tests for social_login() function.""" + + @pytest.mark.asyncio + async def test_social_login_unknown_provider(self): + """Test social_login redirects when provider is unknown.""" + from app.auth import social_login + + mock_request = MagicMock(spec=Request) + + with patch("app.auth.SOCIAL_PROVIDERS", {}): + result = await social_login(mock_request, "unknown_provider") + + assert isinstance(result, RedirectResponse) + assert result.status_code == status.HTTP_302_FOUND + assert "/login?error=Unknown+social+provider" in result.headers["location"] + + @pytest.mark.asyncio + async def test_social_login_provider_not_in_oauth(self): + """Test social_login redirects when provider is registered but OAuth client is missing.""" + from app.auth import social_login + + mock_request = MagicMock(spec=Request) + + with ( + patch("app.auth.SOCIAL_PROVIDERS", {"google": {"name": "Google", "icon": "fab fa-google", "color": "red"}}), + patch("app.auth.oauth") as mock_oauth, + ): + mock_oauth.google = None + + result = await social_login(mock_request, "google") + + assert isinstance(result, RedirectResponse) + assert "/login?error=Provider+not+configured" in result.headers["location"] + + @pytest.mark.asyncio + async def test_social_login_initiates_redirect(self): + """Test social_login initiates OAuth redirect for a valid provider.""" + from app.auth import social_login + + mock_request = MagicMock(spec=Request) + mock_request.url_for = MagicMock(return_value="http://localhost/social-callback/google") + + mock_google = MagicMock() + mock_google.authorize_redirect = AsyncMock(return_value="google_redirect") + + with ( + patch("app.auth.SOCIAL_PROVIDERS", {"google": {"name": "Google", "icon": "fab fa-google", "color": "red"}}), + patch("app.auth.oauth") as mock_oauth, + ): + mock_oauth.google = mock_google + + result = await social_login(mock_request, "google") + + assert result == "google_redirect" + mock_google.authorize_redirect.assert_called_once_with( + mock_request, "http://localhost/social-callback/google" + ) + + +@pytest.mark.unit +class TestNormalizeSocialUserinfo: + """Tests for _normalize_social_userinfo().""" + + def test_normalize_google_userinfo(self): + """Test normalizing Google OIDC userinfo.""" + from app.auth import _normalize_social_userinfo + + raw = { + "sub": "123456789", + "email": "user@gmail.com", + "name": "Test User", + "picture": "https://lh3.googleusercontent.com/photo.jpg", + } + result = _normalize_social_userinfo("google", {}, raw) + + assert result["sub"] == "123456789" + assert result["email"] == "user@gmail.com" + assert result["name"] == "Test User" + assert result["preferred_username"] == "user@gmail.com" + assert result["picture"] == "https://lh3.googleusercontent.com/photo.jpg" + + def test_normalize_microsoft_userinfo(self): + """Test normalizing Microsoft OIDC userinfo.""" + from app.auth import _normalize_social_userinfo + + raw = { + "sub": "ms-sub-123", + "email": "user@outlook.com", + "name": "MS User", + } + result = _normalize_social_userinfo("microsoft", {}, raw) + + assert result["sub"] == "ms-sub-123" + assert result["email"] == "user@outlook.com" + assert result["name"] == "MS User" + assert result["preferred_username"] == "user@outlook.com" + + def test_normalize_apple_userinfo(self): + """Test normalizing Apple OIDC userinfo.""" + from app.auth import _normalize_social_userinfo + + raw = { + "sub": "apple-sub-456", + "email": "user@privaterelay.appleid.com", + } + result = _normalize_social_userinfo("apple", {}, raw) + + assert result["sub"] == "apple-sub-456" + assert result["email"] == "user@privaterelay.appleid.com" + + def test_normalize_dropbox_userinfo(self): + """Test normalizing Dropbox non-standard userinfo.""" + from app.auth import _normalize_social_userinfo + + raw = { + "account_id": "dbid:AABcDEfGhIjKlMnOpQr", + "email": "user@example.com", + "name": {"display_name": "Dropbox User"}, + "profile_photo_url": "https://dropbox.com/photo.jpg", + } + result = _normalize_social_userinfo("dropbox", {}, raw) + + assert result["sub"] == "dbid:AABcDEfGhIjKlMnOpQr" + assert result["email"] == "user@example.com" + assert result["name"] == "Dropbox User" + assert result["picture"] == "https://dropbox.com/photo.jpg" + + def test_normalize_dropbox_missing_fields(self): + """Test normalizing Dropbox userinfo with missing fields.""" + from app.auth import _normalize_social_userinfo + + raw = {"email": "user@example.com"} + result = _normalize_social_userinfo("dropbox", {}, raw) + + assert result["sub"] == "user@example.com" # Falls back to email + assert result["email"] == "user@example.com" + assert result["name"] == "" + + def test_normalize_with_none_userinfo(self): + """Test normalizing when userinfo is None.""" + from app.auth import _normalize_social_userinfo + + result = _normalize_social_userinfo("google", {}, None) + + assert result["sub"] == "" + assert result["email"] == "" + assert result["name"] == "" + + +@pytest.mark.unit +class TestSocialCallback: + """Tests for social_callback() function.""" + + @pytest.mark.asyncio + async def test_social_callback_unknown_provider(self): + """Test social_callback redirects when provider is unknown.""" + from app.auth import social_callback + + mock_request = MagicMock(spec=Request) + mock_db = MagicMock() + + with patch("app.auth.SOCIAL_PROVIDERS", {}): + result = await social_callback(mock_request, "unknown_provider", db=mock_db) + + assert isinstance(result, RedirectResponse) + assert "/login?error=Unknown+social+provider" in result.headers["location"] + + @pytest.mark.asyncio + async def test_social_callback_provider_not_configured(self): + """Test social_callback redirects when OAuth client is missing.""" + from app.auth import social_callback + + mock_request = MagicMock(spec=Request) + mock_db = MagicMock() + + with ( + patch("app.auth.SOCIAL_PROVIDERS", {"google": {"name": "Google", "icon": "fab fa-google", "color": "red"}}), + patch("app.auth.oauth") as mock_oauth, + ): + mock_oauth.google = None + + result = await social_callback(mock_request, "google", db=mock_db) + + assert isinstance(result, RedirectResponse) + assert "/login?error=Provider+not+configured" in result.headers["location"] + + @pytest.mark.asyncio + async def test_social_callback_success_google(self): + """Test successful Google social callback flow.""" + from app.auth import social_callback + + mock_request = MagicMock(spec=Request) + mock_request.session = {} + mock_db = MagicMock() + + mock_google = MagicMock() + mock_google.authorize_access_token = AsyncMock( + return_value={ + "userinfo": { + "sub": "google-123", + "email": "testuser@gmail.com", + "name": "Test User", + "picture": "https://example.com/photo.jpg", + } + } + ) + + mock_profile = MagicMock() + mock_profile.onboarding_completed = True + + with ( + patch("app.auth.SOCIAL_PROVIDERS", {"google": {"name": "Google", "icon": "fab fa-google", "color": "red"}}), + patch("app.auth.oauth") as mock_oauth, + patch("app.auth._ensure_user_profile"), + patch("app.auth._UserProfile") as mock_user_profile_cls, + ): + mock_oauth.google = mock_google + mock_db.query.return_value.filter.return_value.first.return_value = mock_profile + + result = await social_callback(mock_request, "google", db=mock_db) + + assert isinstance(result, RedirectResponse) + # Verify session was set + assert mock_request.session["user"]["email"] == "testuser@gmail.com" + assert mock_request.session["user"]["auth_provider"] == "google" + assert mock_request.session["user"]["is_admin"] is False + + @pytest.mark.asyncio + async def test_social_callback_no_email(self): + """Test social callback when provider doesn't return email.""" + from app.auth import social_callback + + mock_request = MagicMock(spec=Request) + mock_request.session = {} + mock_db = MagicMock() + + mock_google = MagicMock() + mock_google.authorize_access_token = AsyncMock( + return_value={ + "userinfo": { + "sub": "google-123", + # No email! + "name": "Test User", + } + } + ) + + with ( + patch("app.auth.SOCIAL_PROVIDERS", {"google": {"name": "Google", "icon": "fab fa-google", "color": "red"}}), + patch("app.auth.oauth") as mock_oauth, + ): + mock_oauth.google = mock_google + + result = await social_callback(mock_request, "google", db=mock_db) + + assert isinstance(result, RedirectResponse) + assert "/login?error=Could+not+retrieve+email+from+provider" in result.headers["location"] + + @pytest.mark.asyncio + async def test_social_callback_exception_handling(self): + """Test social callback handles exceptions gracefully.""" + from app.auth import social_callback + + mock_request = MagicMock(spec=Request) + mock_request.session = {} + mock_db = MagicMock() + + mock_google = MagicMock() + mock_google.authorize_access_token = AsyncMock(side_effect=Exception("Token exchange failed")) + + with ( + patch("app.auth.SOCIAL_PROVIDERS", {"google": {"name": "Google", "icon": "fab fa-google", "color": "red"}}), + patch("app.auth.oauth") as mock_oauth, + ): + mock_oauth.google = mock_google + + result = await social_callback(mock_request, "google", db=mock_db) + + assert isinstance(result, RedirectResponse) + assert "/login?error=Social+login+failed" in result.headers["location"] + # Ensure internal exception details are not exposed to the user + assert "Exception" not in result.headers["location"] + + +@pytest.mark.unit +class TestLoginPageSocialProviders: + """Tests for login page rendering with social providers.""" + + @pytest.mark.asyncio + async def test_login_page_includes_social_providers(self): + """Test login page passes social_providers to template.""" + mock_providers = { + "google": {"name": "Google", "icon": "fab fa-google", "color": "red"}, + "microsoft": {"name": "Microsoft", "icon": "fab fa-microsoft", "color": "blue"}, + } + + with ( + patch("app.auth.AUTH_ENABLED", True), + patch("app.auth.OAUTH_CONFIGURED", False), + patch("app.auth.SOCIAL_PROVIDERS", mock_providers), + patch("app.auth.templates") as mock_templates, + patch("app.auth.settings") as mock_settings, + ): + mock_settings.version = "1.0.0" + mock_settings.multi_user_enabled = False + mock_settings.allow_local_signup = False + + from app.auth import login + + mock_request = MagicMock() + mock_request.query_params.get.return_value = None + + await login(mock_request) + + mock_templates.TemplateResponse.assert_called_once() + call_args = mock_templates.TemplateResponse.call_args + context = call_args[0][1] + assert context["social_providers"] == mock_providers + + @pytest.mark.asyncio + async def test_login_page_empty_social_providers(self): + """Test login page with no social providers configured.""" + with ( + patch("app.auth.AUTH_ENABLED", True), + patch("app.auth.OAUTH_CONFIGURED", False), + patch("app.auth.SOCIAL_PROVIDERS", {}), + patch("app.auth.templates") as mock_templates, + patch("app.auth.settings") as mock_settings, + ): + mock_settings.version = "1.0.0" + mock_settings.multi_user_enabled = False + mock_settings.allow_local_signup = False + + from app.auth import login + + mock_request = MagicMock() + mock_request.query_params.get.return_value = None + + await login(mock_request) + + mock_templates.TemplateResponse.assert_called_once() + call_args = mock_templates.TemplateResponse.call_args + context = call_args[0][1] + assert context["social_providers"] == {} + + +@pytest.mark.unit +class TestConfigValidatorSocialLogin: + """Tests for config validator social login checks.""" + + def test_social_login_counts_as_valid_auth(self): + """Test that enabled social login prevents 'neither auth configured' warning.""" + from app.utils.config_validator.validators import validate_auth_config + + with patch("app.utils.config_validator.validators.settings") as mock_settings: + mock_settings.auth_enabled = True + mock_settings.session_secret = "a" * 32 + mock_settings.admin_username = None + mock_settings.admin_password = None + mock_settings.authentik_client_id = None + mock_settings.authentik_client_secret = None + mock_settings.authentik_config_url = None + mock_settings.oauth_provider_name = None + mock_settings.social_auth_google_enabled = True + mock_settings.social_auth_google_client_id = "test-id" + mock_settings.social_auth_google_client_secret = "test-secret" + mock_settings.social_auth_microsoft_enabled = False + mock_settings.social_auth_apple_enabled = False + mock_settings.social_auth_dropbox_enabled = False + + issues = validate_auth_config() + + # Should NOT contain the "neither...configured" message + assert not any("Neither" in issue for issue in issues) + + def test_social_login_missing_credentials_reported(self): + """Test that enabled social login without credentials is reported.""" + from app.utils.config_validator.validators import validate_auth_config + + with patch("app.utils.config_validator.validators.settings") as mock_settings: + mock_settings.auth_enabled = True + mock_settings.session_secret = "a" * 32 + mock_settings.admin_username = "admin" + mock_settings.admin_password = "pass" + mock_settings.authentik_client_id = None + mock_settings.authentik_client_secret = None + mock_settings.authentik_config_url = None + mock_settings.oauth_provider_name = None + mock_settings.social_auth_google_enabled = True + mock_settings.social_auth_google_client_id = None # Missing! + mock_settings.social_auth_google_client_secret = None # Missing! + mock_settings.social_auth_microsoft_enabled = False + mock_settings.social_auth_apple_enabled = False + mock_settings.social_auth_dropbox_enabled = False + + issues = validate_auth_config() + + assert any("SOCIAL_AUTH_GOOGLE_CLIENT_ID" in issue for issue in issues) + assert any("SOCIAL_AUTH_GOOGLE_CLIENT_SECRET" in issue for issue in issues) + + def test_microsoft_missing_credentials(self): + """Test Microsoft login validation when credentials are missing.""" + from app.utils.config_validator.validators import validate_auth_config + + with patch("app.utils.config_validator.validators.settings") as mock_settings: + mock_settings.auth_enabled = True + mock_settings.session_secret = "a" * 32 + mock_settings.admin_username = "admin" + mock_settings.admin_password = "pass" + mock_settings.authentik_client_id = None + mock_settings.authentik_client_secret = None + mock_settings.authentik_config_url = None + mock_settings.oauth_provider_name = None + mock_settings.social_auth_google_enabled = False + mock_settings.social_auth_microsoft_enabled = True + mock_settings.social_auth_microsoft_client_id = None + mock_settings.social_auth_microsoft_client_secret = None + mock_settings.social_auth_apple_enabled = False + mock_settings.social_auth_dropbox_enabled = False + + issues = validate_auth_config() + + assert any("SOCIAL_AUTH_MICROSOFT_CLIENT_ID" in issue for issue in issues) + assert any("SOCIAL_AUTH_MICROSOFT_CLIENT_SECRET" in issue for issue in issues) + + def test_apple_missing_credentials(self): + """Test Apple login validation when credentials are missing.""" + from app.utils.config_validator.validators import validate_auth_config + + with patch("app.utils.config_validator.validators.settings") as mock_settings: + mock_settings.auth_enabled = True + mock_settings.session_secret = "a" * 32 + mock_settings.admin_username = "admin" + mock_settings.admin_password = "pass" + mock_settings.authentik_client_id = None + mock_settings.authentik_client_secret = None + mock_settings.authentik_config_url = None + mock_settings.oauth_provider_name = None + mock_settings.social_auth_google_enabled = False + mock_settings.social_auth_microsoft_enabled = False + mock_settings.social_auth_apple_enabled = True + mock_settings.social_auth_apple_client_id = None + mock_settings.social_auth_apple_team_id = None + mock_settings.social_auth_dropbox_enabled = False + + issues = validate_auth_config() + + assert any("SOCIAL_AUTH_APPLE_CLIENT_ID" in issue for issue in issues) + assert any("SOCIAL_AUTH_APPLE_TEAM_ID" in issue for issue in issues) + + def test_dropbox_missing_credentials(self): + """Test Dropbox login validation when credentials are missing.""" + from app.utils.config_validator.validators import validate_auth_config + + with patch("app.utils.config_validator.validators.settings") as mock_settings: + mock_settings.auth_enabled = True + mock_settings.session_secret = "a" * 32 + mock_settings.admin_username = "admin" + mock_settings.admin_password = "pass" + mock_settings.authentik_client_id = None + mock_settings.authentik_client_secret = None + mock_settings.authentik_config_url = None + mock_settings.oauth_provider_name = None + mock_settings.social_auth_google_enabled = False + mock_settings.social_auth_microsoft_enabled = False + mock_settings.social_auth_apple_enabled = False + mock_settings.social_auth_dropbox_enabled = True + mock_settings.social_auth_dropbox_client_id = None + mock_settings.social_auth_dropbox_client_secret = None + + issues = validate_auth_config() + + assert any("SOCIAL_AUTH_DROPBOX_CLIENT_ID" in issue for issue in issues) + assert any("SOCIAL_AUTH_DROPBOX_CLIENT_SECRET" in issue for issue in issues)