"""OIDC (OpenID Connect) API endpoints - Root level blueprint.""" import base64 import json import secrets from urllib.parse import urlencode, urlparse, parse_qs import bcrypt from flask import Blueprint, request, redirect, jsonify, session, g, current_app, Response from app.utils.response import api_response from app.services.oidc_service import ( OIDCService, InvalidClientError, InvalidGrantError, InvalidRequestError ) from app.services.auth_service import AuthService from app.extensions import db from app.models import User, OIDCClient from app.models.organization import Organization from app.exceptions.auth_exceptions import InvalidCredentialsError # Create OIDC blueprint registered at root level oidc_bp = Blueprint("oidc", __name__) # ============================================================================ # Helper Functions # ============================================================================ def get_oidc_config(): """Get OIDC configuration from app config.""" base_url = current_app.config.get("OIDC_ISSUER_URL", "http://localhost:5000") return { "issuer": base_url, "authorization_endpoint": f"{base_url}/oidc/authorize", "token_endpoint": f"{base_url}/oidc/token", "userinfo_endpoint": f"{base_url}/oidc/userinfo", "jwks_uri": f"{base_url}/oidc/jwks", "registration_endpoint": f"{base_url}/oidc/register", "revocation_endpoint": f"{base_url}/oidc/revoke", "introspection_endpoint": f"{base_url}/oidc/introspect", "scopes_supported": ["openid", "profile", "email"], "response_types_supported": ["code"], "response_modes_supported": ["query"], "grant_types_supported": ["authorization_code", "refresh_token"], "token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"], "subject_types_supported": ["public"], "id_token_signing_alg_values_supported": ["RS256"], "claims_supported": ["sub", "name", "email", "email_verified"], } def authenticate_client(client_id, client_secret=None): """Authenticate an OIDC client. Args: client_id: The client ID client_secret: Optional client secret Returns: OIDCClient instance Raises: InvalidClientError: If authentication fails """ client = OIDCClient.query.filter_by(client_id=client_id, is_active=True).first() if not client: raise InvalidClientError("Invalid client") if client.is_confidential and client_secret: if not bcrypt.check_password_hash(client.client_secret_hash, client_secret): raise InvalidClientError("Invalid client credentials") return client def require_valid_token(): """Validate Bearer token from Authorization header. Sets g.current_token and g.current_user on success. Raises: InvalidGrantError: If token is invalid """ auth_header = request.headers.get("Authorization", "") if not auth_header.startswith("Bearer "): raise InvalidGrantError("Invalid token: Missing or invalid Authorization header") token = auth_header[7:] claims = OIDCService.validate_access_token(token) g.current_token = claims user = User.query.get(claims.get("sub")) if not user: raise InvalidGrantError("Invalid token: User not found") g.current_user = user def parse_basic_auth(): """Parse Basic authentication from Authorization header. Returns: Tuple of (client_id, client_secret) or (None, None) """ auth_header = request.headers.get("Authorization", "") if auth_header.startswith("Basic "): try: encoded = auth_header[6:] decoded = base64.b64decode(encoded).decode("utf-8") client_id, client_secret = decoded.split(":", 1) return client_id, client_secret except Exception: pass return None, None # ============================================================================ # Discovery Endpoint # ============================================================================ @oidc_bp.route("/.well-known/openid-configuration", methods=["GET"]) def oidc_discovery(): """OpenID Connect Discovery endpoint. Returns the OIDC configuration as JSON. Cache-Control: max-age=86400 No authentication required. Returns: 200: OIDC discovery document """ config = get_oidc_config() response = jsonify(config) response.headers["Cache-Control"] = "max-age=86400" return response, 200 # ============================================================================ # Authorization Endpoint # ============================================================================ @oidc_bp.route("/oidc/authorize", methods=["GET", "POST"]) def oidc_authorize(): """OpenID Connect Authorization endpoint. Initiates the OIDC authentication flow. GET Parameters: client_id: The client ID redirect_uri: The redirect URI response_type: Must be "code" for authorization code flow scope: Space-separated scopes (e.g., "openid profile email") state: Opaque state value for CSRF protection nonce: Nonce for ID token replay protection code_challenge: PKCE code challenge code_challenge_method: PKCE method ("S256" or "plain") prompt: "login", "consent", "select_account", "none" max_age: Maximum authentication age in seconds acr_values: Requested Authentication Context Class Reference POST Parameters: Same as GET, plus: email: User email password: User password Returns: 302: Redirect with authorization code or error 200: Login page (GET when not authenticated) 400: Invalid request """ # Parse request parameters if request.method == "GET": params = request.args.to_dict() else: params = request.form.to_dict() # Extract required parameters client_id = params.get("client_id") redirect_uri = params.get("redirect_uri") response_type = params.get("response_type") scope = params.get("scope", "") state = params.get("state", "") nonce = params.get("nonce", "") code_challenge = params.get("code_challenge") code_challenge_method = params.get("code_challenge_method") # Validate required parameters errors = [] if not client_id: errors.append("client_id is required") if not redirect_uri: errors.append("redirect_uri is required") if not response_type: errors.append("response_type is required") if errors: return _redirect_with_error(redirect_uri, "invalid_request", "; ".join(errors), state) # Validate response_type if response_type != "code": return _redirect_with_error( redirect_uri, "unsupported_response_type", "Only response_type=code is supported", state ) # Validate client client = OIDCClient.query.filter_by(client_id=client_id, is_active=True).first() if not client: return _redirect_with_error(redirect_uri, "unauthorized_client", "Invalid client", state) # Validate redirect URI if not client.is_redirect_uri_allowed(redirect_uri): return _redirect_with_error(redirect_uri, "invalid_request", "Invalid redirect_uri", state) # Validate scopes requested_scopes = scope.split() if scope else [] allowed_scopes = client.scopes or [] valid_scopes = [s for s in requested_scopes if s in allowed_scopes] if not valid_scopes: return _redirect_with_error(redirect_uri, "invalid_scope", "Invalid or no scopes requested", state) # Check if user is already authenticated via session user_id = session.get("oidc_user_id") # Handle POST with credentials if request.method == "POST" and not user_id: email = params.get("email") password = params.get("password") if not email or not password: return _show_login_page( client_id=client_id, redirect_uri=redirect_uri, scope=scope, state=state, nonce=nonce, response_type=response_type, error="Invalid credentials" ) try: user = AuthService.authenticate(email, password) user_id = user.id session["oidc_user_id"] = user_id except InvalidCredentialsError: return _show_login_page( client_id=client_id, redirect_uri=redirect_uri, scope=scope, state=state, nonce=nonce, response_type=response_type, error="Invalid email or password" ) # If no user, show login page if not user_id: return _show_login_page( client_id=client_id, redirect_uri=redirect_uri, scope=scope, state=state, nonce=nonce, response_type=response_type ) # User is authenticated, generate authorization code user = User.query.get(user_id) if not user: return _redirect_with_error(redirect_uri, "server_error", "User not found", state) try: code = OIDCService.generate_authorization_code( client_id=client_id, user_id=user_id, redirect_uri=redirect_uri, scope=valid_scopes, state=state, nonce=nonce, code_challenge=code_challenge, code_challenge_method=code_challenge_method, ip_address=request.remote_addr, user_agent=request.headers.get("User-Agent"), ) except Exception as e: return _redirect_with_error(redirect_uri, "server_error", str(e), state) # Redirect with authorization code redirect_params = {"code": code} if state: redirect_params["state"] = state return redirect(f"{redirect_uri}?{urlencode(redirect_params)}") def _redirect_with_error(redirect_uri, error, error_description, state=None): """Redirect to client with error parameters.""" if not redirect_uri: return api_response( success=False, message=error_description, status=400, error_type=error.upper(), error_details={"error": error, "error_description": error_description}, ) params = { "error": error, "error_description": error_description, } if state: params["state"] = state return redirect(f"{redirect_uri}?{urlencode(params)}") 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

""" return Response(html, mimetype="text/html"), 200 # ============================================================================ # Token Endpoint # ============================================================================ @oidc_bp.route("/oidc/token", methods=["POST"]) def oidc_token(): """OpenID Connect Token endpoint. Exchanges authorization code for tokens or refreshes tokens. Request body (application/x-www-form-urlencoded): grant_type: "authorization_code" or "refresh_token" For authorization_code: code: The authorization code redirect_uri: The redirect URI used in authorization client_id: The client ID client_secret: The client secret (optional if using Basic auth) code_verifier: PKCE code verifier (optional) For refresh_token: refresh_token: The refresh token scope: Optional scope override client_id: The client ID client_secret: The client secret (optional if using Basic auth) Authentication: - Basic auth with client_id:client_secret, or - client_id + client_secret in request body Returns: 200: JSON with tokens 400: Invalid request 401: Invalid client """ # Parse request body if request.content_type and "application/x-www-form-urlencoded" in request.content_type: data = request.form.to_dict() else: data = request.json or {} grant_type = data.get("grant_type") # Validate grant_type if not grant_type: return api_response( success=False, message="grant_type is required", status=400, error_type="INVALID_REQUEST", error_details={"error": "invalid_request", "error_description": "grant_type is required"}, ) # Authenticate client client_id = data.get("client_id") client_secret = data.get("client_secret") # Try Basic auth if client_id not in body if not client_id: client_id, client_secret = parse_basic_auth() if not client_id: # Return 401 with WWW-Authenticate header for Basic auth response = jsonify({ "error": "invalid_client", "error_description": "Client authentication required" }) response.headers["WWW-Authenticate"] = 'Basic realm="OIDC Token Endpoint"' return response, 401 try: client = authenticate_client(client_id, client_secret) except InvalidClientError: response = jsonify({ "error": "invalid_client", "error_description": "Invalid client credentials" }) return response, 401 # Handle authorization_code grant if grant_type == "authorization_code": return _handle_authorization_code_grant(data, client) # Handle refresh_token grant elif grant_type == "refresh_token": return _handle_refresh_token_grant(data, client) # Unsupported grant type else: return api_response( success=False, message="Unsupported grant_type", status=400, error_type="UNSUPPORTED_GRANT_TYPE", error_details={"error": "unsupported_grant_type", "error_description": f"Grant type '{grant_type}' is not supported"}, ) def _handle_authorization_code_grant(data, client): """Handle authorization_code grant type.""" code = data.get("code") redirect_uri = data.get("redirect_uri") code_verifier = data.get("code_verifier") if not code: return api_response( success=False, message="code is required", status=400, error_type="INVALID_REQUEST", error_details={"error": "invalid_request", "error_description": "code is required"}, ) if not redirect_uri: return api_response( success=False, message="redirect_uri is required", status=400, error_type="INVALID_REQUEST", error_details={"error": "invalid_request", "error_description": "redirect_uri is required"}, ) try: claims, user = OIDCService.validate_authorization_code( code=code, client_id=client.client_id, redirect_uri=redirect_uri, code_verifier=code_verifier, ip_address=request.remote_addr, user_agent=request.headers.get("User-Agent"), ) except InvalidGrantError as e: return api_response( success=False, message=str(e), status=400, error_type="INVALID_GRANT", error_details={"error": "invalid_grant", "error_description": str(e)}, ) # Generate tokens try: tokens = OIDCService.generate_tokens( client_id=client.client_id, user_id=claims["user_id"], scope=claims["scope"], nonce=claims.get("nonce"), ip_address=request.remote_addr, user_agent=request.headers.get("User-Agent"), auth_time=int(__import__("time").time()), ) except Exception as e: return api_response( success=False, message="Failed to generate tokens", status=500, error_type="SERVER_ERROR", error_details={"error": "server_error", "error_description": str(e)}, ) return api_response( data=tokens, message="Tokens issued successfully", status=200, ) def _handle_refresh_token_grant(data, client): """Handle refresh_token grant type.""" refresh_token = data.get("refresh_token") scope = data.get("scope") if not refresh_token: return api_response( success=False, message="refresh_token is required", status=400, error_type="INVALID_REQUEST", error_details={"error": "invalid_request", "error_description": "refresh_token is required"}, ) # Parse scope if provided scope_list = scope.split() if scope else None try: tokens = OIDCService.refresh_access_token( refresh_token=refresh_token, client_id=client.client_id, scope=scope_list, ip_address=request.remote_addr, user_agent=request.headers.get("User-Agent"), ) except InvalidGrantError as e: return api_response( success=False, message=str(e), status=400, error_type="INVALID_GRANT", error_details={"error": "invalid_grant", "error_description": str(e)}, ) return api_response( data=tokens, message="Tokens refreshed successfully", status=200, ) # ============================================================================ # UserInfo Endpoint # ============================================================================ @oidc_bp.route("/oidc/userinfo", methods=["GET", "POST"]) def oidc_userinfo(): """OpenID Connect UserInfo endpoint. Returns claims about the authenticated user. Authorization: Bearer {access_token} Returns claims based on granted scopes: - sub: User ID (always included) - name: User full name (if "profile" scope) - email: User email (if "email" scope) - email_verified: Email verification status (if "email" scope) Returns: 200: User claims 401: Invalid or insufficient token """ try: require_valid_token() except InvalidGrantError as e: return api_response( success=False, message=str(e), status=401, error_type="INVALID_TOKEN", error_details={"error": "invalid_token", "error_description": str(e)}, ) # Get userinfo try: userinfo = OIDCService.get_userinfo(g.current_token.get("access_token", "")) except Exception as e: return api_response( success=False, message="Failed to get user info", status=500, error_type="SERVER_ERROR", error_details={"error": "server_error", "error_description": str(e)}, ) return api_response( data=userinfo, message="User info retrieved successfully", status=200, ) # ============================================================================ # JWKS Endpoint # ============================================================================ @oidc_bp.route("/oidc/jwks", methods=["GET"]) def oidc_jwks(): """OpenID Connect JSON Web Key Set endpoint. Returns the public keys used to sign tokens. Cache-Control: max-age=3600 No authentication required. Returns: 200: JWKS document """ try: jwks = OIDCService.get_jwks() except Exception as e: return api_response( success=False, message="Failed to get JWKS", status=500, error_type="SERVER_ERROR", error_details={"error": "server_error", "error_description": str(e)}, ) response = jsonify(jwks) response.headers["Cache-Control"] = "max-age=3600" return response, 200 # ============================================================================ # Token Revocation Endpoint # ============================================================================ @oidc_bp.route("/oidc/revoke", methods=["POST"]) def oidc_revoke(): """OAuth2 Token Revocation endpoint. Revokes an access token or refresh token. Request body (application/x-www-form-urlencoded): token: The token to revoke token_type_hint: Optional hint ("access_token" or "refresh_token") client_id: The client ID client_secret: The client secret (optional if using Basic auth) Authentication: - Basic auth with client_id:client_secret, or - client_id + client_secret in request body Returns: 200: Token revoked successfully 400: Invalid request 401: Invalid client """ # Parse request body if request.content_type and "application/x-www-form-urlencoded" in request.content_type: data = request.form.to_dict() else: data = request.json or {} token = data.get("token") if not token: return api_response( success=False, message="token is required", status=400, error_type="INVALID_REQUEST", error_details={"error": "invalid_request", "error_description": "token is required"}, ) # Authenticate client client_id = data.get("client_id") client_secret = data.get("client_secret") if not client_id: client_id, client_secret = parse_basic_auth() if not client_id: response = jsonify({ "error": "invalid_client", "error_description": "Client authentication required" }) response.headers["WWW-Authenticate"] = 'Basic realm="OIDC Revoke Endpoint"' return response, 401 try: client = authenticate_client(client_id, client_secret) except InvalidClientError: response = jsonify({ "error": "invalid_client", "error_description": "Invalid client credentials" }) return response, 401 token_type_hint = data.get("token_type_hint") try: OIDCService.revoke_token( token=token, client_id=client.client_id, token_type_hint=token_type_hint, ip_address=request.remote_addr, user_agent=request.headers.get("User-Agent"), ) except Exception as e: # Revocation should succeed even if token is invalid pass return api_response( message="Token revoked successfully", status=200, ) # ============================================================================ # Token Introspection Endpoint # ============================================================================ @oidc_bp.route("/oidc/introspect", methods=["POST"]) def oidc_introspect(): """OAuth2 Token Introspection endpoint. Returns information about a token. Request body (application/x-www-form-urlencoded): token: The token to introspect token_type_hint: Optional hint ("access_token" or "refresh_token") client_id: The client ID client_secret: The client secret (optional if using Basic auth) Authentication: - Basic auth with client_id:client_secret, or - client_id + client_secret in request body Returns: 200: Token status and claims 400: Invalid request 401: Invalid client """ # Parse request body if request.content_type and "application/x-www-form-urlencoded" in request.content_type: data = request.form.to_dict() else: data = request.json or {} token = data.get("token") if not token: return api_response( success=False, message="token is required", status=400, error_type="INVALID_REQUEST", error_details={"error": "invalid_request", "error_description": "token is required"}, ) # Authenticate client client_id = data.get("client_id") client_secret = data.get("client_secret") if not client_id: client_id, client_secret = parse_basic_auth() if not client_id: response = jsonify({ "error": "invalid_client", "error_description": "Client authentication required" }) response.headers["WWW-Authenticate"] = 'Basic realm="OIDC Introspect Endpoint"' return response, 401 try: client = authenticate_client(client_id, client_secret) except InvalidClientError: response = jsonify({ "error": "invalid_client", "error_description": "Invalid client credentials" }) return response, 401 token_type_hint = data.get("token_type_hint") try: result = OIDCService.introspect_token( token=token, client_id=client.client_id, ip_address=request.remote_addr, user_agent=request.headers.get("User-Agent"), ) except Exception as e: return api_response( success=False, message="Failed to introspect token", status=500, error_type="SERVER_ERROR", error_details={"error": "server_error", "error_description": str(e)}, ) return api_response( data=result, message="Token introspection successful", status=200, ) # ============================================================================ # Client Registration Endpoint (Optional) # ============================================================================ @oidc_bp.route("/oidc/register", methods=["POST"]) def oidc_register(): """OpenID Connect Client Registration endpoint. Registers a new OIDC client. Request body (application/json): client_name: Name of the client redirect_uris: List of redirect URIs token_endpoint_auth_method: "client_secret_basic" or "client_secret_post" grant_types: List of grant types ["authorization_code", "refresh_token"] response_types: List of response types ["code"] scope: Space-separated scopes (default: "openid profile email") Returns: 201: Client registered successfully 400: Invalid request """ data = request.json or {} # Validate required fields client_name = data.get("client_name") redirect_uris = data.get("redirect_uris", []) if not client_name: return api_response( success=False, message="client_name is required", status=400, error_type="INVALID_REQUEST", error_details={"error": "invalid_request", "error_description": "client_name is required"}, ) if not redirect_uris: return api_response( success=False, message="redirect_uris is required", status=400, error_type="INVALID_REQUEST", error_details={"error": "invalid_request", "error_description": "redirect_uris is required"}, ) # Validate redirect_uris for uri in redirect_uris: try: parsed = urlparse(uri) if not parsed.scheme or not parsed.netloc: raise ValueError(f"Invalid redirect URI: {uri}") except Exception: return api_response( success=False, message=f"Invalid redirect_uri: {uri}", status=400, error_type="INVALID_REQUEST", error_details={"error": "invalid_request", "error_description": f"Invalid redirect_uri: {uri}"}, ) # Generate client credentials client_id = f"oidc_{secrets.token_urlsafe(16)}" client_secret = f"secret_{secrets.token_urlsafe(24)}" client_secret_hash = bcrypt.generate_password_hash(client_secret).decode("utf-8") # Get organization from request or default org_id = data.get("organization_id") if org_id: organization = Organization.query.get(org_id) else: # Get first active organization or create a default one organization = Organization.query.filter_by(is_active=True).first() if not organization: # Create a default organization for the client organization = Organization( name=f"OIDC Clients", slug=f"oidc-clients-{secrets.token_urlsafe(8)}", ) organization.save() # Create OIDC client client = OIDCClient( organization_id=organization.id, name=client_name, client_id=client_id, client_secret_hash=client_secret_hash, redirect_uris=redirect_uris, grant_types=data.get("grant_types", ["authorization_code", "refresh_token"]), response_types=data.get("response_types", ["code"]), scopes=data.get("scope", "openid profile email").split(), token_endpoint_auth_method=data.get("token_endpoint_auth_method", "client_secret_basic"), is_active=True, is_confidential=True, require_pkce=True, logo_uri=data.get("logo_uri"), client_uri=data.get("client_uri"), policy_uri=data.get("policy_uri"), tos_uri=data.get("tos_uri"), ) client.save() # Return client credentials return api_response( data={ "client_id": client_id, "client_secret": client_secret, "client_id_issued_at": int(__import__("time").time()), "client_secret_expires_at": 0, # Never expires "client_name": client_name, "redirect_uris": redirect_uris, "token_endpoint_auth_method": data.get("token_endpoint_auth_method", "client_secret_basic"), "grant_types": client.grant_types, "response_types": client.response_types, "scope": " ".join(client.scopes), }, message="Client registered successfully", status=201, )