-
Notifications
You must be signed in to change notification settings - Fork 1.1k
fix: enhance okta sso #5424
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
cody-eding
wants to merge
1
commit into
keephq:main
Choose a base branch
from
cody-eding:feature/oktaenhancement
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+252
−58
Open
fix: enhance okta sso #5424
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
|
|
||
| <Frame> | ||
| <img src="/images/okta.png" alt="Setting Up Okta Groups Claim"/> | ||
| </Frame> | ||
|
|
||
| ## 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
195 changes: 144 additions & 51 deletions
195
keep/identitymanager/identity_managers/okta/okta_authverifier.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,100 +1,193 @@ | ||
| 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, | ||
| key=signing_key, | ||
| 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)}") | ||
| self.logger.exception("Failed to validate token") | ||
| raise HTTPException( | ||
| status_code=401, detail=f"Token validation failed: {str(e)}" | ||
| ) | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
how did it work until now?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was unable to get the original implementation working unless I overrode the pre-configured default by setting the
OKTA_JWKS_URLenvironment variable.The Okta documentation suggests that
/keysis the appropriate endpoint.I checked the three Okta tenants I have access to, and they all seem to use
/keysfor thejwks_url.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
what have you set to make it work, I am struggling with that actually
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With my test integration tenant, I set the following in the compose file for both the API and UI with the currently released code:
OKTA_JWKS_URL=https://integrator-XXXXXXX.okta.com/oauth2/default/v1/keysThis assumes your Okta OIDC issuer is something like:
https://integrator-XXXXXXX.okta.com/oauth2/default