diff --git a/docs/deployment/authentication/okta-auth.mdx b/docs/deployment/authentication/okta-auth.mdx new file mode 100644 index 0000000000..544e570aa8 --- /dev/null +++ b/docs/deployment/authentication/okta-auth.mdx @@ -0,0 +1,101 @@ +--- +title: "Okta Authentication" +--- + +This document provides information about the Okta integration in Keep. + +## Overview + +Keep supports Okta as an authentication provider, enabling: +- Single Sign-On (SSO) via Okta +- JWT token validation with JWKS +- User and group management through Okta +- Role-based access control +- Token refresh capabilities + +## Environment Variables + +### Backend Environment Variables + +| Variable | Description | Example | +|----------|-------------|---------| +| `AUTH_TYPE` | Set to `"OKTA"` to enable Okta authentication | `OKTA` | +| `OKTA_DOMAIN` | Your Okta domain | `company.okta.com` | +| `OKTA_API_TOKEN` | Admin API token for Okta management | `00aBcD3f4GhIJkl5m6NoPQr` | +| `OKTA_ISSUER` | The issuer URL for your Okta application | `https://company.okta.com/oauth2/default` | +| `OKTA_CLIENT_ID` | Client ID of your Okta application | `0oa1b2c3d4e5f6g7h8i9j` | +| `OKTA_CLIENT_SECRET` | Client Secret of your Okta application | `abcd1234efgh5678ijkl9012` | +| `OKTA_JWKS_URL` | (Optional) JWKS URL for your Okta application | `https://company.okta.com/oauth2/default/v1/keys` +| `OKTA_AUDIENCE` | (Optional) Audience for token validation | `api://keep` | +| `OKTA_ADMIN_ROLE` | (Optional) Role mapped to the Keep admin role | `KeepAdmin` | +| `OKTA_NOC_ROLE` | (Optional) Role mapped to the Keep noc role | `KeepNoc` | +| `OKTA_WEBHOOK_ROLE` | (Optional) Role mapped to the Keep webhook role | `KeepWebhook` | +| `OKTA_AUTO_CREATE_USER` | (Optional) Try to auto‑create users in Keep when they log in | `True` | + +### Frontend Environment Variables + +| Variable | Description | Example | +|----------|-------------|---------| +| `AUTH_TYPE` | Set to `"OKTA"` to enable Okta authentication | `OKTA` | +| `OKTA_DOMAIN` | Your Okta domain | `company.okta.com` | +| `OKTA_CLIENT_ID` | Client ID of your Okta application | `0oa1b2c3d4e5f6g7h8i9j` | +| `OKTA_CLIENT_SECRET` | Client Secret of your Okta application | `abcd1234efgh5678ijkl9012` | +| `OKTA_ISSUER` | The issuer URL for your Okta application | `https://company.okta.com/oauth2/default` | + +## Okta Configuration + +### Creating an Okta Application + +1. Sign in to your Okta Admin Console +2. Navigate to **Applications** > **Applications** +3. Click **Create App Integration** +4. Select **OIDC - OpenID Connect** as the Sign-in method +5. Choose **Web Application** as the Application type +6. Click **Next** + +### Application Settings + +1. **Name**: Enter a name for your application (e.g., "Keep") +2. **Grant type**: Select **Authorization Code** +3. **Sign-in redirect URIs**: Enter your app's callback URL, e.g., `https://your-keep-domain.com/api/auth/callback/okta` +4. **Sign-out redirect URIs**: Enter your app's sign-out URL, e.g., `https://your-keep-domain.com/signin` +5. **Assignments**: + - **Skip group assignment for now** or assign to appropriate groups +6. Click **Save** + +### Create API Token + +1. Navigate to **Security** > **API** +2. Select the **Tokens** tab +3. Click **Create Token** +4. Name your token (e.g., "Keep Integration") +5. Copy the generated token value – this is your `OKTA_API_TOKEN` + +### Configure OIDC Groups Claims + +1. Open your application in the Okta console +2. Go to the **Sign On** tab +3. Under **OpenID Connect ID Token**, click **Edit** +4. Set the **Groups claim filter**: + - Claim name: `groups` + - Filter: e.g., `Matches regex .*` (returns all groups assigned to the user) + + + Setting Up Okta Groups Claim + + +## Roles + +Keep roles are implemented through Okta groups. By default, the following group mappings are configured: + +| Okta Group | Keep Role | +|----------|-------------| +| `keep_admin` | `admin` | +| `keep_noc` | `noc` | +| `keep_webhook` | `webhook` | + +You can override these defaults by setting the backend environment variables: + +- `OKTA_ADMIN_ROLE` – role mapped to the Keep `admin` role +- `OKTA_NOC_ROLE` – role mapped to the Keep `noc` role +- `OKTA_WEBHOOK_ROLE` – role mapped to the Keep `webhook` role diff --git a/docs/deployment/authentication/overview.mdx b/docs/deployment/authentication/overview.mdx index b32703f599..84753b8369 100644 --- a/docs/deployment/authentication/overview.mdx +++ b/docs/deployment/authentication/overview.mdx @@ -26,13 +26,13 @@ Choosing the right authentication strategy depends on your specific use case, se | Identity Provider | RBAC | SAML/OIDC/SSO | LDAP | Resource-based permission | User Management | Group Management | On Prem | License | |:---:|:----:|:---------:|:----:|:-------------------------:|:----------------:|:-----------------:|:-------:|:-------:| | **No Auth** | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | **OSS** | -| **DB** | ✅
(Predefiend roles) | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ | **OSS** | -| **Auth0** | ✅
(Predefiend roles) | ✅ | 🚧 | 🚧 | ✅ | 🚧 | ❌ | **EE** | +| **DB** | ✅
(Predefined roles) | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ | **OSS** | +| **Auth0** | ✅
(Predefined roles) | ✅ | 🚧 | 🚧 | ✅ | 🚧 | ❌ | **EE** | | **Keycloak** | ✅
(Custom roles) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | **EE** | -| **Oauth2Proxy** | ✅
(Predefiend roles) | ✅ | ❌ | ❌ | N/A | N/A | ✅ | **OSS** | -| **Azure AD** | ✅
(Predefiend roles) | ✅ | ❌ | ❌ | By Azure AD | By Azure AD | ✅ | **EE** | -| **Okta** | ✅
(Predefiend roles) | ✅ | ❌ | ✅ | ❌ | ❌ | ✅ | **OSS** | -| **OneLogin** | ✅
(Predefiend roles) | ✅ | ❌ | ✅ | ❌ | ❌ | ✅ | **OSS** | +| **Oauth2Proxy** | ✅
(Predefined roles) | ✅ | ❌ | ❌ | N/A | N/A | ✅ | **OSS** | +| **Azure AD** | ✅
(Predefined roles) | ✅ | ❌ | ❌ | By Azure AD | By Azure AD | ✅ | **EE** | +| **Okta** | ✅
(Predefined roles) | ✅ | ❌ | ✅ | ❌ | ❌ | ✅ | **OSS** | +| **OneLogin** | ✅
(Predefined roles) | ✅ | ❌ | ✅ | ❌ | ❌ | ✅ | **OSS** | ### How To Configure Some authentication providers require additional environment variables. These will be covered in detail on the specific authentication provider pages. @@ -48,7 +48,7 @@ The authentication scheme on Keep is controlled with environment variables both | **Keycloak** | `AUTH_TYPE=KEYCLOAK` | `KEYCLOAK_URL`, `KEYCLOAK_REALM`, `KEYCLOAK_CLIENT_ID`, `KEYCLOAK_CLIENT_SECRET` | | **Oauth2Proxy** | `AUTH_TYPE=OAUTH2PROXY` | `OAUTH2_PROXY_USER_HEADER`, `OAUTH2_PROXY_ROLE_HEADER`, `OAUTH2_PROXY_AUTO_CREATE_USER` | | **AzureAD** | `AUTH_TYPE=AZUREAD` | See [AzureAD Configuration](/deployment/authentication/azuread-auth) | -| **Okta** | `AUTH_TYPE=OKTA` | `OKTA_DOMAIN`, `OKTA_CLIENT_ID`, `OKTA_CLIENT_SECRET` | +| **Okta** | `AUTH_TYPE=OKTA` | See [Okta Configuration](/deployment/authentication/okta-auth) | | **OneLogin** | `AUTH_TYPE=ONELOGIN` | See [OneLogin Configuration](/deployment/authentication/onelogin-auth) | For more details on each authentication strategy, including setup instructions and implications, refer to the respective sections. diff --git a/docs/images/okta.png b/docs/images/okta.png new file mode 100644 index 0000000000..98539d5b1b Binary files /dev/null and b/docs/images/okta.png differ diff --git a/keep/identitymanager/identity_managers/okta/okta_authverifier.py b/keep/identitymanager/identity_managers/okta/okta_authverifier.py index 615859e8dc..fa50281ba4 100644 --- a/keep/identitymanager/identity_managers/okta/okta_authverifier.py +++ b/keep/identitymanager/identity_managers/okta/okta_authverifier.py @@ -1,52 +1,69 @@ import logging -import os - import jwt from fastapi import Depends, HTTPException - +from keep.api.core.config import config +from keep.api.core.db import ( + user_exists, + create_user, + update_user_last_sign_in, + update_user_role, +) +from keep.api.core.dependencies import SINGLE_TENANT_UUID from keep.identitymanager.authenticatedentity import AuthenticatedEntity from keep.identitymanager.authverifierbase import AuthVerifierBase, oauth2_scheme +from keep.identitymanager.rbac import get_role_by_role_name logger = logging.getLogger(__name__) -# Define constant locally instead of importing it -DEFAULT_ROLE_NAME = "user" # Default role name for user access class OktaAuthVerifier(AuthVerifierBase): """Handles authentication and authorization for Okta""" def __init__(self, scopes: list[str] = []) -> None: super().__init__(scopes) - self.okta_issuer = os.environ.get("OKTA_ISSUER") - self.okta_audience = os.environ.get("OKTA_AUDIENCE") - self.okta_client_id = os.environ.get("OKTA_CLIENT_ID") - self.jwks_url = os.environ.get("OKTA_JWKS_URL") - + self.logger.info(f"Initializing Okta AuthVerifier with scopes: {scopes}") + self.okta_issuer = config("OKTA_ISSUER") + self.okta_audience = config("OKTA_AUDIENCE") + self.okta_client_id = config("OKTA_CLIENT_ID") + self.jwks_url = config("OKTA_JWKS_URL") + self.auto_create_user = config("OKTA_AUTO_CREATE_USER", default=True) + + self.role_mappings = { + config("OKTA_ADMIN_ROLE", default="keep_admin"): "admin", + config("OKTA_NOC_ROLE", default="keep_noc"): "noc", + config("OKTA_WEBHOOK_ROLE", default="keep_webhook"): "webhook", + } + # If no explicit JWKS URL is provided, we need an issuer to construct it if not self.jwks_url and not self.okta_issuer: - raise Exception("Missing both OKTA_JWKS_URL and OKTA_ISSUER environment variables") - + raise Exception( + "Missing both OKTA_JWKS_URL and OKTA_ISSUER environment variables" + ) + # Remove trailing slash if present on issuer if self.okta_issuer and self.okta_issuer.endswith("/"): self.okta_issuer = self.okta_issuer[:-1] - + # Initialize JWKS client - prefer direct JWKS URL if available if not self.jwks_url: - self.jwks_url = f"{self.okta_issuer}/.well-known/jwks.json" - + self.jwks_url = f"{self.okta_issuer}/v1/keys" + # At this point, self.jwks_url is guaranteed to be a string assert self.jwks_url is not None self.jwks_client = jwt.PyJWKClient(self.jwks_url) - logger.info(f"Initialized JWKS client with URL: {self.jwks_url}") + self.logger.info(f"Initialized Okta JWKS client with URL: {self.jwks_url}") + + self.logger.info("Okta Auth Verifier initialized") - def _verify_bearer_token(self, token: str = Depends(oauth2_scheme)) -> AuthenticatedEntity: + def _verify_bearer_token( + self, token: str = Depends(oauth2_scheme) + ) -> AuthenticatedEntity: if not token: raise HTTPException(status_code=401, detail="No token provided") - try: # Get the signing key directly from the JWT signing_key = self.jwks_client.get_signing_key_from_jwt(token).key - + # Decode and verify the token payload = jwt.decode( token, @@ -54,47 +71,123 @@ def _verify_bearer_token(self, token: str = Depends(oauth2_scheme)) -> Authentic algorithms=["RS256"], audience=self.okta_audience or self.okta_client_id, issuer=self.okta_issuer, - options={"verify_exp": True} + options={"verify_exp": True}, ) - + # Extract user info from token with simplified role handling - tenant_id = payload.get("keep_tenant_id", "keep") # Default to 'keep' if not specified - email = payload.get("email") or payload.get("sub") or payload.get("preferred_username") - - # Look for role in standard locations with a default of "user" - groups = payload.get("groups", []) - role_name = ( - payload.get("keep_role") or - payload.get("role") or - (groups[0] if groups else None) or - DEFAULT_ROLE_NAME # Use constant for consistency + tenant_id = payload.get( + "keep_tenant_id", SINGLE_TENANT_UUID + ) # Default to SINGLE_TENANT_UUID if not specified + user_name = ( + payload.get("email") + or payload.get("sub") + or payload.get("preferred_username") + ) + + okta_groups = payload.get("groups", []) + + okta_groups = [g.strip() for g in okta_groups] + + self.logger.debug(f"Okta Groups: {okta_groups}") + + # Define the priority order of roles + role_priority = ["admin", "noc", "webhook"] + mapped_role = None + + self.logger.debug(f"Okta to Keep Role Mapping: {self.role_mappings}") + + for role in role_priority: + self.logger.debug(f"Checking for role {role}") + for okta_group in okta_groups: + self.logger.debug(f"Checking for okta group {okta_group}") + mapped_role_name = self.role_mappings.get(okta_group, "") + self.logger.debug( + f"Checking for mapped role name {mapped_role_name}" + ) + if role == mapped_role_name: + try: + self.logger.debug(f"Getting role {mapped_role_name}") + mapped_role = get_role_by_role_name(mapped_role_name) + self.logger.debug(f"Role {mapped_role_name} found") + break + except HTTPException: + self.logger.debug(f"Role {mapped_role_name} not found") + continue + if mapped_role: + self.logger.debug(f"Role {mapped_role.get_name()} found") + break + # if no valid role was found, throw a 403 exception + if not mapped_role: + self.logger.warning( + f"No valid role-group mapping found among {okta_groups}" + ) + raise HTTPException( + status_code=403, + detail=f"No valid role found among {okta_groups}", + ) + + if not user_name: + raise HTTPException(status_code=401, detail="No user name in token") + + # auto provision user + if self.auto_create_user and not user_exists( + tenant_id=tenant_id, username=user_name + ): + self.logger.info(f"Auto provisioning user: {user_name}") + create_user( + tenant_id=tenant_id, + username=user_name, + role=mapped_role.get_name(), + password="", + ) + self.logger.info(f"User {user_name} created") + elif user_exists(tenant_id=tenant_id, username=user_name): + # update last login + self.logger.debug(f"Updating last login for user: {user_name}") + try: + update_user_last_sign_in(tenant_id=tenant_id, username=user_name) + self.logger.debug(f"Last login updated for user: {user_name}") + except Exception: + self.logger.warning( + f"Failed to update last login for user: {user_name}" + ) + pass + # update role + self.logger.debug(f"Updating role for user: {user_name}") + try: + update_user_role( + tenant_id=tenant_id, + username=user_name, + role=mapped_role.get_name(), + ) + self.logger.debug(f"Role updated for user: {user_name}") + except Exception: + self.logger.warning(f"Failed to update role for user: {user_name}") + pass + + self.logger.info( + f"User {user_name} authenticated with role {mapped_role.get_name()}" ) - - org_id = payload.get("org_id") - org_realm = payload.get("org_realm") - - if not email: - raise HTTPException(status_code=401, detail="No email in token") - - logger.info(f"Successfully verified token for user with email: {email}") return AuthenticatedEntity( tenant_id=tenant_id, - email=email, - role=role_name, - org_id=org_id, - org_realm=org_realm, - token=token + email=user_name, + role=mapped_role.get_name(), + token=token, ) - + except jwt.exceptions.InvalidKeyError as e: - logger.error(f"Invalid key error during token validation: {str(e)}") - raise HTTPException(status_code=401, detail="Invalid signing key - token validation failed") + self.logger.error(f"Invalid key error during token validation: {str(e)}") + raise HTTPException( + status_code=401, detail="Invalid signing key - token validation failed" + ) except jwt.ExpiredSignatureError: - logger.warning("Token has expired") + self.logger.warning("Token has expired") raise HTTPException(status_code=401, detail="Token has expired") except jwt.InvalidTokenError as e: - logger.warning(f"Invalid token: {str(e)}") + self.logger.warning(f"Invalid token: {str(e)}") raise HTTPException(status_code=401, detail=f"Invalid token: {str(e)}") except Exception as e: - logger.exception("Failed to validate token") - raise HTTPException(status_code=401, detail=f"Token validation failed: {str(e)}") \ No newline at end of file + self.logger.exception("Failed to validate token") + raise HTTPException( + status_code=401, detail=f"Token validation failed: {str(e)}" + )