Merge pull request #588 from christianlouis/copilot/add-social-logins-configuration

feat(auth): add social login support for Google, Microsoft, Apple, and Dropbox
This commit is contained in:
Christian Krakau-Louis
2026-03-12 01:40:04 +01:00
committed by GitHub
11 changed files with 1407 additions and 6 deletions
+27
View File
@@ -175,6 +175,33 @@ AUTHENTIK_CLIENT_SECRET=<yourAuthentikClientSecret>
AUTHENTIK_CONFIG_URL=<ConfigUrlOfYourApp, e.g. https://authentik.example.com/application/o/docuelevate/.well-known/openid-configuration> AUTHENTIK_CONFIG_URL=<ConfigUrlOfYourApp, e.g. https://authentik.example.com/application/o/docuelevate/.well-known/openid-configuration>
OAUTH_PROVIDER_NAME="Authentik SSO" 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 | <tenant-id>
# 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** # **AI/ML Services**
# Select your AI provider: openai | azure | anthropic | gemini | ollama | openrouter | portkey | litellm # Select your AI provider: openai | azure | anthropic | gemini | ollama | openrouter | portkey | litellm
AI_PROVIDER=openai AI_PROVIDER=openai
+211
View File
@@ -38,6 +38,9 @@ templates = Jinja2Templates(directory=str(templates_dir))
OAUTH_CONFIGURED = False OAUTH_CONFIGURED = False
OAUTH_PROVIDER_NAME = "Single Sign-On" 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: if AUTH_ENABLED and settings.authentik_client_id and settings.authentik_client_secret:
oauth.register( oauth.register(
name="authentik", name="authentik",
@@ -49,6 +52,68 @@ if AUTH_ENABLED and settings.authentik_client_id and settings.authentik_client_s
OAUTH_CONFIGURED = True OAUTH_CONFIGURED = True
OAUTH_PROVIDER_NAME = settings.oauth_provider_name or "Authentik SSO" 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() router = APIRouter()
@@ -194,6 +259,7 @@ async def login(request: Request):
"message": request.query_params.get("message"), "message": request.query_params.get("message"),
"show_oauth": OAUTH_CONFIGURED, "show_oauth": OAUTH_CONFIGURED,
"oauth_provider_name": OAUTH_PROVIDER_NAME, "oauth_provider_name": OAUTH_PROVIDER_NAME,
"social_providers": SOCIAL_PROVIDERS,
"app_version": settings.version, "app_version": settings.version,
"csrf_token": getattr(request.state, "csrf_token", ""), "csrf_token": getattr(request.state, "csrf_token", ""),
# "Create account" link is only shown when multi-user mode AND local signup are both enabled # "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) 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: def _ensure_user_profile(db: Session, user_data: dict, is_admin: bool = False) -> None:
"""Create or update a UserProfile row for *user_data*. """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("/login", login, methods=["GET"])
router.add_api_route("/oauth-login", oauth_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("/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("/auth", auth, methods=["POST"])
router.add_api_route("/logout", logout, methods=["GET"]) router.add_api_route("/logout", logout, methods=["GET"])
+33 -1
View File
@@ -166,12 +166,44 @@ class Settings(BaseSettings):
), ),
) )
# Authentik # Authentik / Generic OIDC
authentik_client_id: Optional[str] = None authentik_client_id: Optional[str] = None
authentik_client_secret: Optional[str] = None authentik_client_secret: Optional[str] = None
authentik_config_url: Optional[str] = None authentik_config_url: Optional[str] = None
oauth_provider_name: Optional[str] = None # Name to display for the OAuth provider 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 # Local user signup
allow_local_signup: bool = Field( allow_local_signup: bool = Field(
default=False, default=False,
+32 -2
View File
@@ -59,13 +59,43 @@ def validate_auth_config() -> list[str]:
and getattr(settings, "authentik_config_url", None) and getattr(settings, "authentik_config_url", None)
) )
if not using_simple_auth and not using_oidc: # Check if any social login provider is enabled
issues.append("Neither simple authentication nor OIDC are properly configured") 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, check for provider name
if using_oidc and not getattr(settings, "oauth_provider_name", None): if using_oidc and not getattr(settings, "oauth_provider_name", None):
issues.append("OAUTH_PROVIDER_NAME is not configured but OIDC is enabled") 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 return issues
+147
View File
@@ -182,6 +182,153 @@ SETTING_METADATA = {
"required": False, "required": False,
"restart_required": True, "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 # AI Services
"openai_api_key": { "openai_api_key": {
"category": "AI Services", "category": "AI Services",
+10 -1
View File
@@ -20,10 +20,11 @@ For a complete list of configuration options, see the [Configuration Guide](Conf
## Authentication Methods ## 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 1. **Simple Authentication** - Basic username/password authentication managed by DocuElevate
2. **OpenID Connect** - Integration with identity providers like Authentik, Keycloak, or Auth0 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 ## 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 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). 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)**.
+22
View File
@@ -336,6 +336,28 @@ Credentials are encrypted at rest using Fernet encryption.
| `AUTHENTIK_CONFIG_URL` | Configuration URL for Authentik OpenID Connect. | | `AUTHENTIK_CONFIG_URL` | Configuration URL for Authentik OpenID Connect. |
| `OAUTH_PROVIDER_NAME` | Display name for the OAuth provider button. | | `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 ### Multi-User Mode
When multi-user mode is enabled, each authenticated user gets their own isolated document space. When multi-user mode is enabled, each authenticated user gets their own isolated document space.
+375
View File
@@ -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://<EXTERNAL_HOSTNAME>/social-callback/<provider>
```
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.) |
| `<tenant-id>` | 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_<PROVIDER>_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://<EXTERNAL_HOSTNAME>/social-callback/<provider>`
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 |
+28 -1
View File
@@ -75,7 +75,8 @@
<div class="mt-6 grid grid-cols-1 gap-3"> <div class="mt-6 grid grid-cols-1 gap-3">
<a href="/oauth-login" <a href="/oauth-login"
class="w-full inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"> class="w-full inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
style="min-height:44px; min-width:44px">
<span class="sr-only">Sign in with SSO</span> <span class="sr-only">Sign in with SSO</span>
<i class="fas fa-lock mr-2" aria-hidden="true"></i> <i class="fas fa-lock mr-2" aria-hidden="true"></i>
{{ oauth_provider_name }} {{ oauth_provider_name }}
@@ -83,6 +84,32 @@
</div> </div>
{% endif %} {% endif %}
{% if social_providers %}
<!-- Social login options -->
{% if not show_oauth %}
<div class="relative">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-300"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-2 bg-white text-gray-500">Or continue with</span>
</div>
</div>
{% endif %}
<div class="mt-6 grid grid-cols-1 gap-3">
{% for provider_key, provider in social_providers.items() %}
<a href="/social-login/{{ provider_key }}"
class="w-full inline-flex items-center justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
style="min-height:44px; min-width:44px"
aria-label="Sign in with {{ provider.name }}">
<i class="{{ provider.icon }} mr-2" aria-hidden="true"></i>
Sign in with {{ provider.name }}
</a>
{% endfor %}
</div>
{% endif %}
<div class="mt-8 text-center"> <div class="mt-8 text-center">
<a href="/" class="text-sm font-medium text-blue-600 hover:text-blue-500"> <a href="/" class="text-sm font-medium text-blue-600 hover:text-blue-500">
Return to Home Return to Home
+17 -1
View File
@@ -196,8 +196,12 @@ class TestValidateAuthConfig:
mock_settings.authentik_client_id = None mock_settings.authentik_client_id = None
mock_settings.authentik_client_secret = None mock_settings.authentik_client_secret = None
mock_settings.authentik_config_url = 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() 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): def test_auth_enabled_oidc_missing_provider_name(self):
"""Test validation when OIDC is configured but provider name is missing.""" """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_client_secret = "client_secret"
mock_settings.authentik_config_url = "https://example.com" mock_settings.authentik_config_url = "https://example.com"
mock_settings.oauth_provider_name = 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 = False
result = validate_auth_config() result = validate_auth_config()
assert "OAUTH_PROVIDER_NAME is not configured but OIDC is enabled" in result 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_id = None
mock_settings.authentik_client_secret = None mock_settings.authentik_client_secret = None
mock_settings.authentik_config_url = 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() result = validate_auth_config()
assert len(result) == 0 assert len(result) == 0
@@ -237,6 +249,10 @@ class TestValidateAuthConfig:
mock_settings.authentik_client_secret = "client_secret" mock_settings.authentik_client_secret = "client_secret"
mock_settings.authentik_config_url = "https://example.com" mock_settings.authentik_config_url = "https://example.com"
mock_settings.oauth_provider_name = "Authentik" 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() result = validate_auth_config()
assert len(result) == 0 assert len(result) == 0
+505
View File
@@ -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)