diff --git a/README.md b/README.md index bdce491..7cef62d 100644 --- a/README.md +++ b/README.md @@ -104,9 +104,18 @@ The API will be available at `http://localhost:5000` - `DELETE /api/v1/organizations/:id/members/:userId` - Remove member - `PATCH /api/v1/organizations/:id/members/:userId/role` - Update role + ### Health - `GET /api/health` - Health check + +## O-auth Setup + +- Redirect URI + +```http://localhost:5000/api/v1/auth/external/[google|microsoft]/callback``` + + ## API Response Format All API responses follow the standardized envelope format: diff --git a/config/base.py b/config/base.py index 8ab6252..4a2dfe6 100644 --- a/config/base.py +++ b/config/base.py @@ -113,3 +113,6 @@ class BaseConfig: WEBAUTHN_RP_ID = os.getenv("WEBAUTHN_RP_ID", "localhost") WEBAUTHN_RP_NAME = os.getenv("WEBAUTHN_RP_NAME", "Gatehouse") WEBAUTHN_ORIGIN = os.getenv("WEBAUTHN_ORIGIN", "https://ui.webauthn.local") + + # Frontend URL (for OAuth callback redirects) + FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:8080") diff --git a/config/development.py b/config/development.py index ca84166..840a84b 100644 --- a/config/development.py +++ b/config/development.py @@ -16,3 +16,12 @@ class DevelopmentConfig(BaseConfig): # Reduced bcrypt rounds for faster dev cycles BCRYPT_LOG_ROUNDS = 4 + + # Gatehouse React UI URL — OIDC authorize redirects here instead of showing raw HTML + OIDC_UI_URL = os.getenv("OIDC_UI_URL", "http://localhost:8080") + + # Add localhost:8080 (React UI) to CORS allowed origins for OIDC bridge endpoints + CORS_ORIGINS = os.getenv( + "CORS_ORIGINS", + "http://localhost:8080,http://localhost:3000,http://localhost:5173,https://ui.webauthn.local" + ).split(",") diff --git a/docs/external-auth-api.md b/docs/external-auth-api.md index baf6673..e990268 100644 --- a/docs/external-auth-api.md +++ b/docs/external-auth-api.md @@ -714,12 +714,44 @@ Provider tokens are encrypted at rest: - Auth URL: `https://login.microsoftonline.com/common/oauth2/v2.0/authorize` - Token URL: `https://login.microsoftonline.com/common/oauth2/v2.0/token` - UserInfo URL: `https://graph.microsoft.com/oidc/userinfo` +- JWKS URL: `https://login.microsoftonline.com/common/discovery/v2.0/keys` **Default Scopes:** - `openid` - OpenID Connect - `profile` - User profile - `email` - Email address -- `User.Read` - Microsoft Graph access +- `offline_access` - Required by Microsoft to return a refresh token (unlike Google which uses `access_type=offline`) + + +#### Azure App Registration steps + +1. Go to [Azure Portal](https://portal.azure.com) → **App registrations** → **New registration** +2. Under **"Supported account types"** choose the option that matches your use case (see table above) +3. Set **Redirect URI** (Web platform) to: + `https:///api/v1/auth/external/microsoft/callback` +4. Under **Certificates & secrets** → **New client secret** — copy the *Value* (not the Secret ID) +5. Under **API permissions** → **Add a permission** → **Microsoft Graph** → **Delegated**: + add `openid`, `profile`, `email`, `offline_access` +6. Configure Gatehouse: + ```bash + # Multi-tenant (work + personal accounts): + MICROSOFT_CLIENT_ID= \ + MICROSOFT_CLIENT_SECRET= \ + python scripts/configure_oauth_provider.py create microsoft \ + --redirect-url "https:///api/v1/auth/external/microsoft/callback" + + # Work/school accounts only (replace with your tenant ID for single-org): + MICROSOFT_CLIENT_ID= \ + MICROSOFT_CLIENT_SECRET= \ + python scripts/configure_oauth_provider.py create microsoft \ + --tenant-id organizations \ + --redirect-url "https:///api/v1/auth/external/microsoft/callback" + ``` + +**Behaviour notes:** +- Microsoft is a **confidential client** — PKCE is not used (the client secret authenticates the app). +- The `email_verified` claim is implicitly `true` for all Azure AD accounts; Gatehouse defaults it to `true` when Microsoft omits it. +- `prompt=select_account` is sent by default so users can choose between multiple signed-in Microsoft accounts. --- diff --git a/gatehouse_app/api/oidc.py b/gatehouse_app/api/oidc.py index f670cce..5ec7700 100644 --- a/gatehouse_app/api/oidc.py +++ b/gatehouse_app/api/oidc.py @@ -19,10 +19,53 @@ from gatehouse_app.services.auth_service import AuthService from gatehouse_app.services.mfa_policy_service import MfaPolicyService from gatehouse_app.extensions import db from gatehouse_app.extensions import bcrypt as flask_bcrypt +from gatehouse_app.extensions import redis_client as _redis_client_ref # may be None until app init from gatehouse_app.models import User, OIDCClient from gatehouse_app.models.organization import Organization from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError +# --------------------------------------------------------------------------- +# Helpers for Redis-backed OIDC pending state +# (avoids Flask session / cookie dependency for cross-origin /oidc/complete) +# --------------------------------------------------------------------------- + +_OIDC_PENDING_TTL = 600 # 10 minutes + + +def _oidc_redis(): + """Return the shared Redis client, or None if not yet initialised.""" + import gatehouse_app.extensions as _ext + return _ext.redis_client + + +def _stash_oidc_params(oidc_session_id: str, params: dict) -> None: + """Store OIDC params in Redis with a TTL. Falls back to Flask session.""" + rc = _oidc_redis() + key = f"oidc_pending:{oidc_session_id}" + if rc is not None: + rc.setex(key, _OIDC_PENDING_TTL, json.dumps(params)) + else: + session[f"oidc_pending_{oidc_session_id}"] = params + + +def _fetch_oidc_params(oidc_session_id: str, *, consume: bool = False) -> dict | None: + """Retrieve (and optionally delete) OIDC params from Redis / Flask session.""" + rc = _oidc_redis() + key = f"oidc_pending:{oidc_session_id}" + if rc is not None: + raw = rc.get(key) + if raw is None: + return None + params = json.loads(raw) + if consume: + rc.delete(key) + return params + else: + params = session.get(f"oidc_pending_{oidc_session_id}") + if params and consume: + session.pop(f"oidc_pending_{oidc_session_id}", None) + return params + # Create OIDC blueprint registered at root level oidc_bp = Blueprint("oidc", __name__) @@ -217,6 +260,134 @@ def oidc_discovery(): return response, 200 +# ============================================================================ +# OIDC UI Bridge — lets the React frontend drive the OIDC login flow +# ============================================================================ + +@oidc_bp.route("/oidc/begin", methods=["POST"]) +def oidc_begin(): + """Stash OIDC authorize params server-side, return a one-time session ID. + + Called by the React UI after being redirected from _show_login_page. + The UI cannot hold OIDC params itself (XSS risk, URL length limits), so + the backend stashes them in the server-side session store and hands back + an opaque ID the UI passes along during login. + + Request body (JSON): + oidc_session_id: ID previously issued by _show_login_page + + Returns: + 200: { oidc_session_id, client_name, scopes } — context for the UI + 400: missing / expired session + """ + data = request.get_json(silent=True) or {} + oidc_session_id = data.get("oidc_session_id") or request.args.get("oidc_session_id") + if not oidc_session_id: + return api_response(success=False, message="oidc_session_id required", status=400) + + params = _fetch_oidc_params(oidc_session_id) + if not params: + return api_response(success=False, message="OIDC session expired or invalid", status=400) + + # Look up client name for display + client = OIDCClient.query.filter_by(client_id=params.get("client_id"), is_active=True).first() + client_name = client.name if client else params.get("client_id", "Unknown Application") + + return api_response( + data={ + "oidc_session_id": oidc_session_id, + "client_name": client_name, + "scopes": params.get("scope", "").split(), + "redirect_uri": params.get("redirect_uri"), + }, + message="OIDC session found", + ) + + +@oidc_bp.route("/oidc/complete", methods=["POST"]) +def oidc_complete(): + """Complete an OIDC authorization flow after the UI has authenticated the user. + + Called by the React UI after a successful login. The UI sends its Bearer + token + the oidc_session_id. The backend: + 1. Validates the Bearer token → resolves the user + 2. Retrieves the stashed OIDC params + 3. Generates an authorization code + 4. Returns the redirect URL (client app redirect_uri + ?code=...&state=...) + + The UI then does window.location.href = redirect_url. + + Request body (JSON): + oidc_session_id: ID from oidc_begin + token: Gatehouse Bearer token (from /api/v1/auth/login response) + + Returns: + 200: { redirect_url } + 400: invalid request + 401: invalid token + """ + from gatehouse_app.models.session import Session as GHSession + from gatehouse_app.utils.constants import SessionStatus + + data = request.get_json(silent=True) or {} + oidc_session_id = data.get("oidc_session_id") + bearer_token = data.get("token") + + if not oidc_session_id or not bearer_token: + return api_response(success=False, message="oidc_session_id and token required", status=400) + + # Validate the Bearer token + gh_session = GHSession.query.filter_by(token=bearer_token, status=SessionStatus.ACTIVE).first() + if not gh_session or gh_session.is_expired(): + return api_response(success=False, message="Invalid or expired token", status=401) + + user_id = str(gh_session.user_id) + + # Retrieve stashed OIDC params (consume = True removes from Redis atomically) + params = _fetch_oidc_params(oidc_session_id, consume=True) + if not params: + return api_response(success=False, message="OIDC session expired or invalid", status=400) + + client_id = params["client_id"] + redirect_uri = params["redirect_uri"] + state = params.get("state", "") + nonce = params.get("nonce", "") + scope = params.get("scope", "openid") + response_type = params.get("response_type", "code") + + # Validate client still exists + client = OIDCClient.query.filter_by(client_id=client_id, is_active=True).first() + if not client: + return api_response(success=False, message="OIDC client not found", status=400) + + # Generate authorization code + try: + valid_scopes = [s for s in scope.split() if s in (client.scopes or [])] + if not valid_scopes: + valid_scopes = ["openid"] + + code = OIDCService.generate_authorization_code( + client_id=client_id, + user_id=user_id, + redirect_uri=redirect_uri, + scope=valid_scopes, + state=state, + nonce=nonce, + ip_address=request.remote_addr, + user_agent=request.headers.get("User-Agent"), + ) + except Exception as e: + logger.error("[OIDC complete] Code generation failed: %s", str(e)) + return api_response(success=False, message=f"Failed to generate authorization code: {e}", status=500) + + redirect_params = {"code": code} + if state: + redirect_params["state"] = state + redirect_url = f"{redirect_uri}?{urlencode(redirect_params)}" + + return api_response(data={"redirect_url": redirect_url}, message="Authorization complete") + + # ============================================================================ # Authorization Endpoint # ============================================================================ @@ -495,59 +666,30 @@ def _redirect_with_error(redirect_uri, error, error_description, state=None): def _show_login_page(client_id, redirect_uri, scope, state, nonce, response_type, error=None): - """Show the login page for authorization.""" - # Simple HTML login page - html = f""" - - - - Sign In - OIDC Authorization - - - -
-

Sign In

- {"

" + error + "

" if error else ""} -
- - - - - - - -
- - -
- -
- - -
- - -
-

- Cancel -

-
- - + """Redirect to the Gatehouse React UI login page for a proper login experience. + + Stashes the OIDC params in the server-side session keyed by a random ID, + then sends the browser to the React UI at /login?oidc_session_id=. + The UI logs the user in and calls /oidc/complete to finish the flow. """ - return Response(html, mimetype="text/html"), 200 + ui_base_url = current_app.config.get("OIDC_UI_URL", "http://localhost:8080") + + # Stash OIDC params in Redis (TTL 10 min) — cookie-free, cross-origin safe + oidc_session_id = secrets.token_urlsafe(32) + _stash_oidc_params(oidc_session_id, { + "client_id": client_id, + "redirect_uri": redirect_uri, + "scope": scope, + "state": state, + "nonce": nonce, + "response_type": response_type, + }) + + params = {"oidc_session_id": oidc_session_id} + if error: + params["error"] = error + + return redirect(f"{ui_base_url}/login?{urlencode(params)}") # ============================================================================ @@ -1224,7 +1366,6 @@ def oidc_register(): grant_types=data.get("grant_types", ["authorization_code", "refresh_token"]), response_types=data.get("response_types", ["code"]), scopes=data.get("scope", "openid profile email roles").split(), - token_endpoint_auth_method=data.get("token_endpoint_auth_method", "client_secret_basic"), is_active=True, is_confidential=True, require_pkce=True, diff --git a/gatehouse_app/api/v1/external_auth.py b/gatehouse_app/api/v1/external_auth.py index 93d6e7b..bb2969d 100644 --- a/gatehouse_app/api/v1/external_auth.py +++ b/gatehouse_app/api/v1/external_auth.py @@ -1,4 +1,5 @@ """External authentication provider endpoints.""" +import json import logging from flask import request, g from marshmallow import ValidationError @@ -16,6 +17,35 @@ from gatehouse_app.services.oauth_flow_service import ( ) from gatehouse_app.services.audit_service import AuditService +_OAUTH_BRIDGE_TTL = 600 # 10 minutes + + +def _store_oidc_bridge(oauth_state: str, oidc_session_id: str) -> None: + """Store oidc_session_id keyed by OAuth state for retrieval in callback.""" + try: + import gatehouse_app.extensions as _ext + rc = _ext.redis_client + if rc is not None: + rc.setex(f"oauth_oidc_bridge:{oauth_state}", _OAUTH_BRIDGE_TTL, oidc_session_id) + except Exception: + pass + + +def _pop_oidc_bridge(oauth_state: str) -> str | None: + """Retrieve and delete oidc_session_id for the given OAuth state.""" + try: + import gatehouse_app.extensions as _ext + rc = _ext.redis_client + if rc is not None: + key = f"oauth_oidc_bridge:{oauth_state}" + val = rc.get(key) + if val: + rc.delete(key) + return val.decode() if isinstance(val, bytes) else val + except Exception: + pass + return None + logger = logging.getLogger(__name__) @@ -53,63 +83,50 @@ def list_providers(): 200: List of providers with their configuration status 401: Not authenticated """ - from gatehouse_app.models import Organization + from gatehouse_app.models.authentication_method import ApplicationProviderConfig from gatehouse_app.services.external_auth_service import ExternalProviderConfig - # Get user's primary organization + # Check app-level provider configs (ApplicationProviderConfig) + app_configs = { + c.provider_type.lower(): c + for c in ApplicationProviderConfig.query.filter_by(is_enabled=True).all() + } + + # Get user's primary organization — check for org-level overrides too user_orgs = g.current_user.get_organizations() - if not user_orgs: - return api_response( - success=False, - message="No organizations found for user", - status=400, - error_type="BAD_REQUEST", - ) + org_configs = {} + if user_orgs: + organization_id = user_orgs[0].id + org_level = ExternalProviderConfig.query.filter_by( + organization_id=organization_id, + ).all() + org_configs = {c.provider_type.lower(): c for c in org_level} - organization_id = user_orgs[0].id + def provider_info(provider_id: str, name: str) -> dict: + app_cfg = app_configs.get(provider_id) + org_cfg = org_configs.get(provider_id) + is_configured = app_cfg is not None or org_cfg is not None + is_active = False + if app_cfg: + is_active = bool(app_cfg.is_enabled) + if org_cfg and hasattr(org_cfg, "is_active"): + is_active = bool(org_cfg.is_active) + return { + "id": provider_id, + "name": name, + "type": provider_id, + "is_configured": is_configured, + "is_active": is_active, + "settings": { + "requires_domain": False, + "supports_refresh_tokens": True, + }, + } - # Get all configured providers for organization - configs = ExternalProviderConfig.query.filter_by( - organization_id=organization_id, - ).all() - - configured_providers = {c.provider_type.lower(): c for c in configs} - - # Provider definitions providers = [ - { - "id": "google", - "name": "Google", - "type": "google", - "is_configured": "google" in configured_providers, - "is_active": configured_providers.get("google", {}).is_active if "google" in configured_providers else False, - "settings": { - "requires_domain": False, - "supports_refresh_tokens": True, - }, - }, - { - "id": "github", - "name": "GitHub", - "type": "github", - "is_configured": "github" in configured_providers, - "is_active": configured_providers.get("github", {}).is_active if "github" in configured_providers else False, - "settings": { - "requires_domain": False, - "supports_refresh_tokens": True, - }, - }, - { - "id": "microsoft", - "name": "Microsoft", - "type": "microsoft", - "is_configured": "microsoft" in configured_providers, - "is_active": configured_providers.get("microsoft", {}).is_active if "microsoft" in configured_providers else False, - "settings": { - "requires_domain": False, - "supports_refresh_tokens": True, - }, - }, + provider_info("google", "Google"), + provider_info("github", "GitHub"), + provider_info("microsoft", "Microsoft"), ] return api_response( @@ -564,6 +581,7 @@ def initiate_oauth_authorize(provider: str): flow = request.args.get("flow", "login") redirect_uri = request.args.get("redirect_uri") organization_id = request.args.get("organization_id") # Optional hint + oidc_session_id = request.args.get("oidc_session_id") # OIDC bridge passthrough if flow not in ["login", "register"]: return api_response( @@ -588,6 +606,11 @@ def initiate_oauth_authorize(provider: str): redirect_uri=redirect_uri, ) + # If this authorize was triggered during an OIDC bridge flow, remember + # the oidc_session_id so we can hand it back in the callback. + if oidc_session_id: + _store_oidc_bridge(state, oidc_session_id) + return api_response( data={ "authorization_url": auth_url, @@ -609,189 +632,141 @@ def initiate_oauth_authorize(provider: str): def handle_oauth_callback(provider: str): """ Handle OAuth callback from provider. - - This endpoint handles the redirect from the OAuth provider after authentication. - It processes the response and handles different scenarios: - - Successful login/register with redirect_uri: Redirects with authorization code - - Successful login/register without redirect_uri: Returns session token - - Login with multiple orgs: Returns list of organizations for user to select - - Register with no org: Prompts for organization creation/selection + + Google (and other providers) redirect the browser here after authentication. + On success, this endpoint redirects the browser to the frontend + /oauth/callback page carrying the session token as a URL parameter so the + frontend SPA can store it without needing a second API call. + + Success redirect: + {FRONTEND_URL}/oauth/callback?token=TOKEN&expires_in=86400&state=STATE&flow=login&provider=google + + Error redirect: + {FRONTEND_URL}/oauth/callback?error=MESSAGE&error_type=TYPE&state=STATE Args: provider: Provider type (google, github, microsoft) - Query parameters: - code: Authorization code from provider - state: State parameter from OAuth flow - redirect_uri: Optional redirect URI for OAuth 2.0 Authorization Code flow + Query parameters from provider: + code: Authorization code + state: State parameter (CSRF token from OAuth flow) error: Error code if auth failed at provider error_description: Human-readable error description - - Returns: - 302: Redirect with authorization code (if redirect_uri provided) - 200: OAuth flow completed successfully (JSON response) - 400: Validation error, OAuth error, or invalid state - 404: User account not found (for login flows) - - Response formats (when redirect_uri NOT provided): - - Success with session: - { - "token": "session_token", - "expires_in": 86400, - "token_type": "Bearer", - "user": {...} - } - - Requires organization selection (login flow): - { - "requires_org_selection": true, - "user": {...}, - "available_organizations": [...], - "state": "state_token" - } - - Requires organization creation (register flow): - { - "requires_org_creation": true, - "user": {...}, - "state": "state_token" - } """ + from urllib.parse import urlencode + from flask import current_app, redirect as flask_redirect + provider_type = get_provider_type(provider) - # Get callback parameters - authorization_code = request.args.get("code") state = request.args.get("state") + authorization_code = request.args.get("code") error = request.args.get("error") error_description = request.args.get("error_description") - # Get redirect URI from query parameter (for OAuth 2.0 Authorization Code flow) - redirect_uri = request.args.get("redirect_uri") + frontend_url = current_app.config.get("FRONTEND_URL", "http://localhost:8080") + frontend_callback = f"{frontend_url}/oauth/callback" + + def redirect_error(message: str, error_type: str = "OAUTH_ERROR"): + """Redirect to frontend with error params.""" + params = {"error": message, "error_type": error_type} + if state: + params["state"] = state + return flask_redirect(f"{frontend_callback}?{urlencode(params)}", code=302) + + # Handle errors returned by the provider (e.g. user denied) + if error: + msg = error_description or f"Authorization failed: {error}" + return redirect_error(msg, error.upper()) + + if not authorization_code or not state: + return redirect_error("Missing authorization code or state parameter.") try: result = OAuthFlowService.handle_callback( provider_type=provider_type, authorization_code=authorization_code, state=state, - redirect_uri=redirect_uri, - error=error, - error_description=error_description, + redirect_uri=None, # backend handles the full flow + error=None, + error_description=None, ) - if result.get("success"): - flow_type = result.get("flow_type") - - # Check if we should redirect with authorization code - if redirect_uri and flow_type in ["login", "register"]: - # Generate authorization code for external application - user_id = result.get("user", {}).get("id") - if not user_id: - # For org selection/creation flows, we can't redirect - pass - else: - # Determine organization_id - organization_id = result.get("user", {}).get("organization_id") - if not organization_id: - # Can't redirect without organization - pass - else: - # Generate authorization code - auth_code = OAuthFlowService.generate_authorization_code( - user_id=user_id, - client_id="external-app", - redirect_uri=redirect_uri, - scope=["openid", "profile", "email"], - ip_address=request.remote_addr, - user_agent=request.headers.get("User-Agent"), - lifetime_seconds=600, # 10 minutes - ) - - # Mark state as used - state_record = OAuthFlowService.validate_state(state) - if state_record: - state_record.mark_used() - - # Redirect with authorization code - return OAuthFlowService.create_redirect_response( - redirect_uri=redirect_uri, - authorization_code=auth_code, - state=state, - ) - - # Handle login flow responses (no redirect_uri or org selection required) - if flow_type == "login": - # Check if organization selection is required - if result.get("requires_org_selection"): - return api_response( - data={ - "requires_org_selection": True, - "user": result["user"], - "available_organizations": result["available_organizations"], - "state": result["state"], - }, - message="Please select an organization to continue", - status=200, - ) - - # Normal login with session - return api_response( - data={ - "token": result["session"]["token"], - "expires_in": result["session"].get("expires_in", 86400), - "token_type": "Bearer", - "user": result["user"], - }, - message="Login successful", - ) - - # Handle register flow responses - elif flow_type == "register": - # Check if organization creation is required - if result.get("requires_org_creation"): - return api_response( - data={ - "requires_org_creation": True, - "user": result["user"], - "state": result["state"], - }, - message="Please create or select an organization to continue", - status=200, - ) - - # Normal registration with session - return api_response( - data={ - "token": result["session"]["token"], - "expires_in": result["session"].get("expires_in", 86400), - "token_type": "Bearer", - "user": result["user"], - }, - message="Registration successful", - ) - - # Handle link flow responses - elif flow_type == "link": - return api_response( - data={ - "linked_account": result["linked_account"], - }, - message="Account linked successfully", - ) + if not result.get("success"): + return redirect_error("Authentication failed.", "AUTH_FAILED") - # Fallback for unexpected result format - return api_response( - data=result, - message="OAuth flow completed", + flow_type = result.get("flow_type", "login") + + # ── Link flow: redirect to linked-accounts page ────────────────────── + if flow_type == "link": + params = {"flow": "link", "provider": provider, "linked": "1"} + return flask_redirect(f"{frontend_url}/linked-accounts?{urlencode(params)}", code=302) + + # ── Login / Register flow ───────────────────────────────────────────── + + # Recover oidc_session_id if this was triggered from an OIDC bridge flow + oidc_session_id = _pop_oidc_bridge(state) + + # Organization selection needed (user belongs to multiple orgs) + if result.get("requires_org_selection"): + import json + orgs = json.dumps(result.get("available_organizations", [])) + params = { + "requires_org_selection": "1", + "state": result["state"], + "provider": provider, + "flow": flow_type, + "orgs": orgs, + } + if oidc_session_id: + params["oidc_session_id"] = oidc_session_id + return flask_redirect(f"{frontend_callback}?{urlencode(params)}", code=302) + + # Organization creation needed (new user via OAuth with no org) + if result.get("requires_org_creation"): + params = { + "requires_org_creation": "1", + "state": result["state"], + "provider": provider, + "flow": flow_type, + } + if oidc_session_id: + params["oidc_session_id"] = oidc_session_id + return flask_redirect(f"{frontend_callback}?{urlencode(params)}", code=302) + + # Normal success — carry token to frontend via URL + session_data = result.get("session", {}) + token = session_data.get("token") + expires_in = session_data.get("expires_in", 86400) + + if not token: + return redirect_error("No session token returned by server.", "NO_TOKEN") + + params = { + "token": token, + "expires_in": str(expires_in), + "flow": flow_type, + "provider": provider, + "state": state, + } + user_info = result.get("user", {}) + if user_info.get("email"): + params["email"] = user_info["email"] + # Pass oidc_session_id through so the frontend can complete the OIDC flow + if oidc_session_id: + params["oidc_session_id"] = oidc_session_id + + logger.info( + f"OAuth callback success: provider={provider}, flow={flow_type}, " + f"user={user_info.get('email')}, redirecting to frontend" ) + return flask_redirect(f"{frontend_callback}?{urlencode(params)}", code=302) except OAuthFlowError as e: - return api_response( - success=False, - message=e.message, - status=e.status_code, - error_type=e.error_type, - ) + logger.warning(f"OAuth callback OAuthFlowError: {e.message}") + return redirect_error(e.message, e.error_type) + except Exception as e: + logger.error(f"OAuth callback unexpected error: {str(e)}", exc_info=True) + return redirect_error("An unexpected error occurred. Please try again.", "INTERNAL_ERROR") @api_v1_bp.route("/auth/external/select-organization", methods=["POST"]) diff --git a/gatehouse_app/models/oidc_refresh_token.py b/gatehouse_app/models/oidc_refresh_token.py index 0f6aa75..a6459ea 100644 --- a/gatehouse_app/models/oidc_refresh_token.py +++ b/gatehouse_app/models/oidc_refresh_token.py @@ -24,9 +24,9 @@ class OIDCRefreshToken(BaseModel): # Token (hashed for security) token_hash = db.Column(db.String(255), nullable=False, unique=True, index=True) - # Associated access token ID + # Associated access token ID (stores JWT JTI string — no FK to sessions) access_token_id = db.Column( - db.String(36), db.ForeignKey("sessions.id"), nullable=True, index=True + db.String(255), nullable=True, index=True ) # Token scope @@ -50,7 +50,6 @@ class OIDCRefreshToken(BaseModel): # Relationships client = db.relationship("OIDCClient", back_populates="refresh_tokens") user = db.relationship("User", back_populates="oidc_refresh_tokens") - access_token = db.relationship("Session", back_populates="oidc_refresh_token") def __repr__(self): """String representation of OIDCRefreshToken.""" @@ -155,9 +154,3 @@ from gatehouse_app.models.oidc_client import OIDCClient OIDCClient.refresh_tokens = db.relationship( "OIDCRefreshToken", back_populates="client", cascade="all, delete-orphan" ) - -# Add relationship back to Session model -from gatehouse_app.models.session import Session -Session.oidc_refresh_token = db.relationship( - "OIDCRefreshToken", back_populates="access_token", uselist=False -) diff --git a/gatehouse_app/services/external_auth_service.py b/gatehouse_app/services/external_auth_service.py index aa16273..89bfbf1 100644 --- a/gatehouse_app/services/external_auth_service.py +++ b/gatehouse_app/services/external_auth_service.py @@ -1106,6 +1106,7 @@ class ExternalAuthService: def _build_authorization_url(config: ProviderConfigAdapter, state: OAuthState) -> str: """Build authorization URL using the provider config adapter.""" from urllib.parse import urlencode + provider = (config.provider_type or "").lower() params = { "client_id": config.client_id, @@ -1113,10 +1114,26 @@ class ExternalAuthService: "response_type": "code", "scope": " ".join(config.scopes or ["openid", "profile", "email"]), "state": state.state, - "access_type": config.settings.get("access_type", "offline") if config.settings else "offline", - "prompt": config.settings.get("prompt", "consent") if config.settings else "consent", } + if provider == "google": + params["access_type"] = ( + config.settings.get("access_type", "offline") if config.settings else "offline" + ) + params["prompt"] = ( + config.settings.get("prompt", "consent") if config.settings else "consent" + ) + elif provider == "microsoft": + params["prompt"] = ( + config.settings.get("prompt", "select_account") if config.settings else "select_account" + ) + else: + if config.settings: + if "prompt" in config.settings: + params["prompt"] = config.settings["prompt"] + if "access_type" in config.settings: + params["access_type"] = config.settings["access_type"] + if state.nonce: params["nonce"] = state.nonce @@ -1178,17 +1195,26 @@ class ExternalAuthService: """Get user info from provider using the provider config adapter.""" import requests + provider = (config.provider_type or "").lower() headers = {"Authorization": f"Bearer {access_token}"} response = requests.get(config.userinfo_url, headers=headers) response.raise_for_status() data = response.json() + # Microsoft's /oidc/userinfo endpoint returns verified email addresses + # (all AAD accounts are verified) but may omit the email_verified claim. + # Default to True for Microsoft so users aren't stuck with unverified state. + if provider == "microsoft": + email_verified = data.get("email_verified", True) + else: + email_verified = data.get("email_verified", False) + # Standardize user info return { "provider_user_id": data.get("sub"), "email": data.get("email"), - "email_verified": data.get("email_verified", False), + "email_verified": email_verified, "name": data.get("name"), "first_name": data.get("given_name"), "last_name": data.get("family_name"), diff --git a/gatehouse_app/services/oauth_flow_service.py b/gatehouse_app/services/oauth_flow_service.py index 29c45cd..89b1745 100644 --- a/gatehouse_app/services/oauth_flow_service.py +++ b/gatehouse_app/services/oauth_flow_service.py @@ -81,10 +81,11 @@ class OAuthFlowService: 400, ) - # Generate PKCE parameters (Google web applications don't use PKCE) + # Generate PKCE parameters (Google and Microsoft web applications don't use PKCE + # when a client_secret is present — they are confidential clients) code_verifier = None code_challenge = None - if provider_type_str not in ['google']: + if provider_type_str not in ['google', 'microsoft']: code_verifier = secrets.token_urlsafe(32) code_challenge = ExternalAuthService._compute_s256_challenge(code_verifier) @@ -92,7 +93,7 @@ class OAuthFlowService: logger.info( f"[PKCE DEBUG] Provider type check: provider_type_str='{provider_type_str}', " f"is_google={provider_type_str in ['google']}, " - f"will_skip_pkce={provider_type_str in ['google']}" + f"will_skip_pkce={provider_type_str in ['google', 'microsoft']}" ) # Create OAuth state for login flow @@ -183,10 +184,11 @@ class OAuthFlowService: 400, ) - # Generate PKCE parameters (Google web applications don't use PKCE) + # Generate PKCE parameters (Google and Microsoft web applications don't use PKCE + # when a client_secret is present — they are confidential clients) code_verifier = None code_challenge = None - if provider_type_str not in ['google']: + if provider_type_str not in ['google', 'microsoft']: code_verifier = secrets.token_urlsafe(32) code_challenge = ExternalAuthService._compute_s256_challenge(code_verifier) @@ -194,7 +196,7 @@ class OAuthFlowService: logger.info( f"[PKCE DEBUG] Register flow - Provider type check: provider_type_str='{provider_type_str}', " f"is_google={provider_type_str in ['google']}, " - f"will_skip_pkce={provider_type_str in ['google']}" + f"will_skip_pkce={provider_type_str in ['google', 'microsoft']}" ) # Create OAuth state for register flow @@ -404,77 +406,121 @@ class OAuthFlowService: ).first() if not auth_method: - # User doesn't exist - check if email matches existing user + # No linked account found — check if email matches an existing user existing_user = User.query.filter_by( email=user_info["email"] ).first() if existing_user: - AuditService.log_external_auth_login_failed( - organization_id=state_record.organization_id, - provider_type=provider_type_str, + # Email exists but no OAuth link — auto-link and log in + logger.info( + f"OAuth login: email {user_info['email']} matches existing user " + f"{existing_user.id}, auto-linking {provider_type_str} account" + ) + auth_method = AuthenticationMethod( + user_id=existing_user.id, + method_type=provider_type, provider_user_id=user_info["provider_user_id"], + provider_data=ExternalAuthService._encrypt_provider_data(tokens, user_info), + verified=user_info.get("email_verified", False), + is_primary=False, + last_used_at=datetime.utcnow(), + ) + auth_method.save() + user = existing_user + else: + # Brand-new user — auto-register via OAuth (standard behaviour) + logger.info( + f"OAuth login: no account for {user_info['email']}, " + f"auto-creating user via {provider_type_str}" + ) + user = User( email=user_info["email"], - failure_reason="email_exists", - error_message=f"An account with email {user_info['email']} already exists", - ) - raise OAuthFlowError( - f"An account with email {user_info['email']} already exists. " - "Please log in with your password and link your account from settings.", - "EMAIL_EXISTS", - 400, + full_name=user_info.get("name", ""), + status="active", + email_verified=user_info.get("email_verified", False), ) + user.save() - AuditService.log_external_auth_login_failed( - organization_id=state_record.organization_id, - provider_type=provider_type_str, - provider_user_id=user_info["provider_user_id"], - email=user_info["email"], - failure_reason="account_not_found", - error_message="No Gatehouse account matches this external account", - ) - raise OAuthFlowError( - "No Gatehouse account matches this external account. Please register first.", - "ACCOUNT_NOT_FOUND", - 404, + auth_method = AuthenticationMethod( + user_id=user.id, + method_type=provider_type, + provider_user_id=user_info["provider_user_id"], + provider_data=ExternalAuthService._encrypt_provider_data(tokens, user_info), + verified=user_info.get("email_verified", False), + is_primary=True, + last_used_at=datetime.utcnow(), + ) + auth_method.save() + + AuditService.log_action( + action="user.register", + user_id=user.id, + organization_id=state_record.organization_id, + resource_type="user", + resource_id=user.id, + metadata={ + "provider_type": provider_type_str, + "provider_user_id": user_info["provider_user_id"], + "auto_registered": True, + }, + description=f"User auto-registered via {provider_type_str} OAuth", + success=True, + ) + else: + # Existing linked account — update provider data + auth_method.provider_data = ExternalAuthService._encrypt_provider_data( + tokens, user_info ) + auth_method.last_used_at = datetime.utcnow() + auth_method.save() user = auth_method.user - # Update provider data - auth_method.provider_data = ExternalAuthService._encrypt_provider_data( - tokens, user_info - ) - auth_method.last_used_at = datetime.utcnow() - auth_method.save() - # Get user's organizations user_orgs = user.get_organizations() # Determine target organization target_org = None - + # Priority 1: Use organization_id from state if provided (org hint) if state_record.organization_id: target_org = next( (org for org in user_orgs if org.id == state_record.organization_id), None ) - + # Priority 2: If user has exactly one organization, use it if not target_org and len(user_orgs) == 1: target_org = user_orgs[0] - - # Priority 3: No organization or multiple organizations - need selection + + # Priority 3: No orgs at all — auto-create a personal org and log in + if not target_org and len(user_orgs) == 0: + import re + import uuid + from gatehouse_app.services.organization_service import OrganizationService + org_name = f"{user_info.get('name') or user.email.split('@')[0]}'s Workspace" + # Build a URL-safe slug and ensure uniqueness with a short suffix + base_slug = re.sub(r"[^a-z0-9]+", "-", org_name.lower()).strip("-")[:40] + slug = f"{base_slug}-{uuid.uuid4().hex[:6]}" + org = OrganizationService.create_organization( + name=org_name, + slug=slug, + owner_user_id=user.id, + ) + target_org = org + logger.info( + f"OAuth login: auto-created org '{org.name}' (id={org.id}) " + f"for new user {user.id}" + ) + + # Priority 4: Multiple orgs — need user to pick one if not target_org: - # Mark state as used state_record.mark_used() - logger.info( f"OAuth login requires org selection for user={user.id}, " f"provider={provider_type_str}, org_count={len(user_orgs)}" ) - return { "success": True, "flow_type": "login", @@ -488,7 +534,7 @@ class OAuthFlowService: { "id": org.id, "name": org.name, - "slug": org.slug if hasattr(org, 'slug') else None, + "slug": org.slug if hasattr(org, "slug") else None, } for org in user_orgs ], diff --git a/gatehouse_app/services/oidc_service.py b/gatehouse_app/services/oidc_service.py index c2a6f7e..6157c21 100644 --- a/gatehouse_app/services/oidc_service.py +++ b/gatehouse_app/services/oidc_service.py @@ -190,9 +190,9 @@ class OIDCService: logger.debug("[OIDC SERVICE] Auth code expires_at (UTC): %s", auth_code.expires_at.isoformat() + "Z") logger.debug("[OIDC SERVICE] Current UTC time after creating auth code: %s", datetime.now(timezone.utc).isoformat() + "Z") - # Log authorization event + # Log authorization event — use client.id (UUID) not client_id (string) for FK OIDCAuditService.log_authorization_event( - client_id=client_id, + client_id=client.id, user_id=user_id, success=True, redirect_uri=redirect_uri, @@ -264,7 +264,7 @@ class OIDCService: if not auth_code: logger.error(f"[OIDC] Validate auth code - Code not found or deleted: code_hash={code_hash[:20]}...") OIDCAuditService.log_authorization_event( - client_id=client_id, + client_id=client.id, success=False, error_code="invalid_grant", error_description="Invalid or expired authorization code", @@ -275,7 +275,7 @@ class OIDCService: if auth_code.is_used: logger.error(f"[OIDC] Validate auth code - Code already used: code_hash={code_hash[:20]}..., user_id={auth_code.user_id}") OIDCAuditService.log_authorization_event( - client_id=client_id, + client_id=client.id, user_id=auth_code.user_id, success=False, error_code="invalid_grant", @@ -297,7 +297,7 @@ class OIDCService: logger.error("[OIDC] Validate auth code - Code expired: code_hash=%s..., expires_at (UTC)=%s, current UTC time=%s", code_hash[:20], auth_code.expires_at.isoformat() + "Z", datetime.now(timezone.utc).isoformat() + "Z") OIDCAuditService.log_authorization_event( - client_id=client_id, + client_id=client.id, user_id=auth_code.user_id, success=False, error_code="invalid_grant", @@ -324,7 +324,7 @@ class OIDCService: if expected_challenge != auth_code.code_verifier: logger.error(f"[OIDC] Validate auth code - Invalid code_verifier: expected={expected_challenge[:20]}..., got={auth_code.code_verifier[:20]}...") OIDCAuditService.log_authorization_event( - client_id=client_id, + client_id=client.id, user_id=auth_code.user_id, success=False, error_code="invalid_grant", @@ -526,9 +526,9 @@ class OIDCService: expires_at=id_token_expires_at, ) - # Log token event + # Log token event — use client.id (UUID) not client_id (string) for FK OIDCAuditService.log_token_event( - client_id=client_id, + client_id=client.id, user_id=user_id, token_type="access_token", success=True, @@ -607,7 +607,7 @@ class OIDCService: if not refresh_token_obj: OIDCAuditService.log_token_event( - client_id=client_id, + client_id=client.id, success=False, error_code="invalid_grant", error_description="Invalid refresh token", @@ -627,7 +627,7 @@ class OIDCService: if not refresh_token_obj.is_valid(): OIDCAuditService.log_token_event( - client_id=client_id, + client_id=client.id, user_id=refresh_token_obj.user_id, success=False, error_code="invalid_grant", @@ -694,9 +694,9 @@ class OIDCService: expires_at=access_token_expires_at, ) - # Log refresh event + # Log refresh event — use client.id (UUID) not client_id (string) for FK OIDCAuditService.log_token_event( - client_id=client_id, + client_id=client.id, user_id=refresh_token_obj.user_id, token_type="access_token", success=True, @@ -754,9 +754,14 @@ class OIDCService: logger.error("[OIDC SERVICE] Token validation failed: %s: %s", type(e).__name__, str(e)) import traceback logger.error("[OIDC SERVICE] Traceback: %s", traceback.format_exc()) + # Resolve internal client UUID for FK if possible + _client_db_id = None + if client_id: + _c = OIDCClient.query.filter_by(client_id=client_id).first() + _client_db_id = _c.id if _c else None OIDCAuditService.log_event( event_type="token_validation", - client_id=client_id, + client_id=_client_db_id, success=False, error_code="invalid_token", error_description=str(e), @@ -806,7 +811,7 @@ class OIDCService: revoked = True OIDCAuditService.log_token_revocation_event( - client_id=client_id, + client_id=client.id, user_id=refresh_token.user_id, token_type="refresh_token", reason="revoked_by_client", @@ -828,7 +833,7 @@ class OIDCService: revoked = True OIDCAuditService.log_token_revocation_event( - client_id=client_id, + client_id=client.id, user_id=claims.get("sub"), token_type="access_token", reason="revoked_by_client", @@ -859,10 +864,14 @@ class OIDCService: """ result = OIDCTokenService.introspect_token(token, client_id) - # Log introspection + # Log introspection — resolve internal client UUID for FK + _introspect_client_db_id = None + if client_id: + _ic = OIDCClient.query.filter_by(client_id=client_id).first() + _introspect_client_db_id = _ic.id if _ic else None OIDCAuditService.log_event( event_type="token_introspection", - client_id=client_id, + client_id=_introspect_client_db_id, user_id=result.get("sub"), success=result.get("active", False), metadata={"active": result.get("active")}, @@ -949,12 +958,17 @@ class OIDCService: logger.debug("[OIDC SERVICE] Final userinfo: %s", userinfo) - # Log userinfo access + # Log userinfo access — resolve internal client UUID for FK logger.debug("[OIDC SERVICE] Logging userinfo access event...") + _userinfo_client_id_str = claims.get("client_id") + _userinfo_client_db_id = None + if _userinfo_client_id_str: + _uc = OIDCClient.query.filter_by(client_id=_userinfo_client_id_str).first() + _userinfo_client_db_id = _uc.id if _uc else None OIDCAuditService.log_userinfo_event( access_token=access_token, user_id=user_id, - client_id=claims.get("client_id"), + client_id=_userinfo_client_db_id, success=True, scopes_claimed=scopes, ) diff --git a/gatehouse_app/services/totp_service.py b/gatehouse_app/services/totp_service.py index 0b61c43..b71f56a 100644 --- a/gatehouse_app/services/totp_service.py +++ b/gatehouse_app/services/totp_service.py @@ -164,9 +164,10 @@ class TOTPService: be used again. This ensures each code is single-use. """ remaining_codes = [] + matched = False for hashed_code in hashed_codes: - if bcrypt.check_password_hash(hashed_code, code): + if not matched and bcrypt.check_password_hash(hashed_code, code): # Code found and valid - mark as matched but don't add to remaining codes matched = True else: @@ -176,7 +177,7 @@ class TOTPService: if matched: return True, remaining_codes else: - return False, remaining_codes + return False, hashed_codes @staticmethod def generate_qr_code_data_uri(provisioning_uri: str) -> str: diff --git a/migrations/versions/005_fix_refresh_token_access_token_id.py b/migrations/versions/005_fix_refresh_token_access_token_id.py new file mode 100644 index 0000000..7a183fd --- /dev/null +++ b/migrations/versions/005_fix_refresh_token_access_token_id.py @@ -0,0 +1,72 @@ +"""Fix oidc_refresh_tokens.access_token_id — widen column and drop wrong FK + +The access_token_id column was VARCHAR(36) with a foreign key to sessions.id. +In practice the code stores JWT JTI strings (43+ chars) in this column, not +session UUIDs, so the FK constraint was wrong and the column was too narrow. + +This migration: + 1. Drops the foreign key constraint to sessions.id (IF EXISTS — may have been + applied manually already via raw SQL) + 2. Widens the column to VARCHAR(255) + +Revision ID: 005 +Revises: d2fd4f159054 +Create Date: 2026-02-25 +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.engine.reflection import Inspector + + +# revision identifiers, used by Alembic. +revision = '005' +down_revision = 'd2fd4f159054' +branch_labels = None +depends_on = None + + +def _fk_exists(conn, table_name, constraint_name): + """Check whether a named FK constraint exists on a table.""" + insp = Inspector.from_engine(conn) + fks = insp.get_foreign_keys(table_name) + return any(fk.get('name') == constraint_name for fk in fks) + + +def upgrade(): + conn = op.get_bind() + + # Drop the incorrect FK to sessions.id only if it still exists + # (may have been removed manually before this migration was written) + if _fk_exists(conn, 'oidc_refresh_tokens', 'oidc_refresh_tokens_access_token_id_fkey'): + op.drop_constraint( + 'oidc_refresh_tokens_access_token_id_fkey', + 'oidc_refresh_tokens', + type_='foreignkey' + ) + + # Widen the column to hold JWT JTI strings (43+ chars) + op.alter_column( + 'oidc_refresh_tokens', + 'access_token_id', + existing_type=sa.String(length=36), + type_=sa.String(length=255), + existing_nullable=True + ) + + +def downgrade(): + op.alter_column( + 'oidc_refresh_tokens', + 'access_token_id', + existing_type=sa.String(length=255), + type_=sa.String(length=36), + existing_nullable=True + ) + # Re-add the FK constraint to sessions.id + op.create_foreign_key( + 'oidc_refresh_tokens_access_token_id_fkey', + 'oidc_refresh_tokens', + 'sessions', + ['access_token_id'], + ['id'] + ) diff --git a/migrations/versions/d2fd4f159054_totp.py b/migrations/versions/d2fd4f159054_totp.py index 2f9b464..0ab474e 100644 --- a/migrations/versions/d2fd4f159054_totp.py +++ b/migrations/versions/d2fd4f159054_totp.py @@ -99,7 +99,7 @@ def upgrade(): sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('id'), - sa.UniqueConstraint('organization_id', 'provider_type', name='uix_org_provider_type') + sa.UniqueConstraint('organization_id', 'provider_type', name='uix_org_provider_override_type') ) op.create_index(op.f('ix_organization_provider_overrides_organization_id'), 'organization_provider_overrides', ['organization_id'], unique=False) op.create_index(op.f('ix_organization_provider_overrides_provider_type'), 'organization_provider_overrides', ['provider_type'], unique=False) diff --git a/scripts/configure_oauth_provider.py b/scripts/configure_oauth_provider.py index e222266..fe8bcc0 100755 --- a/scripts/configure_oauth_provider.py +++ b/scripts/configure_oauth_provider.py @@ -12,6 +12,12 @@ Usage: --client-secret "YOUR_CLIENT_SECRET" \\ --redirect-url "http://localhost:5173/auth/callback" + # Create a Microsoft provider configuration + python scripts/configure_oauth_provider.py create microsoft \\ + --client-id "YOUR_AZURE_APP_ID" \\ + --client-secret "YOUR_AZURE_CLIENT_SECRET" \\ + --redirect-url "http://localhost:5000/api/v1/auth/external/microsoft/callback" + # List all configured providers python scripts/configure_oauth_provider.py list @@ -27,6 +33,11 @@ Usage: # Use environment variables GOOGLE_CLIENT_ID=xxx GOOGLE_CLIENT_SECRET=yyy \\ python scripts/configure_oauth_provider.py create google + + # Use environment variables (Microsoft) + MICROSOFT_CLIENT_ID=xxx MICROSOFT_CLIENT_SECRET=yyy \\ + python scripts/configure_oauth_provider.py create microsoft + """ import os @@ -50,6 +61,33 @@ from gatehouse_app import create_app from gatehouse_app.services.external_auth_service import ExternalAuthService, ExternalAuthError +def _microsoft_defaults() -> dict: + """ + Build Microsoft provider defaults, honouring MICROSOFT_TENANT_ID if set. + + Tenant options: + - "common" : work/school AND personal Microsoft accounts (app must be + registered with "Accounts in any organizational directory + and personal Microsoft accounts" in Azure Portal) + - "consumers" : personal Microsoft accounts only (MSA) + - "organizations": work/school accounts only (AAD) + - "": single specific Azure AD tenant (most secure for enterprise) + + Set MICROSOFT_TENANT_ID env var or pass --tenant-id to the script. + """ + tenant = os.environ.get("MICROSOFT_TENANT_ID", "common") + base = f"https://login.microsoftonline.com/{tenant}" + return { + "auth_url": f"{base}/oauth2/v2.0/authorize", + "token_url": f"{base}/oauth2/v2.0/token", + "userinfo_url": "https://graph.microsoft.com/oidc/userinfo", + "jwks_url": f"{base}/discovery/v2.0/keys", + # offline_access is required by Microsoft to receive a refresh token + # (unlike Google which uses access_type=offline as a query param) + "scopes": ["openid", "profile", "email", "offline_access"], + } + + # Provider endpoint configurations PROVIDER_DEFAULTS = { "google": { @@ -65,13 +103,7 @@ PROVIDER_DEFAULTS = { "userinfo_url": "https://api.github.com/user", "scopes": ["read:user", "user:email"], }, - "microsoft": { - "auth_url": "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", - "token_url": "https://login.microsoftonline.com/common/oauth2/v2.0/token", - "userinfo_url": "https://graph.microsoft.com/oidc/userinfo", - "jwks_url": "https://login.microsoftonline.com/common/discovery/v2.0/keys", - "scopes": ["openid", "profile", "email"], - }, + "microsoft": _microsoft_defaults(), } @@ -163,6 +195,25 @@ def create_provider(args): return 1 defaults = PROVIDER_DEFAULTS[provider_type] + + # For Microsoft, allow --tenant-id / MICROSOFT_TENANT_ID to override URLs at create time + if provider_type == "microsoft": + tenant_id = getattr(args, "tenant_id", None) or os.environ.get("MICROSOFT_TENANT_ID") + if tenant_id and tenant_id != os.environ.get("MICROSOFT_TENANT_ID", "common"): + # Recompute URLs with the supplied tenant + os.environ["MICROSOFT_TENANT_ID"] = tenant_id + defaults = _microsoft_defaults() + print_info(f"Using Microsoft tenant: {tenant_id}") + elif tenant_id: + print_info(f"Using Microsoft tenant: {tenant_id}") + else: + print_warning( + "No --tenant-id provided; using 'common'.\n" + " • For personal Microsoft accounts: your Azure app must be registered with\n" + " 'Accounts in any organizational directory and personal Microsoft accounts'.\n" + " • For work/school only: use --tenant-id organizations\n" + " • For a single Azure AD tenant: use --tenant-id " + ) # Build configuration config_data = { @@ -231,6 +282,9 @@ def update_provider(args): if args.enabled is not None: updates["is_enabled"] = args.enabled + if args.scopes: + updates["scopes"] = [s.strip() for s in args.scopes.split(",")] + if args.settings: settings = {} for setting in args.settings: @@ -443,6 +497,13 @@ Supported Providers: create_parser.add_argument("--redirect-url", help="Default redirect URL for OAuth callbacks") create_parser.add_argument("--disabled", action="store_true", help="Create provider in disabled state") create_parser.add_argument("--settings", action="append", help="Custom settings (key=value format)") + create_parser.add_argument( + "--tenant-id", + help=( + "Microsoft only: Azure AD tenant ID (or 'common' / 'consumers' / 'organizations'). " + "Defaults to the MICROSOFT_TENANT_ID env var, then 'common'." + ), + ) create_parser.set_defaults(func=create_provider) # Update command @@ -453,6 +514,7 @@ Supported Providers: update_parser.add_argument("--redirect-url", help="New default redirect URL") update_parser.add_argument("--enabled", type=lambda x: x.lower() in ['true', '1', 'yes'], help="Enable or disable the provider (true/false)") + update_parser.add_argument("--scopes", help="Comma-separated list of OAuth scopes to set (e.g. 'openid,profile,email,offline_access')") update_parser.add_argument("--settings", action="append", help="Custom settings to update (key=value format)") update_parser.set_defaults(func=update_provider)