From 24159466477852628fe6206b188bd4db27567b7d Mon Sep 17 00:00:00 2001 From: Francesco Servida Date: Fri, 26 Jan 2024 19:59:42 +0300 Subject: [PATCH 01/26] Initial skeleton of SSO with Headers --- backend/btrixcloud/auth.py | 28 ++++++++++++++ frontend/src/pages/log-in.ts | 63 +++++++++++++++++++++++++++++-- frontend/src/utils/AuthService.ts | 29 ++++++++++++++ 3 files changed, 117 insertions(+), 3 deletions(-) diff --git a/backend/btrixcloud/auth.py b/backend/btrixcloud/auth.py index d2c2fcbfce..39642edeef 100644 --- a/backend/btrixcloud/auth.py +++ b/backend/btrixcloud/auth.py @@ -17,6 +17,7 @@ Depends, WebSocket, APIRouter, + Header, ) from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm @@ -227,6 +228,33 @@ async def send_reset_if_needed(): # successfully logged in, reset failed logins, return user await user_manager.reset_failed_logins(login_email) return get_bearer_response(user) + + @auth_jwt_router.post("/login_header", response_model=BearerResponse) + async def login_header( + x_remote_user: str | None = Header(default=None), + x_remote_email: str | None = Header(default=None), + x_remote_groups: str | None = Header(default=None) + ) -> BearerResponse: + + login_email = x_remote_email + login_name = x_remote_user + + user = await user_manager.get_by_email(login_email) + if user: + # User exist, login immediately + return get_bearer_response(user) + else: + # Create verified user + await user_manager.create_non_super_user(login_email, None, login_name) + user = await user_manager.get_by_email(login_email) + if user: + # User exist, login immediately + return get_bearer_response(user) + else: + raise HTTPException( + status_code=500, + detail="user_creation_failed", + ) @auth_jwt_router.post("/refresh", response_model=BearerResponse) async def refresh_jwt(user=Depends(current_active_user)): diff --git a/frontend/src/pages/log-in.ts b/frontend/src/pages/log-in.ts index 1a252eb51d..bdf1ed9044 100644 --- a/frontend/src/pages/log-in.ts +++ b/frontend/src/pages/log-in.ts @@ -218,7 +218,9 @@ export class LogInPage extends LiteElement { ${successMessage}
+
${this.renderFormError()}
${form}
+
${this.renderLoginHeaderButton()}
@@ -235,7 +237,7 @@ export class LogInPage extends LiteElement { } } - private renderLoginForm() { + private renderFormError() { let formError; if (this.formState.context.serverError) { @@ -248,6 +250,11 @@ export class LogInPage extends LiteElement { `; } + return formError + } + + private renderLoginForm() { + return html`
@@ -274,8 +281,6 @@ export class LogInPage extends LiteElement {
- ${formError} - + ${msg("Log In with Single Sign On")} + + `; + } + private renderForgotPasswordForm() { let formError; @@ -395,6 +416,42 @@ export class LogInPage extends LiteElement { } } + async onSubmitLogInHeader(event: SubmitEvent) { + event.preventDefault(); + this.formStateService.send("SUBMIT"); + + try { + const data = await AuthService.login_header({}); + + this.dispatchEvent( + AuthService.createLoggedInEvent({ + ...data, + redirectUrl: this.redirectUrl, + }) + ); + + // no state update here, since "btrix-logged-in" event + // will result in a route change + } catch (e: any) { + if (e.isApiError) { + let message = msg("Sorry, an error occurred while attempting Single Sign On"); + this.formStateService.send({ + type: "ERROR", + detail: { + serverError: message, + }, + }); + } else { + this.formStateService.send({ + type: "ERROR", + detail: { + serverError: msg("Something went wrong, couldn't sign you in"), + }, + }); + } + } + } + async onSubmitResetPassword(event: SubmitEvent) { event.preventDefault(); this.formStateService.send("SUBMIT"); diff --git a/frontend/src/utils/AuthService.ts b/frontend/src/utils/AuthService.ts index 89ffebbc31..bf411f5455 100644 --- a/frontend/src/utils/AuthService.ts +++ b/frontend/src/utils/AuthService.ts @@ -158,6 +158,35 @@ export default class AuthService { }; } + static async login_header({}: { + }): Promise { + const resp = await fetch("/api/auth/jwt/login_header", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + }).toString(), + }); + + if (resp.status !== 200) { + throw new APIError({ + message: resp.statusText, + status: resp.status, + }); + } + + const data = await resp.json(); + const token = AuthService.decodeToken(data.access_token); + const authHeaders = AuthService.parseAuthHeaders(data); + + return { + username: "test_user", + headers: authHeaders, + tokenExpiresAt: token.exp * 1000, + }; + } + /** * Decode JSON web token returned as access token */ From aa4f20293c9509db416fd7ab8a9087d85f69be7c Mon Sep 17 00:00:00 2001 From: Francesco Servida Date: Sat, 27 Jan 2024 13:44:47 +0300 Subject: [PATCH 02/26] Update User Org Membership on creation and login from Header variable --- backend/btrixcloud/auth.py | 40 +++++++++++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/backend/btrixcloud/auth.py b/backend/btrixcloud/auth.py index 39642edeef..e6d4b52be3 100644 --- a/backend/btrixcloud/auth.py +++ b/backend/btrixcloud/auth.py @@ -22,7 +22,7 @@ from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm -from .models import User +from .models import User, UserRole # ============================================================================ @@ -138,6 +138,27 @@ def generate_password() -> str: return pwd.genword() +# ============================================================================ +async def update_user_orgs(groups: [str], user, ops): + orgs = await ops.get_org_slugs_by_ids() + user_orgs, _ = await ops.get_orgs_for_user(user) + for org_id, slug in orgs.items(): + if slug.lower() in groups: + already_in_org = False + for user_org in user_orgs: + if user_org.slug == slug: + # User is already in org, no need to add + already_in_org = True + if not already_in_org: + org = await ops.get_org_by_id(org_id) + await ops.add_user_to_org(org, user.id, UserRole.CRAWLER) + + for org in user_orgs: + if org.slug.lower() not in groups: + del org.users[str(user.id)] + await ops.update_users(org) + + # ============================================================================ # pylint: disable=raise-missing-from def init_jwt_auth(user_manager): @@ -236,19 +257,32 @@ async def login_header( x_remote_groups: str | None = Header(default=None) ) -> BearerResponse: + if not (x_remote_user is not None and x_remote_email is not None and x_remote_groups is not None): + raise HTTPException( + status_code=500, + detail="invalid_parameters_for_login", + ) + + SSO_GROUP_SEPARATOR = ";" # Potentially dynamically pull this from config + login_email = x_remote_email login_name = x_remote_user + groups = [group.lower() for group in x_remote_groups.split(SSO_GROUP_SEPARATOR)] user = await user_manager.get_by_email(login_email) + ops = user_manager.org_ops + if user: - # User exist, login immediately + await update_user_orgs(groups, user, ops) + # User exist, and correct orgs have been set, proceed to login return get_bearer_response(user) else: # Create verified user await user_manager.create_non_super_user(login_email, None, login_name) user = await user_manager.get_by_email(login_email) if user: - # User exist, login immediately + await update_user_orgs(groups, user, ops) + # User has been created and correct orgs have been set, proceed to login return get_bearer_response(user) else: raise HTTPException( From 495dfe74956c33515cf31b1304b66824b82049f3 Mon Sep 17 00:00:00 2001 From: Francesco Servida Date: Sat, 27 Jan 2024 14:52:09 +0300 Subject: [PATCH 03/26] Initial Config Setting, allow Admin to enable/disable SSO --- backend/btrixcloud/auth.py | 28 ++++++++++++++++++++++++---- chart/templates/configmap.yaml | 4 ++++ frontend/src/utils/AuthService.ts | 9 +-------- 3 files changed, 29 insertions(+), 12 deletions(-) diff --git a/backend/btrixcloud/auth.py b/backend/btrixcloud/auth.py index e6d4b52be3..2d9b8bd119 100644 --- a/backend/btrixcloud/auth.py +++ b/backend/btrixcloud/auth.py @@ -36,6 +36,9 @@ PWD_CONTEXT = CryptContext(schemes=["bcrypt"], deprecated="auto") +SSO_HEADER_ENABLED = bool(int(os.environ.get("SSO_HEADER_ENABLED", 0))) +SSO_HEADER_GROUPS_SEPARATOR = os.environ.get("SSO_HEADER_GROUPS_SEPARATOR", ";") + # Audiences AUTH_AUD = "btrix:auth" RESET_AUD = "btrix:reset" @@ -56,6 +59,8 @@ class BearerResponse(BaseModel): access_token: str token_type: str +class LoginMethodsInquiryResponse(BaseModel): + login_methods: dict # ============================================================================ # pylint: disable=too-few-public-methods @@ -250,24 +255,28 @@ async def send_reset_if_needed(): await user_manager.reset_failed_logins(login_email) return get_bearer_response(user) - @auth_jwt_router.post("/login_header", response_model=BearerResponse) + @auth_jwt_router.get("/login_header", response_model=BearerResponse) async def login_header( x_remote_user: str | None = Header(default=None), x_remote_email: str | None = Header(default=None), x_remote_groups: str | None = Header(default=None) ) -> BearerResponse: + if not SSO_HEADER_ENABLED: + raise HTTPException( + status_code=405, + detail="sso_is_disabled", + ) + if not (x_remote_user is not None and x_remote_email is not None and x_remote_groups is not None): raise HTTPException( status_code=500, detail="invalid_parameters_for_login", ) - - SSO_GROUP_SEPARATOR = ";" # Potentially dynamically pull this from config login_email = x_remote_email login_name = x_remote_user - groups = [group.lower() for group in x_remote_groups.split(SSO_GROUP_SEPARATOR)] + groups = [group.lower() for group in x_remote_groups.split(SSO_HEADER_GROUPS_SEPARATOR)] user = await user_manager.get_by_email(login_email) ops = user_manager.org_ops @@ -290,6 +299,17 @@ async def login_header( detail="user_creation_failed", ) + @auth_jwt_router.get("/login_methods", response_model=LoginMethodsInquiryResponse) + async def login_header() -> LoginMethodsInquiryResponse: + enabled_login_methods = { + 'password': True, + 'sso_header': False + } + + if SSO_HEADER_ENABLED: + enabled_login_methods['sso_header'] = True + return LoginMethodsInquiryResponse(login_methods=enabled_login_methods) + @auth_jwt_router.post("/refresh", response_model=BearerResponse) async def refresh_jwt(user=Depends(current_active_user)): return get_bearer_response(user) diff --git a/chart/templates/configmap.yaml b/chart/templates/configmap.yaml index 9eb8d9e422..f42ffdc36f 100644 --- a/chart/templates/configmap.yaml +++ b/chart/templates/configmap.yaml @@ -50,6 +50,10 @@ data: CRAWLER_CHANNELS_JSON: "/ops-configs/crawler_channels.json" + SSO_HEADER_ENABLED: "{{ .Values.sso_header_enabled | default 0 }}" + + SSO_HEADER_GROUPS_SEPARATOR: {{ .Values.sso_header_groups_separator | default ";" }} + --- apiVersion: v1 kind: ConfigMap diff --git a/frontend/src/utils/AuthService.ts b/frontend/src/utils/AuthService.ts index bf411f5455..b054a2656f 100644 --- a/frontend/src/utils/AuthService.ts +++ b/frontend/src/utils/AuthService.ts @@ -160,14 +160,7 @@ export default class AuthService { static async login_header({}: { }): Promise { - const resp = await fetch("/api/auth/jwt/login_header", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - }).toString(), - }); + const resp = await fetch("/api/auth/jwt/login_header"); if (resp.status !== 200) { throw new APIError({ From ff71cb864deb51705613fbc8158c60f59f1d75d7 Mon Sep 17 00:00:00 2001 From: Francesco Servida Date: Sat, 27 Jan 2024 15:21:18 +0300 Subject: [PATCH 04/26] Refactored Endpoints to better match structure --- backend/btrixcloud/auth.py | 4 ++-- frontend/src/utils/AuthService.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/btrixcloud/auth.py b/backend/btrixcloud/auth.py index 2d9b8bd119..0e816d90fc 100644 --- a/backend/btrixcloud/auth.py +++ b/backend/btrixcloud/auth.py @@ -255,7 +255,7 @@ async def send_reset_if_needed(): await user_manager.reset_failed_logins(login_email) return get_bearer_response(user) - @auth_jwt_router.get("/login_header", response_model=BearerResponse) + @auth_jwt_router.get("/login/header", response_model=BearerResponse) async def login_header( x_remote_user: str | None = Header(default=None), x_remote_email: str | None = Header(default=None), @@ -299,7 +299,7 @@ async def login_header( detail="user_creation_failed", ) - @auth_jwt_router.get("/login_methods", response_model=LoginMethodsInquiryResponse) + @auth_jwt_router.get("/login/methods", response_model=LoginMethodsInquiryResponse) async def login_header() -> LoginMethodsInquiryResponse: enabled_login_methods = { 'password': True, diff --git a/frontend/src/utils/AuthService.ts b/frontend/src/utils/AuthService.ts index b054a2656f..f2e33314db 100644 --- a/frontend/src/utils/AuthService.ts +++ b/frontend/src/utils/AuthService.ts @@ -160,7 +160,7 @@ export default class AuthService { static async login_header({}: { }): Promise { - const resp = await fetch("/api/auth/jwt/login_header"); + const resp = await fetch("/api/auth/jwt/login/header"); if (resp.status !== 200) { throw new APIError({ From 1b5d58a96e777abdbbbc7a7177602cdadc1ca76f Mon Sep 17 00:00:00 2001 From: Francesco Servida Date: Sat, 27 Jan 2024 17:07:16 +0300 Subject: [PATCH 05/26] Partial OIDC support --- backend/btrixcloud/auth.py | 126 +++++++++++++++++++++++++----- backend/requirements.txt | 1 + chart/templates/configmap.yaml | 23 ++++++ frontend/src/utils/AuthService.ts | 45 +++++++++++ 4 files changed, 175 insertions(+), 20 deletions(-) diff --git a/backend/btrixcloud/auth.py b/backend/btrixcloud/auth.py index 0e816d90fc..dbc1689038 100644 --- a/backend/btrixcloud/auth.py +++ b/backend/btrixcloud/auth.py @@ -22,6 +22,10 @@ from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm +from typing import Dict, Any +from fastapi_sso.sso.generic import create_provider +from fastapi_sso.sso.base import OpenID + from .models import User, UserRole @@ -39,6 +43,15 @@ SSO_HEADER_ENABLED = bool(int(os.environ.get("SSO_HEADER_ENABLED", 0))) SSO_HEADER_GROUPS_SEPARATOR = os.environ.get("SSO_HEADER_GROUPS_SEPARATOR", ";") +SSO_OIDC_ENABLED = bool(int(os.environ.get("SSO_OIDC_ENABLED", 0))) +SSO_OIDC_AUTH_ENDPOINT = os.environ.get("SSO_OIDC_AUTH_ENDPOINT", "") +SSO_OIDC_TOKEN_ENDPOINT = os.environ.get("SSO_OIDC_TOKEN_ENDPOINT", "") +SSO_OIDC_USERINFO_ENDPOINT = os.environ.get("SSO_OIDC_USERINFO_ENDPOINT", "") +SSO_OIDC_CLIENT_ID = os.environ.get("SSO_OIDC_CLIENT_ID", "") +SSO_OIDC_CLIENT_SECRET = os.environ.get("SSO_OIDC_CLIENT_SECRET", "") +SSO_OIDC_REDIRECT_URL = os.environ.get("SSO_OIDC_REDIRECT_URL", "") +SSO_OIDC_ALLOW_HTTP_INSECURE = bool(int(os.environ.get("SSO_OIDC_ALLOW_HTTP_INSECURE", 0))) + # Audiences AUTH_AUD = "btrix:auth" RESET_AUD = "btrix:reset" @@ -143,6 +156,40 @@ def generate_password() -> str: return pwd.genword() +# ============================================================================ +def openid_convertor(response: Dict[str, Any], session = None) -> OpenID: + + email = response.get("email", None) + username = response.get("preferred_username", None) + groups = response.get("isMemberOf", None) + + if email is None or username is None or groups is None or not isinstance(groups, list): + raise HTTPException( + status_code=500, + detail="error_processing_sso_response", + ) + + return OpenID( + email=email, + display_name=username, # Abusing variable names to match what we need + id=";".join(groups) # Abusing variable names to match what we need + ) + +if SSO_OIDC_ENABLED: + discovery = { + "authorization_endpoint": SSO_OIDC_AUTH_ENDPOINT, + "token_endpoint": SSO_OIDC_TOKEN_ENDPOINT, + "userinfo_endpoint": SSO_OIDC_USERINFO_ENDPOINT, + } + + SSOProvider = create_provider(name="oidc", discovery_document=discovery, response_convertor=openid_convertor) + sso = SSOProvider( + client_id=SSO_OIDC_CLIENT_ID, + client_secret=SSO_OIDC_CLIENT_SECRET, + redirect_uri=SSO_OIDC_REDIRECT_URL, + allow_insecure_http=SSO_OIDC_ALLOW_HTTP_INSECURE + ) + # ============================================================================ async def update_user_orgs(groups: [str], user, ops): orgs = await ops.get_org_slugs_by_ids() @@ -163,6 +210,27 @@ async def update_user_orgs(groups: [str], user, ops): del org.users[str(user.id)] await ops.update_users(org) +async def process_sso_user_login(user_manager, login_email, login_name, groups) -> User: + user = await user_manager.get_by_email(login_email) + ops = user_manager.org_ops + + if user: + await update_user_orgs(groups, user, ops) + # User exist, and correct orgs have been set, proceed to login + return user + else: + # Create verified user + await user_manager.create_non_super_user(login_email, None, login_name) + user = await user_manager.get_by_email(login_email) + if user: + await update_user_orgs(groups, user, ops) + # User has been created and correct orgs have been set, proceed to login + return user + else: + raise HTTPException( + status_code=500, + detail="user_creation_failed", + ) # ============================================================================ # pylint: disable=raise-missing-from @@ -278,36 +346,54 @@ async def login_header( login_name = x_remote_user groups = [group.lower() for group in x_remote_groups.split(SSO_HEADER_GROUPS_SEPARATOR)] - user = await user_manager.get_by_email(login_email) - ops = user_manager.org_ops + user = await process_sso_user_login(user_manager, login_email, login_name, groups) + return get_bearer_response(user) - if user: - await update_user_orgs(groups, user, ops) - # User exist, and correct orgs have been set, proceed to login - return get_bearer_response(user) - else: - # Create verified user - await user_manager.create_non_super_user(login_email, None, login_name) - user = await user_manager.get_by_email(login_email) - if user: - await update_user_orgs(groups, user, ops) - # User has been created and correct orgs have been set, proceed to login - return get_bearer_response(user) - else: - raise HTTPException( - status_code=500, - detail="user_creation_failed", - ) + @auth_jwt_router.get("/login/oidc") + async def login_header(): + if not SSO_OIDC_ENABLED: + raise HTTPException( + status_code=405, + detail="sso_is_disabled", + ) + + """Redirect the user to the OIDC login page.""" + with sso: + return await sso.get_login_redirect() + + @auth_jwt_router.get("/login/oidc/callback", response_model=BearerResponse) + async def login_header(request: Request) -> BearerResponse: + if not SSO_OIDC_ENABLED: + raise HTTPException( + status_code=405, + detail="sso_is_disabled", + ) + + with sso: + openid = await sso.verify_and_process(request) + if not openid: + raise HTTPException(status_code=401, detail="Authentication failed") + login_email = openid.email + login_name = openid.display_name # Abusing variable names, see openid convertor above + groups = [group.lower() for group in openid.id.split(";")] # Abusing variable names, see openid convertor above + user = await process_sso_user_login(user_manager, login_email, login_name, groups) + return get_bearer_response(user) + @auth_jwt_router.get("/login/methods", response_model=LoginMethodsInquiryResponse) async def login_header() -> LoginMethodsInquiryResponse: enabled_login_methods = { 'password': True, - 'sso_header': False + 'sso_header': False, + 'sso_oidc': False } if SSO_HEADER_ENABLED: enabled_login_methods['sso_header'] = True + + if SSO_OIDC_ENABLED: + enabled_login_methods['sso_oidc'] = True + return LoginMethodsInquiryResponse(login_methods=enabled_login_methods) @auth_jwt_router.post("/refresh", response_model=BearerResponse) diff --git a/backend/requirements.txt b/backend/requirements.txt index 0877636c95..66799bf946 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -28,3 +28,4 @@ types_aiobotocore_s3 types-redis types-python-slugify types-pyYAML +fastapi-sso diff --git a/chart/templates/configmap.yaml b/chart/templates/configmap.yaml index f42ffdc36f..599f60a4b2 100644 --- a/chart/templates/configmap.yaml +++ b/chart/templates/configmap.yaml @@ -54,6 +54,29 @@ data: SSO_HEADER_GROUPS_SEPARATOR: {{ .Values.sso_header_groups_separator | default ";" }} + SSO_OIDC_ENABLED: "{{ .Values.sso_oidc_enabled | default 0 }}" + + SSO_OIDC_AUTH_ENDPOINT: {{ .Values.sso_oidc_auth_endpoint | default "" }} + + SSO_OIDC_TOKEN_ENDPOINT: {{ .Values.sso_oidc_token_endpoint | default "" }} + + SSO_OIDC_USERINFO_ENDPOINT: {{ .Values.sso_oidc_userinfo_endpoint | default "" }} + + SSO_OIDC_CLIENT_ID: {{ .Values.sso_oidc_client_id | default "" }} + + SSO_OIDC_CLIENT_SECRET: {{ .Values.sso_oidc_client_secret | default "" }} + + SSO_OIDC_REDIRECT_URL: {{ .Values.sso_oidc_redirect_url | default "" }} + + SSO_OIDC_ALLOW_HTTP_INSECURE: "{{ .Values.sso_oidc_allow_http_insecure | default 0 }}" + + SSO_OIDC_USERINFO_EMAIL_FIELD: {{ .Values.sso_oidc_userinfo_email_field | default "email" }} + + SSO_OIDC_USERINFO_USERNAME_FIELD: {{ .Values.sso_oidc_userinfo_username_field | default "preferred_username" }} + + SSO_OIDC_USERINFO_GROUPS_FIELD: {{ .Values.sso_oidc_userinfo_groups_field | default "isMemberOf" }} + + --- apiVersion: v1 kind: ConfigMap diff --git a/frontend/src/utils/AuthService.ts b/frontend/src/utils/AuthService.ts index f2e33314db..10a3aa69d9 100644 --- a/frontend/src/utils/AuthService.ts +++ b/frontend/src/utils/AuthService.ts @@ -180,6 +180,51 @@ export default class AuthService { }; } + static async login_oidc({}: { + }): Promise { + const resp = await fetch("/api/auth/jwt/login/oidc"); + + if (resp.status !== 200) { + throw new APIError({ + message: resp.statusText, + status: resp.status, + }); + } + + const data = await resp.json(); + const token = AuthService.decodeToken(data.access_token); + const authHeaders = AuthService.parseAuthHeaders(data); + + return { + username: "test_user", + headers: authHeaders, + tokenExpiresAt: token.exp * 1000, + }; + } + + static async login_oidc_callback({}: { + }): Promise { + const resp = await fetch("/api/auth/jwt/login/oidc/callback"); + + if (resp.status !== 200) { + throw new APIError({ + message: resp.statusText, + status: resp.status, + }); + } + + const data = await resp.json(); + const token = AuthService.decodeToken(data.access_token); + const authHeaders = AuthService.parseAuthHeaders(data); + + return { + username: "test_user", + headers: authHeaders, + tokenExpiresAt: token.exp * 1000, + }; + } + + /** * Decode JSON web token returned as access token */ From 9b2f7d26f1f7b34dbc0214ab9c12362a9ad107f6 Mon Sep 17 00:00:00 2001 From: Francesco Servida Date: Sat, 27 Jan 2024 17:14:09 +0300 Subject: [PATCH 06/26] Fix Template for Configmap --- chart/templates/configmap.yaml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/chart/templates/configmap.yaml b/chart/templates/configmap.yaml index 599f60a4b2..5993749318 100644 --- a/chart/templates/configmap.yaml +++ b/chart/templates/configmap.yaml @@ -56,17 +56,17 @@ data: SSO_OIDC_ENABLED: "{{ .Values.sso_oidc_enabled | default 0 }}" - SSO_OIDC_AUTH_ENDPOINT: {{ .Values.sso_oidc_auth_endpoint | default "" }} + SSO_OIDC_AUTH_ENDPOINT: {{ .Values.sso_oidc_auth_endpoint | default "-" }} - SSO_OIDC_TOKEN_ENDPOINT: {{ .Values.sso_oidc_token_endpoint | default "" }} + SSO_OIDC_TOKEN_ENDPOINT: {{ .Values.sso_oidc_token_endpoint | default "-" }} - SSO_OIDC_USERINFO_ENDPOINT: {{ .Values.sso_oidc_userinfo_endpoint | default "" }} + SSO_OIDC_USERINFO_ENDPOINT: {{ .Values.sso_oidc_userinfo_endpoint | default "-" }} - SSO_OIDC_CLIENT_ID: {{ .Values.sso_oidc_client_id | default "" }} + SSO_OIDC_CLIENT_ID: {{ .Values.sso_oidc_client_id | default "-" }} - SSO_OIDC_CLIENT_SECRET: {{ .Values.sso_oidc_client_secret | default "" }} + SSO_OIDC_CLIENT_SECRET: {{ .Values.sso_oidc_client_secret | default "-" }} - SSO_OIDC_REDIRECT_URL: {{ .Values.sso_oidc_redirect_url | default "" }} + SSO_OIDC_REDIRECT_URL: {{ .Values.sso_oidc_redirect_url | default "-" }} SSO_OIDC_ALLOW_HTTP_INSECURE: "{{ .Values.sso_oidc_allow_http_insecure | default 0 }}" From 4d367d5853ee455fda4a122c59cb56ea474d6982 Mon Sep 17 00:00:00 2001 From: Francesco Servida Date: Sat, 27 Jan 2024 17:20:14 +0300 Subject: [PATCH 07/26] Dynamic OIDC fields --- backend/btrixcloud/auth.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/backend/btrixcloud/auth.py b/backend/btrixcloud/auth.py index dbc1689038..2d43947315 100644 --- a/backend/btrixcloud/auth.py +++ b/backend/btrixcloud/auth.py @@ -51,6 +51,9 @@ SSO_OIDC_CLIENT_SECRET = os.environ.get("SSO_OIDC_CLIENT_SECRET", "") SSO_OIDC_REDIRECT_URL = os.environ.get("SSO_OIDC_REDIRECT_URL", "") SSO_OIDC_ALLOW_HTTP_INSECURE = bool(int(os.environ.get("SSO_OIDC_ALLOW_HTTP_INSECURE", 0))) +SSO_OIDC_USERINFO_EMAIL_FIELD = os.environ.get("SSO_OIDC_USERINFO_EMAIL_FIELD", "email") +SSO_OIDC_USERINFO_USERNAME_FIELD = os.environ.get("SSO_OIDC_USERINFO_USERNAME_FIELD", "preferred_username") +SSO_OIDC_USERINFO_GROUPS_FIELD = os.environ.get("SSO_OIDC_USERINFO_GROUPS_FIELD", "isMemberOf") # Audiences AUTH_AUD = "btrix:auth" @@ -159,9 +162,9 @@ def generate_password() -> str: # ============================================================================ def openid_convertor(response: Dict[str, Any], session = None) -> OpenID: - email = response.get("email", None) - username = response.get("preferred_username", None) - groups = response.get("isMemberOf", None) + email = response.get("SSO_OIDC_USERINFO_EMAIL_FIELD", None) + username = response.get("SSO_OIDC_USERINFO_USERNAME_FIELD", None) + groups = response.get("SSO_OIDC_USERINFO_GROUPS_FIELD", None) if email is None or username is None or groups is None or not isinstance(groups, list): raise HTTPException( From 16fee6417e4d5d30cd5c641eeb32272327e81199 Mon Sep 17 00:00:00 2001 From: Francesco Servida Date: Sat, 27 Jan 2024 17:34:39 +0300 Subject: [PATCH 08/26] Dynamic Header Names --- backend/btrixcloud/auth.py | 12 +++++++++--- chart/templates/configmap.yaml | 6 ++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/backend/btrixcloud/auth.py b/backend/btrixcloud/auth.py index 2d43947315..3d737d6f11 100644 --- a/backend/btrixcloud/auth.py +++ b/backend/btrixcloud/auth.py @@ -42,6 +42,10 @@ SSO_HEADER_ENABLED = bool(int(os.environ.get("SSO_HEADER_ENABLED", 0))) SSO_HEADER_GROUPS_SEPARATOR = os.environ.get("SSO_HEADER_GROUPS_SEPARATOR", ";") +SSO_HEADER_EMAIL_FIELD = os.environ.get("SSO_HEADER_EMAIL_FIELD", "x-remote-email") +SSO_HEADER_USERNAME_FIELD = os.environ.get("SSO_HEADER_USERNAME_FIELD", "x-remote-user") +SSO_HEADER_GROUPS_FIELD = os.environ.get("SSO_HEADER_GROUPS_FIELD", "x-remote-groups") + SSO_OIDC_ENABLED = bool(int(os.environ.get("SSO_OIDC_ENABLED", 0))) SSO_OIDC_AUTH_ENDPOINT = os.environ.get("SSO_OIDC_AUTH_ENDPOINT", "") @@ -328,11 +332,13 @@ async def send_reset_if_needed(): @auth_jwt_router.get("/login/header", response_model=BearerResponse) async def login_header( - x_remote_user: str | None = Header(default=None), - x_remote_email: str | None = Header(default=None), - x_remote_groups: str | None = Header(default=None) + request: Request ) -> BearerResponse: + x_remote_email = request.headers.get(SSO_HEADER_EMAIL_FIELD, None) + x_remote_user = request.headers.get(SSO_HEADER_USERNAME_FIELD, None) + x_remote_groups = request.headers.get(SSO_HEADER_GROUPS_FIELD, None) + if not SSO_HEADER_ENABLED: raise HTTPException( status_code=405, diff --git a/chart/templates/configmap.yaml b/chart/templates/configmap.yaml index 5993749318..d9285e1bca 100644 --- a/chart/templates/configmap.yaml +++ b/chart/templates/configmap.yaml @@ -52,6 +52,12 @@ data: SSO_HEADER_ENABLED: "{{ .Values.sso_header_enabled | default 0 }}" + SSO_HEADER_EMAIL_FIELD: {{ .Values.sso_header_email_field | default "x-remote-email" }} + + SSO_HEADER_USERNAME_FIELD: {{ .Values.sso_header_username_field | default "x-remote-user" }} + + SSO_HEADER_GROUPS_FIELD: {{ .Values.sso_header_groups_field | default "x-remote-groups" }} + SSO_HEADER_GROUPS_SEPARATOR: {{ .Values.sso_header_groups_separator | default ";" }} SSO_OIDC_ENABLED: "{{ .Values.sso_oidc_enabled | default 0 }}" From 544579fd7157b4b3334d9d448df8b71a2a756731 Mon Sep 17 00:00:00 2001 From: Francesco Servida Date: Sat, 27 Jan 2024 17:47:35 +0300 Subject: [PATCH 09/26] Updated values.yml with default values --- chart/values.yaml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/chart/values.yaml b/chart/values.yaml index 63685bea5a..9b931b4fda 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -83,6 +83,28 @@ superuser: # Set name for default organization created with superuser default_org: "My Organization" +# SSO Settings +# +# SSO Configuration +# Enabled: 1, Disabled 0 (Default) +# sso_header_enabled: 0 +# sso_header_email_field: x-remote-email +# sso_header_username_field: x-remote-user +# sso_header_groups_field: x-remote-groups +# sso_header_groups_separator: ';' + +# sso_oidc_enabled: 0 +# sso_oidc_auth_endpoint: https://localhost/auth/realms/example/protocol/openid-connect/auth +# sso_oidc_token_endpoint: https://localhost/auth/realms/example/protocol/openid-connect/token +# sso_oidc_userinfo_endpoint: https://localhost/auth/realms/example/protocol/openid-connect/userinfo +# sso_oidc_client_id: yourclientid +# sso_oidc_client_secret: yourclientsecret +# sso_oidc_redirect_url: http://localhost:30870/api/auth/jwt/login/oidc/callback +# sso_oidc_allow_http_insecure: 0 +# sso_oidc_userinfo_email_field: email +# sso_oidc_userinfo_username_field: preferred_username +# sso_oidc_userinfo_groups_field: isMemberOf + # API Image # ========================================= From 006d080bb9dd73247d98dfa4bef451e01cd46178 Mon Sep 17 00:00:00 2001 From: Francesco Servida Date: Wed, 31 Jan 2024 19:46:52 +0300 Subject: [PATCH 10/26] Fix to openid_convertor dynamic parameters --- backend/btrixcloud/auth.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/btrixcloud/auth.py b/backend/btrixcloud/auth.py index 3d737d6f11..ccafa11aef 100644 --- a/backend/btrixcloud/auth.py +++ b/backend/btrixcloud/auth.py @@ -166,9 +166,9 @@ def generate_password() -> str: # ============================================================================ def openid_convertor(response: Dict[str, Any], session = None) -> OpenID: - email = response.get("SSO_OIDC_USERINFO_EMAIL_FIELD", None) - username = response.get("SSO_OIDC_USERINFO_USERNAME_FIELD", None) - groups = response.get("SSO_OIDC_USERINFO_GROUPS_FIELD", None) + email = response.get(SSO_OIDC_USERINFO_EMAIL_FIELD, None) + username = response.get(SSO_OIDC_USERINFO_USERNAME_FIELD, None) + groups = response.get(SSO_OIDC_USERINFO_GROUPS_FIELD, None) if email is None or username is None or groups is None or not isinstance(groups, list): raise HTTPException( From 4965a9e3c540678a4f7568af61a1d7e80486b6b7 Mon Sep 17 00:00:00 2001 From: Francesco Servida Date: Wed, 31 Jan 2024 20:46:41 +0300 Subject: [PATCH 11/26] Implemented login with OIDC and Headers --- backend/btrixcloud/auth.py | 14 ++-- frontend/src/index.ts | 14 ++++ frontend/src/pages/index.ts | 3 + frontend/src/pages/log-in-header.ts | 87 ++++++++++++++++++++ frontend/src/pages/log-in-oidc.ts | 119 ++++++++++++++++++++++++++++ frontend/src/pages/log-in.ts | 57 ++++++------- frontend/src/routes.ts | 2 + frontend/src/utils/AuthService.ts | 24 +++--- 8 files changed, 270 insertions(+), 50 deletions(-) create mode 100644 frontend/src/pages/log-in-header.ts create mode 100644 frontend/src/pages/log-in-oidc.ts diff --git a/backend/btrixcloud/auth.py b/backend/btrixcloud/auth.py index ccafa11aef..5b91ddd7f7 100644 --- a/backend/btrixcloud/auth.py +++ b/backend/btrixcloud/auth.py @@ -82,6 +82,9 @@ class BearerResponse(BaseModel): class LoginMethodsInquiryResponse(BaseModel): login_methods: dict +class OIDCRedirectResponse(BaseModel): + redirect_url: str + # ============================================================================ # pylint: disable=too-few-public-methods class OA2BearerOrQuery(OAuth2PasswordBearer): @@ -358,8 +361,8 @@ async def login_header( user = await process_sso_user_login(user_manager, login_email, login_name, groups) return get_bearer_response(user) - @auth_jwt_router.get("/login/oidc") - async def login_header(): + @auth_jwt_router.get("/login/oidc", response_model=OIDCRedirectResponse) + async def login_oidc() -> OIDCRedirectResponse: if not SSO_OIDC_ENABLED: raise HTTPException( status_code=405, @@ -368,10 +371,11 @@ async def login_header(): """Redirect the user to the OIDC login page.""" with sso: - return await sso.get_login_redirect() + redirect_url = await sso.get_login_url() + return OIDCRedirectResponse(redirect_url=redirect_url) @auth_jwt_router.get("/login/oidc/callback", response_model=BearerResponse) - async def login_header(request: Request) -> BearerResponse: + async def login_oidc_callback(request: Request) -> BearerResponse: if not SSO_OIDC_ENABLED: raise HTTPException( status_code=405, @@ -390,7 +394,7 @@ async def login_header(request: Request) -> BearerResponse: return get_bearer_response(user) @auth_jwt_router.get("/login/methods", response_model=LoginMethodsInquiryResponse) - async def login_header() -> LoginMethodsInquiryResponse: + async def login_methods() -> LoginMethodsInquiryResponse: enabled_login_methods = { 'password': True, 'sso_header': False, diff --git a/frontend/src/index.ts b/frontend/src/index.ts index 3d29680431..ad17663293 100644 --- a/frontend/src/index.ts +++ b/frontend/src/index.ts @@ -554,6 +554,20 @@ export class App extends LiteElement { this.viewState.data?.redirectUrl} >`; + case "loginSsoHeader": + return html``; + + case "loginSsoOidc": + return html``; + case "resetPassword": return html` +
+
+ + ${this.serverError} + +
+
${this.renderBackButton()}
+
+ `; + } + return html`
`; + } + + private renderBackButton() { + + return html` +
+ ${msg("Back To Log In")} +
+ `; + } + + private async login(): Promise { + try { + const data = await AuthService.login_header({}); + + this.dispatchEvent( + AuthService.createLoggedInEvent({ + ...data, + redirectUrl: this.redirectUrl, + }) + ); + + // no state update here, since "btrix-logged-in" event + // will result in a route change + } catch (e: any) { + if (e.isApiError) { + let message = msg("Sorry, an error occurred while attempting Single Sign On"); + this.serverError = message; + } else { + let message = msg("Something went wrong, couldn't sign you in"); + this.serverError = message; + } + } + } + + async onSubmitBack(event: SubmitEvent) { + event.preventDefault(); + window.location.href = "/log-in"; + } +} diff --git a/frontend/src/pages/log-in-oidc.ts b/frontend/src/pages/log-in-oidc.ts new file mode 100644 index 0000000000..fbefe425bd --- /dev/null +++ b/frontend/src/pages/log-in-oidc.ts @@ -0,0 +1,119 @@ +import { state, property, customElement } from "lit/decorators.js"; +import { msg, localized } from "@lit/localize"; + +import type { AuthState } from "@/utils/AuthService"; +import LiteElement, { html } from "@/utils/LiteElement"; +import AuthService from "@/utils/AuthService"; +import { ROUTES } from "@/routes"; + +@localized() +@customElement("btrix-log-in-oidc") +export class LoginSsoOidc extends LiteElement { + @property({ type: Object }) + authState?: AuthState; + + @property({ type: String }) + token?: string; + + @property({ type: String }) + redirectUrl: string = ROUTES.home; + + @property({ type: String }) + session_state: string = ''; + + @property({ type: String }) + code: string = ''; + + @state() + private serverError?: string; + + firstUpdated() { + let params = new URLSearchParams(window.location.search); + this.session_state = params.get('session_state') || '' as string; + this.code = params.get('code') || '' as string; + + if (this.code !== '' && this.session_state !== '') { + this.login_callback(); + } + else { + this.login_init(); + } + } + + render() { + if (this.serverError) { + return html` +
+
+
+ + ${this.serverError} + +
+
${this.renderBackButton()}
+
+
`; + } + return html`
`; + } + + private renderBackButton() { + + return html` +
+ ${msg("Back To Log In")} +
+ `; + } + + + private async login_init(): Promise { + try { + const redirect_url = await AuthService.login_oidc({}); + window.location.href = redirect_url; + } + catch (e: any) { + if (e.isApiError) { + let message = msg("Sorry, an error occurred while attempting Single Sign On"); + this.serverError = message; + } else { + let message = msg("Something went wrong, couldn't sign you in"); + this.serverError = message; + } + } + } + + private async login_callback(): Promise { + try { + const data = await AuthService.login_oidc_callback({session_state: this.session_state, code: this.code}); + + this.dispatchEvent( + AuthService.createLoggedInEvent({ + ...data, + redirectUrl: this.redirectUrl, + }) + ); + + // no state update here, since "btrix-logged-in" event + // will result in a route change + } catch (e: any) { + if (e.isApiError) { + let message = msg("Sorry, an error occurred while attempting Single Sign On"); + this.serverError = message; + } else { + let message = msg("Something went wrong, couldn't sign you in"); + this.serverError = message; + } + } + } + + async onSubmitBack(event: SubmitEvent) { + event.preventDefault(); + window.location.href = "/log-in"; + } +} diff --git a/frontend/src/pages/log-in.ts b/frontend/src/pages/log-in.ts index bdf1ed9044..52079ab36c 100644 --- a/frontend/src/pages/log-in.ts +++ b/frontend/src/pages/log-in.ts @@ -221,6 +221,7 @@ export class LogInPage extends LiteElement {
${this.renderFormError()}
${form}
${this.renderLoginHeaderButton()}
+
${this.renderLoginOIDCButton()}
${link}
@@ -305,14 +306,30 @@ export class LogInPage extends LiteElement { private renderLoginHeaderButton() { return html` -
+ ${msg("Log In with Single Sign On")}${msg("Log In with Single Sign On (Header)")} +
+ `; + } + + private renderLoginOIDCButton() { + + return html` +
+ ${msg("Log In with Single Sign On (OIDC)")}
`; @@ -418,38 +435,12 @@ export class LogInPage extends LiteElement { async onSubmitLogInHeader(event: SubmitEvent) { event.preventDefault(); - this.formStateService.send("SUBMIT"); - - try { - const data = await AuthService.login_header({}); - - this.dispatchEvent( - AuthService.createLoggedInEvent({ - ...data, - redirectUrl: this.redirectUrl, - }) - ); + window.location.href = "/log-in/header"; + } - // no state update here, since "btrix-logged-in" event - // will result in a route change - } catch (e: any) { - if (e.isApiError) { - let message = msg("Sorry, an error occurred while attempting Single Sign On"); - this.formStateService.send({ - type: "ERROR", - detail: { - serverError: message, - }, - }); - } else { - this.formStateService.send({ - type: "ERROR", - detail: { - serverError: msg("Something went wrong, couldn't sign you in"), - }, - }); - } - } + async onSubmitLogInOIDC(event: SubmitEvent) { + event.preventDefault(); + window.location.href = "/log-in/oidc"; } async onSubmitResetPassword(event: SubmitEvent) { diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index 9d297cb0a0..e607e82533 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -5,6 +5,8 @@ export const ROUTES = { acceptInvite: "/invite/accept/:token", verify: "/verify", login: "/log-in", + loginSsoHeader: "/log-in/header", + loginSsoOidc: "/log-in/oidc", loginWithRedirect: "/log-in?redirectUrl", forgotPassword: "/log-in/forgot-password", resetPassword: "/reset-password", diff --git a/frontend/src/utils/AuthService.ts b/frontend/src/utils/AuthService.ts index 10a3aa69d9..eede6203d6 100644 --- a/frontend/src/utils/AuthService.ts +++ b/frontend/src/utils/AuthService.ts @@ -174,14 +174,14 @@ export default class AuthService { const authHeaders = AuthService.parseAuthHeaders(data); return { - username: "test_user", + username: "placeholder", headers: authHeaders, tokenExpiresAt: token.exp * 1000, }; } static async login_oidc({}: { - }): Promise { + }): Promise { const resp = await fetch("/api/auth/jwt/login/oidc"); if (resp.status !== 200) { @@ -192,19 +192,19 @@ export default class AuthService { } const data = await resp.json(); - const token = AuthService.decodeToken(data.access_token); - const authHeaders = AuthService.parseAuthHeaders(data); - return { - username: "test_user", - headers: authHeaders, - tokenExpiresAt: token.exp * 1000, - }; + return data.redirect_url } - static async login_oidc_callback({}: { + static async login_oidc_callback({ + session_state, + code, + }: { + session_state: string, + code: string }): Promise { - const resp = await fetch("/api/auth/jwt/login/oidc/callback"); + const params = "?session_state=" + session_state + "&code=" + code + const resp = await fetch("/api/auth/jwt/login/oidc/callback" + params); if (resp.status !== 200) { throw new APIError({ @@ -218,7 +218,7 @@ export default class AuthService { const authHeaders = AuthService.parseAuthHeaders(data); return { - username: "test_user", + username: "placeholder", headers: authHeaders, tokenExpiresAt: token.exp * 1000, }; From edf6c40feb15e2bcd1fecf95134c493b28203c27 Mon Sep 17 00:00:00 2001 From: Francesco Servida Date: Wed, 31 Jan 2024 20:58:45 +0300 Subject: [PATCH 12/26] Remove redundant import in index.ts --- frontend/src/pages/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/pages/index.ts b/frontend/src/pages/index.ts index 38d6863eea..e951aae40f 100644 --- a/frontend/src/pages/index.ts +++ b/frontend/src/pages/index.ts @@ -4,7 +4,6 @@ import(/* webpackChunkName: "sign-up" */ "./sign-up"); import(/* webpackChunkName: "log-in" */ "./log-in"); import(/* webpackChunkName: "log-in-header" */ "./log-in-header"); import(/* webpackChunkName: "log-in-header" */ "./log-in-oidc"); -import(/* webpackChunkName: "log-in" */ "./log-in"); import(/* webpackChunkName: "orgs" */ "./orgs"); import(/* webpackChunkName: "org" */ "./org"); import(/* webpackChunkName: "crawls" */ "./crawls"); From 0d0e4b33da4cc17da4c779686cb30a7a8bfbb461 Mon Sep 17 00:00:00 2001 From: Francesco Servida Date: Fri, 2 Feb 2024 10:52:44 +0300 Subject: [PATCH 13/26] Implemented Dynamic Buttons for Login Form --- frontend/src/pages/log-in.ts | 41 +++++++++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/frontend/src/pages/log-in.ts b/frontend/src/pages/log-in.ts index 52079ab36c..df502b5484 100644 --- a/frontend/src/pages/log-in.ts +++ b/frontend/src/pages/log-in.ts @@ -146,6 +146,12 @@ export class LogInPage extends LiteElement { @property({ type: String }) redirectUrl: string = ROUTES.home; + @property({ type: Boolean }) + HeaderSSOEnabled: boolean = false; + + @property({ type: Boolean }) + OIDCSSOEnabled: boolean = false; + private formStateService = interpret(machine); @state() @@ -213,6 +219,16 @@ export class LogInPage extends LiteElement { `; } + var header_sso_component = html`` + var oidc_sso_component = html`` + if (this.HeaderSSOEnabled){ + header_sso_component = html`
${this.renderLoginHeaderButton()}
` + } + + if (this.OIDCSSOEnabled){ + oidc_sso_component = html`
${this.renderLoginOIDCButton()}
` + } + return html`
${successMessage} @@ -220,8 +236,8 @@ export class LogInPage extends LiteElement {
${this.renderFormError()}
${form}
-
${this.renderLoginHeaderButton()}
-
${this.renderLoginOIDCButton()}
+ ${header_sso_component} + ${oidc_sso_component}
${link}
@@ -256,6 +272,11 @@ export class LogInPage extends LiteElement { private renderLoginForm() { + var button_type = "primary" + if (this.HeaderSSOEnabled || this.OIDCSSOEnabled) { + button_type = "default" + } + return html`
@@ -284,7 +305,7 @@ export class LogInPage extends LiteElement { ${msg("Log In with Single Sign On (Header)")}${msg("Log In with Single Sign On")} `; @@ -329,7 +350,7 @@ export class LogInPage extends LiteElement { ?loading=${this.formState.value === "signingIn"} ?disabled=${this.formState.value === "backendInitializing"} type="submit" - >${msg("Log In with Single Sign On (OIDC)")}${msg("Log In with Single Sign On")} `; @@ -379,6 +400,7 @@ export class LogInPage extends LiteElement { const resp = await fetch("/api/settings"); if (resp.status === 200) { this.formStateService.send("BACKEND_INITIALIZED"); + this.checkEnabledSSOMethods(); } else { this.formStateService.send("BACKEND_NOT_INITIALIZED"); this.timerId = window.setTimeout( @@ -388,6 +410,15 @@ export class LogInPage extends LiteElement { } } + async checkEnabledSSOMethods() { + const resp = await fetch("/api/auth/jwt/login/methods"); + if (resp.status == 200) { + const data = await resp.json(); + this.HeaderSSOEnabled = data.login_methods.sso_header; + this.OIDCSSOEnabled = data.login_methods.sso_oidc; + } + } + async onSubmitLogIn(event: SubmitEvent) { event.preventDefault(); this.formStateService.send("SUBMIT"); From c97af6e4a0b5c808ea52208d485875aa07c8c124 Mon Sep 17 00:00:00 2001 From: Francesco Servida Date: Fri, 2 Feb 2024 13:54:55 +0300 Subject: [PATCH 14/26] Backend Support for SSO User flag field --- backend/btrixcloud/auth.py | 2 +- backend/btrixcloud/models.py | 4 ++++ backend/btrixcloud/users.py | 5 +++++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/backend/btrixcloud/auth.py b/backend/btrixcloud/auth.py index 5b91ddd7f7..f2087cc946 100644 --- a/backend/btrixcloud/auth.py +++ b/backend/btrixcloud/auth.py @@ -230,7 +230,7 @@ async def process_sso_user_login(user_manager, login_email, login_name, groups) return user else: # Create verified user - await user_manager.create_non_super_user(login_email, None, login_name) + await user_manager.create_non_super_user(login_email, None, login_name, is_sso=True) user = await user_manager.get_by_email(login_email) if user: await update_user_orgs(groups, user, ops) diff --git a/backend/btrixcloud/models.py b/backend/btrixcloud/models.py index 6f1f6ee5f6..87127f0a01 100644 --- a/backend/btrixcloud/models.py +++ b/backend/btrixcloud/models.py @@ -94,6 +94,8 @@ class User(BaseModel): email: EmailStr is_superuser: bool = False is_verified: bool = False + + is_sso: bool = False invites: Dict[str, InvitePending] = {} hashed_password: str @@ -144,6 +146,7 @@ class UserOut(BaseModel): email: EmailStr is_superuser: bool = False is_verified: bool = False + is_sso: bool = False orgs: List[UserOrgInfoOut] @@ -1151,6 +1154,7 @@ class UserCreate(UserCreateIn): is_superuser: Optional[bool] = False is_verified: Optional[bool] = False + is_sso: Optional[bool] = False # ============================================================================ diff --git a/backend/btrixcloud/users.py b/backend/btrixcloud/users.py index f542a1729e..2fac9d21e5 100644 --- a/backend/btrixcloud/users.py +++ b/backend/btrixcloud/users.py @@ -162,6 +162,7 @@ async def get_user_info_with_orgs(self, user: User) -> UserOut: orgs=orgs, is_superuser=user.is_superuser, is_verified=user.is_verified, + is_sso=user.is_sso, ) async def validate_password(self, password: str) -> None: @@ -274,6 +275,7 @@ async def create_non_super_user( email: str, password: str, name: str = "New user", + is_sso: bool = False, ) -> None: """create a regular user with given credentials""" if not email: @@ -291,6 +293,7 @@ async def create_non_super_user( is_superuser=False, newOrg=False, is_verified=True, + is_sso=is_sso, ) await self._create(user_create) @@ -342,6 +345,7 @@ async def _create( if isinstance(create, UserCreate): is_superuser = create.is_superuser is_verified = create.is_verified + is_sso = create.is_sso else: is_superuser = False is_verified = create.inviteToken is not None @@ -355,6 +359,7 @@ async def _create( hashed_password=hashed_password, is_superuser=is_superuser, is_verified=is_verified, + is_sso=is_sso, ) try: From dba01dbe55582e540c77b77e0931eab85edd57a3 Mon Sep 17 00:00:00 2001 From: Francesco Servida Date: Fri, 2 Feb 2024 15:20:52 +0300 Subject: [PATCH 15/26] Disable user edits if SSO on frontend --- .../src/features/accounts/account-settings.ts | 143 ++++++++++-------- frontend/src/index.ts | 2 + frontend/src/types/user.ts | 1 + 3 files changed, 86 insertions(+), 60 deletions(-) diff --git a/frontend/src/features/accounts/account-settings.ts b/frontend/src/features/accounts/account-settings.ts index 3499dd2780..c9ac34110e 100644 --- a/frontend/src/features/accounts/account-settings.ts +++ b/frontend/src/features/accounts/account-settings.ts @@ -133,6 +133,12 @@ export class AccountSettings extends LiteElement {

${msg("Account Settings")}

+ ${when(this.userInfo.isSSO, () => + html` + Some of the setting are managed by your organization and cannot be changed. +
+ ` + )}

@@ -149,6 +155,7 @@ export class AccountSettings extends LiteElement { maxlength="40" minlength="2" required + ?disabled=${this.userInfo.isSSO} aria-label=${msg("Display name")} >

@@ -158,6 +165,7 @@ export class AccountSettings extends LiteElement { size="small" variant="primary" ?loading=${this.sectionSubmitting === "name"} + ?disabled=${this.userInfo.isSSO} >${msg("Save")} @@ -172,6 +180,7 @@ export class AccountSettings extends LiteElement { name="email" value=${this.userInfo.email} type="email" + ?disabled=${this.userInfo.isSSO} aria-label=${msg("Email")} >
@@ -208,75 +217,89 @@ export class AccountSettings extends LiteElement { size="small" variant="primary" ?loading=${this.sectionSubmitting === "email"} + ?disabled=${this.userInfo.isSSO} >${msg("Save")} -
- ${when( - this.isChangingPassword, - () => html` -
-
-

+ ${when(!this.userInfo.isSSO, () => html` +
+ ${when( + this.isChangingPassword, + () => html` + +
+

+ ${msg("Password")} +

+ + + + ${when(this.pwStrengthResults, this.renderPasswordStrength)} +
+
+

+ ${msg( + str`Choose a strong password between ${PASSWORD_MINLENGTH}-${PASSWORD_MAXLENGTH} characters.` + )} +

+ ${msg("Save")} +
+ + `, + () => html` +
+

${msg("Password")}

- - - - ${when(this.pwStrengthResults, this.renderPasswordStrength)} -
-
-

- ${msg( - str`Choose a strong password between ${PASSWORD_MINLENGTH}-${PASSWORD_MAXLENGTH} characters.` - )} -

${msg("Save")} (this.isChangingPassword = true)} + >${msg("Change Password")} -
- - `, - () => html` -
-

- ${msg("Password")} -

- (this.isChangingPassword = true)} - >${msg("Change Password")} -
- ` - )} -
+

+ ` + )} +
+ `, + () => html` +
+
+

+ ${msg("Password")} +

+

Password change is disabled for external accounts. Please change the password in your organization portal.

+
+
+ ` + )}
`; } diff --git a/frontend/src/index.ts b/frontend/src/index.ts index ad17663293..db03d1f357 100644 --- a/frontend/src/index.ts +++ b/frontend/src/index.ts @@ -49,6 +49,7 @@ export type APIUser = { is_verified: boolean; is_superuser: boolean; orgs: UserOrg[]; + is_sso: boolean; }; @localized() @@ -150,6 +151,7 @@ export class App extends LiteElement { isVerified: userInfo.is_verified, isAdmin: userInfo.is_superuser, orgs: userInfo.orgs, + isSSO: userInfo.is_sso, }); const orgs = userInfo.orgs; if ( diff --git a/frontend/src/types/user.ts b/frontend/src/types/user.ts index bd708e7265..435a74f16c 100644 --- a/frontend/src/types/user.ts +++ b/frontend/src/types/user.ts @@ -12,4 +12,5 @@ export type CurrentUser = { isVerified: boolean; isAdmin: boolean; orgs: UserOrg[]; + isSSO: boolean; }; From 6e5e0d08f09a3ce2973492ba6a1a79ccc1766687 Mon Sep 17 00:00:00 2001 From: Francesco Servida Date: Fri, 2 Feb 2024 16:03:54 +0300 Subject: [PATCH 16/26] Disable user edits in backend if user is SSO --- backend/btrixcloud/users.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/backend/btrixcloud/users.py b/backend/btrixcloud/users.py index 2fac9d21e5..2ab283fe9b 100644 --- a/backend/btrixcloud/users.py +++ b/backend/btrixcloud/users.py @@ -508,12 +508,22 @@ async def reset_password(self, token: str, password: str) -> None: user = await self.get_by_id(user_uuid) if user: + if user.is_sso: + raise HTTPException( + status_code=400, + detail="external_user", + ) await self._update_password(user, password) async def change_password( self, user_update: UserUpdatePassword, user: User ) -> None: """Change password after checking existing password""" + if user.is_sso: + raise HTTPException( + status_code=400, + detail="external_user", + ) if not await self.check_password(user, user_update.password): raise HTTPException(status_code=400, detail="invalid_current_password") @@ -523,6 +533,11 @@ async def change_email_name( self, user_update: UserUpdateEmailName, user: User ) -> None: """Change email and/or name, if specified, throw if neither is specified""" + if user.is_sso: + raise HTTPException( + status_code=400, + detail="external_user", + ) if not user_update.email and not user_update.name: raise HTTPException(status_code=400, detail="no_updates_specified") @@ -668,7 +683,7 @@ async def forgot_password( email: EmailStr = Body(..., embed=True), ): user = await user_manager.get_by_email(email) - if not user: + if not user or user.is_sso: return None await user_manager.forgot_password(user, request) From 8d989c3a1f3a62a9d25d13fef3a982da30e1b66f Mon Sep 17 00:00:00 2001 From: Francesco Servida Date: Fri, 2 Feb 2024 16:31:28 +0300 Subject: [PATCH 17/26] Allow fully disabling password login --- backend/btrixcloud/auth.py | 11 +++++++++++ chart/templates/configmap.yaml | 2 ++ frontend/src/pages/log-in.ts | 20 ++++++++++---------- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/backend/btrixcloud/auth.py b/backend/btrixcloud/auth.py index f2087cc946..5ac8a0bd22 100644 --- a/backend/btrixcloud/auth.py +++ b/backend/btrixcloud/auth.py @@ -40,6 +40,8 @@ PWD_CONTEXT = CryptContext(schemes=["bcrypt"], deprecated="auto") +PASSWORD_DISABLED = bool(int(os.environ.get("PASSWORD_DISABLED", 0))) + SSO_HEADER_ENABLED = bool(int(os.environ.get("SSO_HEADER_ENABLED", 0))) SSO_HEADER_GROUPS_SEPARATOR = os.environ.get("SSO_HEADER_GROUPS_SEPARATOR", ";") SSO_HEADER_EMAIL_FIELD = os.environ.get("SSO_HEADER_EMAIL_FIELD", "x-remote-email") @@ -281,6 +283,12 @@ async def login( lock the user account and send an email to reset their password. On successful login when user is not already locked, reset count to 0. """ + if PASSWORD_DISABLED: + raise HTTPException( + status_code=405, + detail="password_based_login_disabled", + ) + login_email = credentials.username failed_count = await user_manager.get_failed_logins_count(login_email) @@ -401,6 +409,9 @@ async def login_methods() -> LoginMethodsInquiryResponse: 'sso_oidc': False } + if PASSWORD_DISABLED: + enabled_login_methods['password'] = False + if SSO_HEADER_ENABLED: enabled_login_methods['sso_header'] = True diff --git a/chart/templates/configmap.yaml b/chart/templates/configmap.yaml index d9285e1bca..1d5b24bfe6 100644 --- a/chart/templates/configmap.yaml +++ b/chart/templates/configmap.yaml @@ -50,6 +50,8 @@ data: CRAWLER_CHANNELS_JSON: "/ops-configs/crawler_channels.json" + PASSWORD_DISABLED: "{{ .Values.sso_header_enabled | default 0 }}" + SSO_HEADER_ENABLED: "{{ .Values.sso_header_enabled | default 0 }}" SSO_HEADER_EMAIL_FIELD: {{ .Values.sso_header_email_field | default "x-remote-email" }} diff --git a/frontend/src/pages/log-in.ts b/frontend/src/pages/log-in.ts index df502b5484..69addd31d4 100644 --- a/frontend/src/pages/log-in.ts +++ b/frontend/src/pages/log-in.ts @@ -2,6 +2,7 @@ import { state, property, customElement } from "lit/decorators.js"; import { msg, localized } from "@lit/localize"; import { createMachine, interpret, assign } from "@xstate/fsm"; +import { when } from "lit/directives/when.js"; import type { ViewState } from "@/utils/APIRouter"; import LiteElement, { html } from "@/utils/LiteElement"; @@ -146,6 +147,9 @@ export class LogInPage extends LiteElement { @property({ type: String }) redirectUrl: string = ROUTES.home; + @property({ type: Boolean }) + PasswordLoginEnabled: boolean = true; + @property({ type: Boolean }) HeaderSSOEnabled: boolean = false; @@ -219,14 +223,9 @@ export class LogInPage extends LiteElement { `; } - var header_sso_component = html`` - var oidc_sso_component = html`` - if (this.HeaderSSOEnabled){ - header_sso_component = html`
${this.renderLoginHeaderButton()}
` - } - - if (this.OIDCSSOEnabled){ - oidc_sso_component = html`
${this.renderLoginOIDCButton()}
` + if (!this.PasswordLoginEnabled){ + form = ''; + link = ''; } return html` @@ -236,8 +235,8 @@ export class LogInPage extends LiteElement {
${this.renderFormError()}
${form}
- ${header_sso_component} - ${oidc_sso_component} + ${when(this.HeaderSSOEnabled, () => html`
${this.renderLoginHeaderButton()}
`)} + ${when(this.OIDCSSOEnabled, () => html`
${this.renderLoginOIDCButton()}
`)}
${link}
@@ -414,6 +413,7 @@ export class LogInPage extends LiteElement { const resp = await fetch("/api/auth/jwt/login/methods"); if (resp.status == 200) { const data = await resp.json(); + this.PasswordLoginEnabled = data.login_methods.password; this.HeaderSSOEnabled = data.login_methods.sso_header; this.OIDCSSOEnabled = data.login_methods.sso_oidc; } From fa25e8f991aa9ce53b44c0fc0039f22d1e565312 Mon Sep 17 00:00:00 2001 From: Francesco Servida Date: Fri, 2 Feb 2024 16:44:00 +0300 Subject: [PATCH 18/26] Fix configmap value for PASSWORD_DISABLED: --- chart/templates/configmap.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chart/templates/configmap.yaml b/chart/templates/configmap.yaml index 1d5b24bfe6..f3d1a5a476 100644 --- a/chart/templates/configmap.yaml +++ b/chart/templates/configmap.yaml @@ -50,7 +50,7 @@ data: CRAWLER_CHANNELS_JSON: "/ops-configs/crawler_channels.json" - PASSWORD_DISABLED: "{{ .Values.sso_header_enabled | default 0 }}" + PASSWORD_DISABLED: "{{ .Values.password_disabled | default 0 }}" SSO_HEADER_ENABLED: "{{ .Values.sso_header_enabled | default 0 }}" From 82d9507a3b241a70aed8bec333ab1954588ce5cb Mon Sep 17 00:00:00 2001 From: Francesco Servida Date: Fri, 2 Feb 2024 17:36:27 +0300 Subject: [PATCH 19/26] Promote/Demote superusers based on group --- backend/btrixcloud/auth.py | 16 ++++++++++++++++ chart/templates/configmap.yaml | 2 ++ 2 files changed, 18 insertions(+) diff --git a/backend/btrixcloud/auth.py b/backend/btrixcloud/auth.py index 5ac8a0bd22..72208113a2 100644 --- a/backend/btrixcloud/auth.py +++ b/backend/btrixcloud/auth.py @@ -41,6 +41,7 @@ PWD_CONTEXT = CryptContext(schemes=["bcrypt"], deprecated="auto") PASSWORD_DISABLED = bool(int(os.environ.get("PASSWORD_DISABLED", 0))) +SSO_SUPERUSER_GROUPS = os.environ.get("SSO_SUPERUSER_GROUPS", "browsertrix-admins").split(";") SSO_HEADER_ENABLED = bool(int(os.environ.get("SSO_HEADER_ENABLED", 0))) SSO_HEADER_GROUPS_SEPARATOR = os.environ.get("SSO_HEADER_GROUPS_SEPARATOR", ";") @@ -204,6 +205,8 @@ def openid_convertor(response: Dict[str, Any], session = None) -> OpenID: # ============================================================================ async def update_user_orgs(groups: [str], user, ops): + if user.is_superuser: + return orgs = await ops.get_org_slugs_by_ids() user_orgs, _ = await ops.get_orgs_for_user(user) for org_id, slug in orgs.items(): @@ -222,11 +225,23 @@ async def update_user_orgs(groups: [str], user, ops): del org.users[str(user.id)] await ops.update_users(org) +async def update_user_role(groups: [str], user, user_manager): + """Update if user should be superuser""" + is_superuser = False + for group in groups: + if group in SSO_SUPERUSER_GROUPS: + is_superuser = True + if user.is_superuser != is_superuser: + query: dict[str, str] = {} + query["is_superuser"] = is_superuser + await user_manager.users.find_one_and_update({"id": user.id}, {"$set": query}) + async def process_sso_user_login(user_manager, login_email, login_name, groups) -> User: user = await user_manager.get_by_email(login_email) ops = user_manager.org_ops if user: + await update_user_role(groups, user, user_manager) await update_user_orgs(groups, user, ops) # User exist, and correct orgs have been set, proceed to login return user @@ -235,6 +250,7 @@ async def process_sso_user_login(user_manager, login_email, login_name, groups) await user_manager.create_non_super_user(login_email, None, login_name, is_sso=True) user = await user_manager.get_by_email(login_email) if user: + await update_user_role(groups, user, user_manager) await update_user_orgs(groups, user, ops) # User has been created and correct orgs have been set, proceed to login return user diff --git a/chart/templates/configmap.yaml b/chart/templates/configmap.yaml index f3d1a5a476..b457d32f34 100644 --- a/chart/templates/configmap.yaml +++ b/chart/templates/configmap.yaml @@ -52,6 +52,8 @@ data: PASSWORD_DISABLED: "{{ .Values.password_disabled | default 0 }}" + SSO_SUPERUSER_GROUPS: {{ .Values.sso_superuser_groups | default "browsertrix-admins" }} + SSO_HEADER_ENABLED: "{{ .Values.sso_header_enabled | default 0 }}" SSO_HEADER_EMAIL_FIELD: {{ .Values.sso_header_email_field | default "x-remote-email" }} From 975f0b8eba95872b2365c8d4830571c9c681d60f Mon Sep 17 00:00:00 2001 From: Francesco Servida Date: Fri, 2 Feb 2024 18:23:21 +0300 Subject: [PATCH 20/26] Implemented ability to disable invites --- backend/btrixcloud/auth.py | 4 +- chart/templates/configmap.yaml | 2 + frontend/src/pages/home.ts | 37 ++++++++++++---- frontend/src/pages/org/settings.ts | 69 ++++++++++++++++++++---------- 4 files changed, 80 insertions(+), 32 deletions(-) diff --git a/backend/btrixcloud/auth.py b/backend/btrixcloud/auth.py index 72208113a2..acd761be1e 100644 --- a/backend/btrixcloud/auth.py +++ b/backend/btrixcloud/auth.py @@ -41,6 +41,7 @@ PWD_CONTEXT = CryptContext(schemes=["bcrypt"], deprecated="auto") PASSWORD_DISABLED = bool(int(os.environ.get("PASSWORD_DISABLED", 0))) +INVITES_ENABLED = not bool(int(os.environ.get("INVITES_DISABLED", 0))) # Negation as Workaround for inability of setting a value to 0 in values.yml: https://github.com/helm/helm/issues/3164 SSO_SUPERUSER_GROUPS = os.environ.get("SSO_SUPERUSER_GROUPS", "browsertrix-admins").split(";") SSO_HEADER_ENABLED = bool(int(os.environ.get("SSO_HEADER_ENABLED", 0))) @@ -84,6 +85,7 @@ class BearerResponse(BaseModel): class LoginMethodsInquiryResponse(BaseModel): login_methods: dict + invites_enabled: bool class OIDCRedirectResponse(BaseModel): redirect_url: str @@ -434,7 +436,7 @@ async def login_methods() -> LoginMethodsInquiryResponse: if SSO_OIDC_ENABLED: enabled_login_methods['sso_oidc'] = True - return LoginMethodsInquiryResponse(login_methods=enabled_login_methods) + return LoginMethodsInquiryResponse(login_methods=enabled_login_methods, invites_enabled=INVITES_ENABLED) @auth_jwt_router.post("/refresh", response_model=BearerResponse) async def refresh_jwt(user=Depends(current_active_user)): diff --git a/chart/templates/configmap.yaml b/chart/templates/configmap.yaml index b457d32f34..df94d76c3d 100644 --- a/chart/templates/configmap.yaml +++ b/chart/templates/configmap.yaml @@ -52,6 +52,8 @@ data: PASSWORD_DISABLED: "{{ .Values.password_disabled | default 0 }}" + INVITES_DISABLED: "{{ .Values.invites_disabled | default 0 }}" + SSO_SUPERUSER_GROUPS: {{ .Values.sso_superuser_groups | default "browsertrix-admins" }} SSO_HEADER_ENABLED: "{{ .Values.sso_header_enabled | default 0 }}" diff --git a/frontend/src/pages/home.ts b/frontend/src/pages/home.ts index b1e22f3ce3..a257d309a8 100644 --- a/frontend/src/pages/home.ts +++ b/frontend/src/pages/home.ts @@ -1,6 +1,7 @@ import { state, property, customElement } from "lit/decorators.js"; import { msg, localized, str } from "@lit/localize"; import { serialize } from "@shoelace-style/shoelace/dist/utilities/form.js"; +import { when } from "lit/directives/when.js"; import type { AuthState } from "@/utils/AuthService"; import type { CurrentUser } from "@/types/user"; @@ -21,6 +22,9 @@ export class Home extends LiteElement { @property({ type: String }) slug?: string; + @property({ type: Boolean }) + InvitesEnabled: boolean = true; + @state() private isInviteComplete?: boolean; @@ -46,6 +50,10 @@ export class Home extends LiteElement { } } + firstUpdated() { + this.checkEnabledInvites(); + } + willUpdate(changedProperties: Map) { if (changedProperties.has("slug") && this.slug) { this.navTo(`/orgs/${this.slug}`); @@ -160,14 +168,19 @@ export class Home extends LiteElement { >
-
-
-

- ${msg("Invite User to Org")} -

- ${this.renderInvite()} -
-
+ ${when(this.InvitesEnabled, () => + html` +
+
+

+ ${msg("Invite User to Org")} +

+ ${this.renderInvite()} +
+
+ ` + )} + ) { if (changedProperties.has("isAddingMember") && this.isAddingMember) { this.isAddMemberFormVisible = true; @@ -123,20 +130,24 @@ export class OrgSettings extends LiteElement { this.activePanel === "members", () => html`

${msg("Active Members")}

- - - ${msg("Invite New Member")} + ${when(this.InvitesEnabled, () => + html` + + + ${msg("Invite New Member")} + ` + )} `, () => html`

${this.tabLabels[this.activePanel]}

` )} @@ -309,15 +320,19 @@ export class OrgSettings extends LiteElement { ` )} - (this.isAddMemberFormVisible = true)} - @sl-after-hide=${() => (this.isAddMemberFormVisible = false)} - > - ${this.isAddMemberFormVisible ? this.renderInviteForm() : ""} - + ${when(this.InvitesEnabled, () => + html` + (this.isAddMemberFormVisible = true)} + @sl-after-hide=${() => (this.isAddMemberFormVisible = false)} + > + ${this.isAddMemberFormVisible ? this.renderInviteForm() : ""} + + ` + )} `; } @@ -586,4 +601,12 @@ export class OrgSettings extends LiteElement { }); } } + + async checkEnabledInvites() { + const resp = await fetch("/api/auth/jwt/login/methods"); + if (resp.status == 200) { + const data = await resp.json(); + this.InvitesEnabled = data.invites_enabled; + } + } } From 560066c2121f37b277747cd58ee3e807051930ec Mon Sep 17 00:00:00 2001 From: Francesco Servida Date: Fri, 2 Feb 2024 19:52:42 +0300 Subject: [PATCH 21/26] Dynamically disable last elements of interface for invites and invites creation backend --- backend/btrixcloud/invites.py | 6 ++++++ frontend/src/index.ts | 17 ++++++++++++++++- frontend/src/pages/users-invite.ts | 14 ++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/backend/btrixcloud/invites.py b/backend/btrixcloud/invites.py index 3b204f9768..7680ab3aaa 100644 --- a/backend/btrixcloud/invites.py +++ b/backend/btrixcloud/invites.py @@ -125,6 +125,12 @@ async def invite_user( :returns: is_new_user (bool), invite token (str) """ + if bool(int(os.environ.get("INVITES_DISABLED", 0))): + raise HTTPException( + status_code=405, + detail="invites_are_disabled", + ) + invite_code = uuid4().hex if org: diff --git a/frontend/src/index.ts b/frontend/src/index.ts index 0b900f7b24..a6f7ed1b0d 100644 --- a/frontend/src/index.ts +++ b/frontend/src/index.ts @@ -59,6 +59,9 @@ export class App extends LiteElement { @property({ type: String }) version?: string; + @property({ type: Boolean }) + InvitesEnabled: boolean = true; + private readonly router = new APIRouter(ROUTES); authService = new AuthService(); @@ -221,6 +224,10 @@ export class App extends LiteElement { ); } + firstUpdated() { + this.checkEnabledInvites(); + } + render() { return html`
@@ -311,7 +318,7 @@ export class App extends LiteElement { ${msg("Account Settings")} - ${this.appState.userInfo?.isAdmin + ${this.appState.userInfo?.isAdmin && this.InvitesEnabled ? html` this.navigate(ROUTES.usersInvite)} > @@ -925,4 +932,12 @@ export class App extends LiteElement { private clearSelectedOrg() { AppStateService.updateOrgSlug(null); } + + async checkEnabledInvites() { + const resp = await fetch("/api/auth/jwt/login/methods"); + if (resp.status == 200) { + const data = await resp.json(); + this.InvitesEnabled = data.invites_enabled; + } + } } diff --git a/frontend/src/pages/users-invite.ts b/frontend/src/pages/users-invite.ts index a89296b2a2..2fc2bd7cee 100644 --- a/frontend/src/pages/users-invite.ts +++ b/frontend/src/pages/users-invite.ts @@ -19,6 +19,10 @@ export class UsersInvite extends LiteElement { @state() private invitedEmail?: string; + firstUpdated() { + this.checkEnabledInvites(); + } + render() { let successMessage; @@ -58,4 +62,14 @@ export class UsersInvite extends LiteElement { private onSuccess(event: CustomEvent<{ inviteEmail: string }>) { this.invitedEmail = event.detail.inviteEmail; } + + async checkEnabledInvites() { + const resp = await fetch("/api/auth/jwt/login/methods"); + if (resp.status == 200) { + const data = await resp.json(); + if (!data.invites_enabled){ + this.navTo(`/`); + } + } + } } From cea4cfd4c40f20391512914c8c7156ee20cd7b34 Mon Sep 17 00:00:00 2001 From: Francesco Servida Date: Fri, 2 Feb 2024 19:58:47 +0300 Subject: [PATCH 22/26] Updated values.yml with new options --- chart/values.yaml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/chart/values.yaml b/chart/values.yaml index 9b931b4fda..cd5b5cc72e 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -83,9 +83,10 @@ superuser: # Set name for default organization created with superuser default_org: "My Organization" -# SSO Settings -# # SSO Configuration +# ========================================= +# Header SSO + # Enabled: 1, Disabled 0 (Default) # sso_header_enabled: 0 # sso_header_email_field: x-remote-email @@ -93,6 +94,8 @@ default_org: "My Organization" # sso_header_groups_field: x-remote-groups # sso_header_groups_separator: ';' +# Open ID Connect SSO + # sso_oidc_enabled: 0 # sso_oidc_auth_endpoint: https://localhost/auth/realms/example/protocol/openid-connect/auth # sso_oidc_token_endpoint: https://localhost/auth/realms/example/protocol/openid-connect/token @@ -105,6 +108,11 @@ default_org: "My Organization" # sso_oidc_userinfo_username_field: preferred_username # sso_oidc_userinfo_groups_field: isMemberOf +# Additional SSO Options +# sso_superuser_groups: browsertrix-admins # Semicolon separated list of groups whose users should be promoted to superadmins +# Optionally completely disable password based login +# password_disabled: 0 +# invites_disabled: 0 # API Image # ========================================= From 521fafab8b3aab7938bb8cc45235064c08c17935 Mon Sep 17 00:00:00 2001 From: Francesco Servida Date: Sat, 3 Feb 2024 11:35:44 +0300 Subject: [PATCH 23/26] Created Documentation and fixed callback url in values.yml --- chart/values.yaml | 2 +- docs/deploy/sso.md | 192 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 docs/deploy/sso.md diff --git a/chart/values.yaml b/chart/values.yaml index cd5b5cc72e..271820c7a2 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -102,7 +102,7 @@ default_org: "My Organization" # sso_oidc_userinfo_endpoint: https://localhost/auth/realms/example/protocol/openid-connect/userinfo # sso_oidc_client_id: yourclientid # sso_oidc_client_secret: yourclientsecret -# sso_oidc_redirect_url: http://localhost:30870/api/auth/jwt/login/oidc/callback +# sso_oidc_redirect_url: http://localhost:30870/log-in/oidc # sso_oidc_allow_http_insecure: 0 # sso_oidc_userinfo_email_field: email # sso_oidc_userinfo_username_field: preferred_username diff --git a/docs/deploy/sso.md b/docs/deploy/sso.md new file mode 100644 index 0000000000..3c32791ab8 --- /dev/null +++ b/docs/deploy/sso.md @@ -0,0 +1,192 @@ + + +# Deploying Single Sign On +- [Deploying Single Sign On](#deploying-single-sign-on) + - [General Configuration](#general-configuration) + - [Deploying SSO with OIDC](#deploying-sso-with-oidc) + - [Requirements](#requirements) + - [Configuration of IDP](#configuration-of-idp) + - [Configuration of Browsertrix](#configuration-of-browsertrix) + - [Deploying SSO with Headers](#deploying-sso-with-headers) + - [Requirements](#requirements-1) + - [Configuration of Proxy](#configuration-of-proxy) + - [Configuration of Browsertrix](#configuration-of-browsertrix-1) + + +Browsertrix allows for Single Sign On using either OIDC protocol or based on header values. + +Although it is technically possible to enable both OIDC and Header based SSO at the same time it is not suggested, to keep the user experience seamless. + +Single Logout is not supported as of now. + +## General Configuration +When using SSO for login to Browsertrix the users' organizations are assigned and removed dynamically based on users' group memberships provided by the IDP/Proxy. + +This can result in two conflicts with the invite functionality: +- Users org membership will be reset as soon as an user logs in with SSO, therefore any manual assignement will be lost. +- Users supposed to login with SSO are able to create accounts with password login if invited manually. + +If this is a problem for the insitution deploying Browsertrix it is possible to disable either functionality by setting the following variables in the local values.yml file: + +```yaml +password_disabled: 1 +invites_disabled: 1 +``` + +If disabling password altogether it is recommended to assign some users superuser privileges by adding them to "browsertrix-admins" group on the authentication backend. + +The superuser group can be changed by setting the __sso_superuser_groups__ variable in values.yml + +eg. +```yaml +sso_superuser_groups: browsertrix-admins;Domain Admins # Semicolon separated list of groups whose users should be promoted to superadmins +``` + +## Deploying SSO with OIDC +### Requirements +- IDP supporting OIDC. This guide will take Keycloak as an example. + +### Configuration of IDP +1. Create a new client scope + 1. Set a pertinent name, eg. browsertrix-authorization + 2. Type: None + 3. Protocol OpenID Connect + 4. In the Mappers + 1. Create new mapper, by configuration + 2. Type: Group Membership + 3. Name: isMemberOf + 4. Token Claim Name: isMemberOf + 1. This should the value set in values.yml (default if not set: isMemberOf) + 5. FullGroupPath: Off + 6. Add to ID token: On + 7. Add to access token: Off + 8. Add to userinfo: On +2. Create a new client in Keycloak +3. In client settings: + 1. Choose a client ID + 2. Set Root URL, Home URL, Admin URL to your main Browsertrix URL (eg. https://archive.example.com) + 3. Set Valid redirect URIs to https://archive.example.com/* + 4. Set Web origins to "+" + 5. Ensure Client Authentication and Standard Flow are enabled. +4. In client credentials + 1. Ensure authenticator is set to client id and secret + 2. Copy client secred to reuse in values.yml +5. In client scopes + 1. Add client scope, select previously created scope + 2. Set assigned type to Default + +When Browsertrix processes the OIDC login it is expected that the userinfo token has the following fields set, if your IDP uses different names ensure that it is reflected in the values.yml config. +- preferred_username + - string + - will be used as the user display name +- email + - string + - will be used as email and for matching user +- isMemberOf + - list of groups + - each group should be a single string + - will be used to dynamically add/remove organization membership for the user on login + +This can be verified with the Evaluate tool in Keycloak client scopes. +Evaluate with a test user and verify that in user info the following is correct: + 1. preferred_username is present and set to correct value + 2. email is present and set to correct value + 3. isMemberOf is present and set to a LIST of groups the user belongs to. + +### Configuration of Browsertrix +When configuring Broswertrix with OIDC as SSO protocol the following variables have to be set and match the previously configured settings in the IDP client. + +sso_oidc_auth_endpoint, sso_oidc_token_endpoint, sso_oidc_userinfo_endpoint can be found in the .well-known configuration for OIDC (eg. https://idp.example.com/auth/realms/example/.well-known/openid-configuration) + +```yaml +# Open ID Connect SSO + +sso_oidc_enabled: 1 +sso_oidc_auth_endpoint: https://idp.example.com/auth/realms/example/protocol/openid-connect/auth +sso_oidc_token_endpoint: https://idp.example.com/auth/realms/example/protocol/openid-connect/token +sso_oidc_userinfo_endpoint: https://idp.example.com/auth/realms/example/protocol/openid-connect/userinfo +sso_oidc_client_id: yourclientid +sso_oidc_client_secret: yourclientsecret +sso_oidc_redirect_url: https://browsertrix.example.com/log-in/oidc +# sso_oidc_allow_http_insecure: 0 (optional and not suggested, only for testing purposes) +# Optional, defaults to the below values +# sso_oidc_userinfo_email_field: email +# sso_oidc_userinfo_username_field: preferred_username +# sso_oidc_userinfo_groups_field: isMemberOf +``` + +## Deploying SSO with Headers +### Requirements +- Authenticating proxy. This guide will take Apache2 as an example configured with Shibboleth. + +### Configuration of Proxy +1. Configure proxy to authenticate users with your preferred Identity Provider. Ensure that username, email and group membership are provided to the proxy. Configuration of this step is outside of this guide scope. +2. Create virtual host for browsertrix +3. Protect the following paths behind authentication + - /log-in/header + - /api/auth/jwt/login/header +4. Transform and send the following user attributes as headers: + - email -> x-remote-email + - username -> x-remote-user + - group memberships (as a single, semicolon separated string) -> x-remote-groups + - If they are sent with different header names or you use a different separator for the group string ensure you edit values.yml accordingly. +```apache + + + ServerAdmin webmaster@example.com + ServerAlias archive.example.com + + + AuthType shibboleth + ShibRequestSetting requireSession true + Require valid-user + RequestHeader set X-Remote-User %{uid}e + RequestHeader set X-Remote-Email %{principalName}e + RequestHeader set X-Remote-Groups %{isMemberOf}e + + + + AuthType shibboleth + ShibRequestSetting requireSession true + Require valid-user + RequestHeader set X-Remote-User %{uid}e + RequestHeader set X-Remote-Email %{principalName}e + RequestHeader set X-Remote-Groups %{isMemberOf}e + + + SSLProxyEngine on + + ProxyPreserveHost On + + ProxyPass / https://k8s-ingress.example.com:443/ + ProxyPassReverse / https://k8s-ingress.example.com:443/ + + Protocols h2 http/1.1 + + ErrorLog ${APACHE_LOG_DIR}/error.log + CustomLog ${APACHE_LOG_DIR}/access.log combined + + SSLEngine on + + SSLCertificateFile /etc/letsencrypt/live/archive.example.com/fullchain.pem + SSLCertificateKeyFile /etc/letsencrypt/live/archive.example.com/privkey.pem + + + +``` + + +### Configuration of Browsertrix +When configuring Broswertrix with Header Auth as SSO protocol the following variables have to be set and match the previously configured settings in the IDP client. + +```yaml +# Header SSO + +# Enabled: 1, Disabled 0 (Default) +sso_header_enabled: 1 +# Optional, defaults to below values +# sso_header_email_field: x-remote-email +# sso_header_username_field: x-remote-user +# sso_header_groups_field: x-remote-groups +# sso_header_groups_separator: ';' +``` \ No newline at end of file From 4307fc256d68a5098af210f89e74f3f68480973c Mon Sep 17 00:00:00 2001 From: Francesco Servida Date: Sat, 3 Feb 2024 11:58:20 +0300 Subject: [PATCH 24/26] Doc fix --- docs/deploy/sso.md | 19 ++++++------------- mkdocs.yml | 1 + 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/docs/deploy/sso.md b/docs/deploy/sso.md index 3c32791ab8..ecbcc82512 100644 --- a/docs/deploy/sso.md +++ b/docs/deploy/sso.md @@ -1,18 +1,6 @@ # Deploying Single Sign On -- [Deploying Single Sign On](#deploying-single-sign-on) - - [General Configuration](#general-configuration) - - [Deploying SSO with OIDC](#deploying-sso-with-oidc) - - [Requirements](#requirements) - - [Configuration of IDP](#configuration-of-idp) - - [Configuration of Browsertrix](#configuration-of-browsertrix) - - [Deploying SSO with Headers](#deploying-sso-with-headers) - - [Requirements](#requirements-1) - - [Configuration of Proxy](#configuration-of-proxy) - - [Configuration of Browsertrix](#configuration-of-browsertrix-1) - - Browsertrix allows for Single Sign On using either OIDC protocol or based on header values. Although it is technically possible to enable both OIDC and Header based SSO at the same time it is not suggested, to keep the user experience seamless. @@ -56,7 +44,7 @@ sso_superuser_groups: browsertrix-admins;Domain Admins # Semicolon separated li 2. Type: Group Membership 3. Name: isMemberOf 4. Token Claim Name: isMemberOf - 1. This should the value set in values.yml (default if not set: isMemberOf) + 1. This should be the value set in values.yml (default if not set: isMemberOf) 5. FullGroupPath: Off 6. Add to ID token: On 7. Add to access token: Off @@ -119,6 +107,11 @@ sso_oidc_redirect_url: https://browsertrix.example.com/log-in/oidc ### Requirements - Authenticating proxy. This guide will take Apache2 as an example configured with Shibboleth. +!!! danger + + Direct access to the ingress endpoint in the kubernetes cluster must be limited to only the proxy. If not restricted any user with direct access to the ingress would be able to manually set the required headers. + + ### Configuration of Proxy 1. Configure proxy to authenticate users with your preferred Identity Provider. Ensure that username, email and group membership are provided to the proxy. Configuration of this step is outside of this guide scope. 2. Create virtual host for browsertrix diff --git a/mkdocs.yml b/mkdocs.yml index 1d3ab9a032..fbc43a594f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -50,6 +50,7 @@ nav: - deploy/index.md - deploy/local.md - deploy/remote.md + - deploy/sso.md - Ansible: - deploy/ansible/digitalocean.md - deploy/ansible/microk8s.md From af4e46183676219ed4e8fd94f870bf130fc10a82 Mon Sep 17 00:00:00 2001 From: Francesco Servida Date: Tue, 27 Feb 2024 10:48:22 +0300 Subject: [PATCH 25/26] Apply suggestions from code review Batch accepting review suggestions, thanks @shrinks99 Co-authored-by: Henry Wilkinson --- docs/deploy/sso.md | 32 +++++++++---------- .../src/features/accounts/account-settings.ts | 2 +- frontend/src/pages/log-in-header.ts | 2 +- frontend/src/pages/log-in-oidc.ts | 4 +-- frontend/src/pages/log-in.ts | 4 +-- 5 files changed, 22 insertions(+), 22 deletions(-) diff --git a/docs/deploy/sso.md b/docs/deploy/sso.md index ecbcc82512..0250178b50 100644 --- a/docs/deploy/sso.md +++ b/docs/deploy/sso.md @@ -1,29 +1,29 @@ -# Deploying Single Sign On -Browsertrix allows for Single Sign On using either OIDC protocol or based on header values. +# Deploying Single Sign-On +Browsertrix supports Single Sign-On (SSO) using either the OIDC protocol or based on header values. Although it is technically possible to enable both OIDC and Header based SSO at the same time it is not suggested, to keep the user experience seamless. -Single Logout is not supported as of now. +Single Logout is not supported. ## General Configuration -When using SSO for login to Browsertrix the users' organizations are assigned and removed dynamically based on users' group memberships provided by the IDP/Proxy. +When using SSO, user organization membership within Browsertrix is assigned and removed dynamically based on a user's group membership status provided by the IDP/Proxy. This can result in two conflicts with the invite functionality: - Users org membership will be reset as soon as an user logs in with SSO, therefore any manual assignement will be lost. -- Users supposed to login with SSO are able to create accounts with password login if invited manually. +- Users that are supposed to login with SSO are able to create accounts with password login if invited manually. -If this is a problem for the insitution deploying Browsertrix it is possible to disable either functionality by setting the following variables in the local values.yml file: +If this is a problem, it is possible to disable either function by setting the following variables in the local values.yml file: ```yaml password_disabled: 1 invites_disabled: 1 ``` -If disabling password altogether it is recommended to assign some users superuser privileges by adding them to "browsertrix-admins" group on the authentication backend. +If disabling passwords altogether, it is recommended to assign some users superuser privileges by adding them to the `browsertrix-admins` group on the authentication backend. -The superuser group can be changed by setting the __sso_superuser_groups__ variable in values.yml +The superuser group can be changed by setting the `sso_superuser_groups` variable in values.yml eg. ```yaml @@ -32,11 +32,11 @@ sso_superuser_groups: browsertrix-admins;Domain Admins # Semicolon separated li ## Deploying SSO with OIDC ### Requirements -- IDP supporting OIDC. This guide will take Keycloak as an example. +- IDP supporting OIDC. This guide uses [Keycloak](https://www.keycloak.org/) as an example. ### Configuration of IDP 1. Create a new client scope - 1. Set a pertinent name, eg. browsertrix-authorization + 1. Set a pertinent name, eg. `browsertrix-authorization` 2. Type: None 3. Protocol OpenID Connect 4. In the Mappers @@ -63,7 +63,7 @@ sso_superuser_groups: browsertrix-admins;Domain Admins # Semicolon separated li 1. Add client scope, select previously created scope 2. Set assigned type to Default -When Browsertrix processes the OIDC login it is expected that the userinfo token has the following fields set, if your IDP uses different names ensure that it is reflected in the values.yml config. +When Browsertrix processes the OIDC login, it is expected that the userinfo token has the following fields set, if your IDP uses different names ensure that it is reflected in the values.yml config. - preferred_username - string - will be used as the user display name @@ -82,7 +82,7 @@ Evaluate with a test user and verify that in user info the following is correct: 3. isMemberOf is present and set to a LIST of groups the user belongs to. ### Configuration of Browsertrix -When configuring Broswertrix with OIDC as SSO protocol the following variables have to be set and match the previously configured settings in the IDP client. +When configuring SSO with the OIDC protocol, the following variables must be set and match the previously configured settings in the IDP client. sso_oidc_auth_endpoint, sso_oidc_token_endpoint, sso_oidc_userinfo_endpoint can be found in the .well-known configuration for OIDC (eg. https://idp.example.com/auth/realms/example/.well-known/openid-configuration) @@ -105,16 +105,16 @@ sso_oidc_redirect_url: https://browsertrix.example.com/log-in/oidc ## Deploying SSO with Headers ### Requirements -- Authenticating proxy. This guide will take Apache2 as an example configured with Shibboleth. +- Authenticating proxy. This guide uses Apache2 as an example configured with Shibboleth. !!! danger - Direct access to the ingress endpoint in the kubernetes cluster must be limited to only the proxy. If not restricted any user with direct access to the ingress would be able to manually set the required headers. + Direct access to the ingress endpoint in the Kubernetes cluster must only be limited to the proxy. If not restricted, any user with direct access to the ingress would be able to manually set the required headers. ### Configuration of Proxy 1. Configure proxy to authenticate users with your preferred Identity Provider. Ensure that username, email and group membership are provided to the proxy. Configuration of this step is outside of this guide scope. -2. Create virtual host for browsertrix +2. Create virtual host for Browsertrix 3. Protect the following paths behind authentication - /log-in/header - /api/auth/jwt/login/header @@ -170,7 +170,7 @@ sso_oidc_redirect_url: https://browsertrix.example.com/log-in/oidc ### Configuration of Browsertrix -When configuring Broswertrix with Header Auth as SSO protocol the following variables have to be set and match the previously configured settings in the IDP client. +When configuring SSO with Header Auth, the following variables must be set and match the previously configured settings in the IDP client. ```yaml # Header SSO diff --git a/frontend/src/features/accounts/account-settings.ts b/frontend/src/features/accounts/account-settings.ts index 1b5a4e07b0..4310113ab2 100644 --- a/frontend/src/features/accounts/account-settings.ts +++ b/frontend/src/features/accounts/account-settings.ts @@ -139,7 +139,7 @@ export class AccountSettings extends LiteElement { ${when(this.userInfo.isSSO, () => html` - Some of the setting are managed by your organization and cannot be changed. + ${msg("Some settings are managed by your organization and cannot be changed.")}
` )} diff --git a/frontend/src/pages/log-in-header.ts b/frontend/src/pages/log-in-header.ts index f57e8e2fca..63b5ee46d2 100644 --- a/frontend/src/pages/log-in-header.ts +++ b/frontend/src/pages/log-in-header.ts @@ -71,7 +71,7 @@ export class LoginSsoHeader extends LiteElement { // will result in a route change } catch (e: any) { if (e.isApiError) { - let message = msg("Sorry, an error occurred while attempting Single Sign On"); + let message = msg("Sorry, an error occurred while attempting single sign-on"); this.serverError = message; } else { let message = msg("Something went wrong, couldn't sign you in"); diff --git a/frontend/src/pages/log-in-oidc.ts b/frontend/src/pages/log-in-oidc.ts index fbefe425bd..fbbc741e4f 100644 --- a/frontend/src/pages/log-in-oidc.ts +++ b/frontend/src/pages/log-in-oidc.ts @@ -79,7 +79,7 @@ export class LoginSsoOidc extends LiteElement { } catch (e: any) { if (e.isApiError) { - let message = msg("Sorry, an error occurred while attempting Single Sign On"); + let message = msg("Sorry, an error occurred while attempting single sign-on"); this.serverError = message; } else { let message = msg("Something went wrong, couldn't sign you in"); @@ -103,7 +103,7 @@ export class LoginSsoOidc extends LiteElement { // will result in a route change } catch (e: any) { if (e.isApiError) { - let message = msg("Sorry, an error occurred while attempting Single Sign On"); + let message = msg("Sorry, an error occurred while attempting single sign-on"); this.serverError = message; } else { let message = msg("Something went wrong, couldn't sign you in"); diff --git a/frontend/src/pages/log-in.ts b/frontend/src/pages/log-in.ts index fbb1e7106c..922cae104e 100644 --- a/frontend/src/pages/log-in.ts +++ b/frontend/src/pages/log-in.ts @@ -334,7 +334,7 @@ export class LogInPage extends LiteElement { ?loading=${this.formState.value === "signingIn"} ?disabled=${this.formState.value === "backendInitializing"} type="submit" - >${msg("Log In with Single Sign On")}${msg("Log In with Single Sign-On")} `; @@ -350,7 +350,7 @@ export class LogInPage extends LiteElement { ?loading=${this.formState.value === "signingIn"} ?disabled=${this.formState.value === "backendInitializing"} type="submit" - >${msg("Log In with Single Sign On")}${msg("Log In with Single Sign-On")} `; From fad02f2ccde245be0b7b8dc9a1043e6ee4426c05 Mon Sep 17 00:00:00 2001 From: Francesco Servida Date: Tue, 27 Feb 2024 10:51:05 +0300 Subject: [PATCH 26/26] Apply suggestions from code review Appliying one missed one suggestion Co-authored-by: Henry Wilkinson --- frontend/src/features/accounts/account-settings.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/features/accounts/account-settings.ts b/frontend/src/features/accounts/account-settings.ts index 4310113ab2..8e8a7efcd4 100644 --- a/frontend/src/features/accounts/account-settings.ts +++ b/frontend/src/features/accounts/account-settings.ts @@ -301,7 +301,7 @@ export class AccountSettings extends LiteElement {

${msg("Password")}

-

Password change is disabled for external accounts. Please change the password in your organization portal.

+

${msg("Password changes are disabled for external accounts. Please change your password in your organization's portal.")}

`