Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions docs/deployment/authentication/okta-auth.mdx
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
14 changes: 7 additions & 7 deletions docs/deployment/authentication/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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** | ✅ <br />(Predefiend roles) | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ | **OSS** |
| **Auth0** | ✅ <br />(Predefiend roles) | ✅ | 🚧 | 🚧 | ✅ | 🚧 | ❌ | **EE** |
| **DB** | ✅ <br />(Predefined roles) | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ | **OSS** |
| **Auth0** | ✅ <br />(Predefined roles) | ✅ | 🚧 | 🚧 | ✅ | 🚧 | ❌ | **EE** |
| **Keycloak** | ✅ <br />(Custom roles) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | **EE** |
| **Oauth2Proxy** | ✅ <br />(Predefiend roles) | ✅ | ❌ | ❌ | N/A | N/A | ✅ | **OSS** |
| **Azure AD** | ✅ <br />(Predefiend roles) | ✅ | ❌ | ❌ | By Azure AD | By Azure AD | ✅ | **EE** |
| **Okta** | ✅ <br />(Predefiend roles) | ✅ | ❌ | ✅ | ❌ | ❌ | ✅ | **OSS** |
| **OneLogin** | ✅ <br />(Predefiend roles) | ✅ | ❌ | ✅ | ❌ | ❌ | ✅ | **OSS** |
| **Oauth2Proxy** | ✅ <br />(Predefined roles) | ✅ | ❌ | ❌ | N/A | N/A | ✅ | **OSS** |
| **Azure AD** | ✅ <br />(Predefined roles) | ✅ | ❌ | ❌ | By Azure AD | By Azure AD | ✅ | **EE** |
| **Okta** | ✅ <br />(Predefined roles) | ✅ | ❌ | ✅ | ❌ | ❌ | ✅ | **OSS** |
| **OneLogin** | ✅ <br />(Predefined roles) | ✅ | ❌ | ✅ | ❌ | ❌ | ✅ | **OSS** |
### How To Configure
<Tip>
Some authentication providers require additional environment variables. These will be covered in detail on the specific authentication provider pages.
Expand All @@ -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.
Binary file added docs/images/okta.png
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 keep/identitymanager/identity_managers/okta/okta_authverifier.py
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"

Comment on lines -35 to +50
Copy link
Member

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?

Copy link
Author

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_URL environment variable.

The Okta documentation suggests that /keys is the appropriate endpoint.

I checked the three Okta tenants I have access to, and they all seem to use /keys for the jwks_url.

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

Copy link
Author

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/keys

This assumes your Okta OIDC issuer is something like: https://integrator-XXXXXXX.okta.com/oauth2/default

# 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)}"
)