diff --git a/backend/btrixcloud/auth.py b/backend/btrixcloud/auth.py
index d2c2fcbfce..acd761be1e 100644
--- a/backend/btrixcloud/auth.py
+++ b/backend/btrixcloud/auth.py
@@ -17,11 +17,16 @@
Depends,
WebSocket,
APIRouter,
+ Header,
)
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
-from .models import User
+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
# ============================================================================
@@ -35,6 +40,29 @@
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)))
+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", "")
+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)))
+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"
RESET_AUD = "btrix:reset"
@@ -55,6 +83,12 @@ class BearerResponse(BaseModel):
access_token: str
token_type: str
+class LoginMethodsInquiryResponse(BaseModel):
+ login_methods: dict
+ invites_enabled: bool
+
+class OIDCRedirectResponse(BaseModel):
+ redirect_url: str
# ============================================================================
# pylint: disable=too-few-public-methods
@@ -137,6 +171,97 @@ def generate_password() -> str:
return pwd.genword()
+# ============================================================================
+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)
+
+ 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):
+ 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():
+ 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)
+
+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
+ else:
+ # Create verified user
+ 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
+ else:
+ raise HTTPException(
+ status_code=500,
+ detail="user_creation_failed",
+ )
+
# ============================================================================
# pylint: disable=raise-missing-from
def init_jwt_auth(user_manager):
@@ -176,6 +301,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)
@@ -227,6 +358,85 @@ 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.get("/login/header", response_model=BearerResponse)
+ async def login_header(
+ 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,
+ 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",
+ )
+
+ login_email = x_remote_email
+ login_name = x_remote_user
+ groups = [group.lower() for group in x_remote_groups.split(SSO_HEADER_GROUPS_SEPARATOR)]
+
+ user = await process_sso_user_login(user_manager, login_email, login_name, groups)
+ return get_bearer_response(user)
+
+ @auth_jwt_router.get("/login/oidc", response_model=OIDCRedirectResponse)
+ async def login_oidc() -> OIDCRedirectResponse:
+ if not SSO_OIDC_ENABLED:
+ raise HTTPException(
+ status_code=405,
+ detail="sso_is_disabled",
+ )
+
+ """Redirect the user to the OIDC login page."""
+ with sso:
+ 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_oidc_callback(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_methods() -> LoginMethodsInquiryResponse:
+ enabled_login_methods = {
+ 'password': True,
+ 'sso_header': False,
+ 'sso_oidc': False
+ }
+
+ if PASSWORD_DISABLED:
+ enabled_login_methods['password'] = 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, invites_enabled=INVITES_ENABLED)
@auth_jwt_router.post("/refresh", response_model=BearerResponse)
async def refresh_jwt(user=Depends(current_active_user)):
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/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..2ab283fe9b 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:
@@ -503,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")
@@ -518,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")
@@ -663,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)
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 9eb8d9e422..df94d76c3d 100644
--- a/chart/templates/configmap.yaml
+++ b/chart/templates/configmap.yaml
@@ -50,6 +50,45 @@ data:
CRAWLER_CHANNELS_JSON: "/ops-configs/crawler_channels.json"
+ 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 }}"
+
+ 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 }}"
+
+ 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/chart/values.yaml b/chart/values.yaml
index 63685bea5a..271820c7a2 100644
--- a/chart/values.yaml
+++ b/chart/values.yaml
@@ -83,6 +83,36 @@ superuser:
# Set name for default organization created with superuser
default_org: "My Organization"
+# SSO Configuration
+# =========================================
+# Header SSO
+
+# 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: ';'
+
+# 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
+# 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/log-in/oidc
+# 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
+
+# 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
# =========================================
diff --git a/docs/deploy/sso.md b/docs/deploy/sso.md
new file mode 100644
index 0000000000..0250178b50
--- /dev/null
+++ b/docs/deploy/sso.md
@@ -0,0 +1,185 @@
+
+
+# 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.
+
+## General Configuration
+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 that are supposed to login with SSO are able to create accounts with password login if invited manually.
+
+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 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
+
+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 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`
+ 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 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
+ 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 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)
+
+```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 uses Apache2 as an example configured with Shibboleth.
+
+!!! danger
+
+ 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
+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
+