| name | kanidm-expert |
| version | 2.0.0 |
| description | Kanidm identity management server with OAuth2/OIDC provider configuration, LDAP compatibility, and WebAuthn passwordless authentication. Use when deploying Kanidm, configuring OAuth2 clients in Kanidm, setting up LDAP gateway, or implementing WebAuthn with Kanidm as IdP. Do NOT use for general OAuth2/OIDC flows without Kanidm or other identity providers like Keycloak or Auth0. |
| compatibility | Kanidm 1.3+ |
| risk_level | HIGH |
| token_budget | 5500 |
Kanidm Identity Management Expert - Code Generation Rules
0. Anti-Hallucination Protocol
0.2 Security Patterns (security rules)
CWE-532: Sensitive Info in Logs (CVE-2025-30205)
- Do not: Use NixOS module with
adminPasswordFile on unpatched versions
- Instead: Audit logs for leaked creds, rotate exposed passwords
CWE-287: OAuth2 Auth Code Reuse
- Do not: Assume auth codes are one-time-use without verification
- Instead: Implement client-side validation, monitor for code reuse
CWE-269: Account Policy Downgrade
- Do not: Assume MFA policies persist across migrations
- Instead: Review
idm_all_accounts policies after upgrades, verify MFA settings
CWE-90: LDAP Injection
- Do not: Use LDAP gateway with unsanitized user input in filters
- Instead: Parameterized queries, escape
( ) ! | & * chars, whitelist validation
1. Security Principles
1.1 OAuth2/OIDC Configuration (CWE-287)
Principle: Use PKCE for all clients. Validate redirect URIs strictly.
[oauth2_client.myapp]
basic_secret = "plaintext-secret"
redirect_uris = ["*"]
[[oauth2_rs_basic_secret]]
name = "myapp"
origin = "https://myapp.example.com"
displayname = "My Application"
redirect_uris = ["https://myapp.example.com/auth/callback"]
enable_pkce = true
prefer_short_username = false
scope_maps = [
{ "group" = "myapp_users", "scopes" = ["openid", "profile", "email"] },
{ "group" = "myapp_admins", "scopes" = ["openid", "profile", "email", "groups"] },
]
1.2 WebAuthn/Passkey Security (CWE-308)
Principle: Prefer passkeys over passwords. Enforce MFA for privileged accounts.
1.3 Group-Based Access Control (CWE-284)
Principle: Use groups for authorization. Minimize direct user permissions.
1.4 Session Management (CWE-613)
Principle: Configure appropriate session timeouts. Enable session revocation.
1.5 LDAP Security (CWE-90)
Principle: Use LDAPS only. Validate all LDAP queries for injection.
1.6 Credential Storage (CWE-916)
Principle: Use Kanidm's built-in credential storage. Never store passwords externally.
2. Version Requirements
Use these minimum versions:
kanidm-server: v1.3.0+
kanidm-client: v1.3.0+
kanidm-tools: v1.3.0+
3. Code Patterns
3.1 WHEN configuring Kanidm server
bindaddress = "0.0.0.0:8443"
ldapbindaddress = "0.0.0.0:636"
bindaddress = "[::]:8443"
ldapbindaddress = "[::]:636"
domain = "idm.example.com"
origin = "https://idm.example.com"
tls_chain = "/etc/kanidm/certs/fullchain.pem"
tls_key = "/etc/kanidm/certs/privkey.pem"
db_path = "/var/lib/kanidm/kanidm.db"
db_fs_type = "zfs"
trust_x_forward_for = true
role = "WriteReplica"
log_level = "info"
[online_backup]
path = "/var/lib/kanidm/backup"
schedule = "0 2 * * *"
versions = 7
[replication]
3.2 WHEN creating OAuth2/OIDC clients programmatically
let client = kanidm_client::KanidmClient::new("https://idm.example.com");
client.auth_simple_password("admin", "password123").await?;
use kanidm_client::{KanidmClient, KanidmClientBuilder};
use kanidm_proto::v1::{
Entry, CreateRequest, ModifyRequest, Modify,
OAuth2RsBasicSecretRequest, OAuth2RsBasicSecret,
};
use std::collections::BTreeSet;
#[derive(Clone)]
pub struct KanidmConfig {
pub url: String,
pub ca_path: Option<String>,
pub connect_timeout: u64,
}
pub struct KanidmAdmin {
client: KanidmClient,
}
impl KanidmAdmin {
pub async fn new(config: &KanidmConfig) -> Result<Self, anyhow::Error> {
let mut builder = KanidmClientBuilder::new()
.address(config.url.clone())
.connect_timeout(config.connect_timeout);
if let Some(ca_path) = &config.ca_path {
builder = builder.add_root_certificate_filepath(ca_path)?;
}
let client = builder.build()?;
let token = std::env::var("KANIDM_TOKEN")
.map_err(|_| anyhow::anyhow!("KANIDM_TOKEN not set"))?;
client.auth_with_token(&token).await?;
Ok(Self { client })
}
pub async fn create_oauth2_client(
&self,
name: &str,
display_name: &str,
origin: &str,
redirect_uris: &[String],
allowed_groups: &[String],
) -> Result<OAuth2RsBasicSecret, anyhow::Error> {
for uri in redirect_uris {
if !uri.starts_with("https://") {
anyhow::bail!("Redirect URI must use HTTPS: {}", uri);
}
if uri.contains('*') {
anyhow::bail!("Wildcard redirect URIs not allowed: {}", uri);
}
}
let request = OAuth2RsBasicSecretRequest {
name: name.to_string(),
displayname: display_name.to_string(),
origin: origin.to_string(),
redirect_uris: redirect_uris.to_vec(),
enable_pkce: true,
enable_refresh_token: true,
strict_redirect_uri: true,
};
let secret = self.client
.idm_oauth2_rs_basic_secret_create(&request)
.await?;
for group in allowed_groups {
self.client
.idm_oauth2_rs_update_scope_map(
name,
group,
&["openid", "profile", "email"],
)
.await?;
}
self.client
.idm_oauth2_rs_update_scope_map(
name,
&format!("{}_admins", name),
&["openid", "profile", "email", "groups"],
)
.await?;
Ok(secret)
}
pub async fn create_service_account(
&self,
name: &str,
display_name: &str,
groups: &[String],
) -> Result<String, anyhow::Error> {
self.client
.idm_service_account_create(name, display_name)
.await?;
for group in groups {
self.client
.idm_group_add_members(group, &[name])
.await?;
}
let token = self.client
.idm_service_account_generate_api_token(name, "automation", None)
.await?;
Ok(token)
}
pub async fn configure_mfa_policy(
&self,
group_name: &str,
require_mfa: bool,
allowed_methods: &[&str],
) -> Result<(), anyhow::Error> {
let policy_name = format!("{}_cred_policy", group_name);
self.client
.idm_group_set_credential_policy(
group_name,
&policy_name,
)
.await?;
if require_mfa {
self.client
.idm_credential_policy_set_mfa_required(&policy_name, true)
.await?;
}
for method in allowed_methods {
match *method {
"passkey" => {
self.client
.idm_credential_policy_enable_webauthn(&policy_name)
.await?;
}
"totp" => {
self.client
.idm_credential_policy_enable_totp(&policy_name)
.await?;
}
_ => anyhow::bail!("Unknown auth method: {}", method),
}
}
Ok(())
}
}
3.3 WHEN integrating applications with Kanidm OIDC
const authUrl = `${KANIDM_URL}/oauth2/authorize?client_id=${CLIENT_ID}&redirect_uri=${REDIRECT}`;
import { Issuer, generators, Client, TokenSet } from 'openid-client';
import { z } from 'zod';
interface KanidmOIDCConfig {
issuerUrl: string;
clientId: string;
clientSecret: string;
redirectUri: string;
scopes: string[];
}
const UserInfoSchema = z.object({
sub: z.string(),
name: z.string().optional(),
preferred_username: z.string(),
email: z.string().email().optional(),
email_verified: z.boolean().optional(),
groups: z.array(z.string()).optional(),
});
type UserInfo = z.infer<typeof UserInfoSchema>;
class KanidmOIDC {
private client: Client | null = null;
private config: KanidmOIDCConfig;
constructor(config: KanidmOIDCConfig) {
this.config = config;
}
async initialize(): Promise<void> {
const issuer = await Issuer.discover(this.config.issuerUrl);
this.client = new issuer.Client({
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
redirect_uris: [this.config.redirectUri],
response_types: ['code'],
token_endpoint_auth_method: 'client_secret_basic',
});
}
generateAuthorizationUrl(state: string): { url: string; codeVerifier: string } {
if (!this.client) {
throw new Error('OIDC client not initialized');
}
const codeVerifier = generators.codeVerifier();
const codeChallenge = generators.codeChallenge(codeVerifier);
const url = this.client.authorizationUrl({
scope: this.config.scopes.join(' '),
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
prompt: 'login',
});
return { url, codeVerifier };
}
async handleCallback(
params: { code: string; state: string },
expectedState: string,
codeVerifier: string
): Promise<{ tokens: TokenSet; userInfo: UserInfo }> {
if (!this.client) {
throw new Error('OIDC client not initialized');
}
if (params.state !== expectedState) {
throw new Error('Invalid state parameter');
}
const tokens = await this.client.callback(
this.config.redirectUri,
{ code: params.code, state: params.state },
{ state: expectedState, code_verifier: codeVerifier }
);
const rawUserInfo = await this.client.userinfo(tokens.access_token!);
const userInfo = UserInfoSchema.parse(rawUserInfo);
return { tokens, userInfo };
}
async refreshTokens(refreshToken: string): Promise<TokenSet> {
if (!this.client) {
throw new Error('OIDC client not initialized');
}
return this.client.refresh(refreshToken);
}
async revokeToken(token: string, tokenType: 'access_token' | 'refresh_token'): Promise<void> {
if (!this.client) {
throw new Error('OIDC client not initialized');
}
await this.client.revoke(token, tokenType);
}
hasRequiredGroups(userInfo: UserInfo, requiredGroups: string[]): boolean {
if (!userInfo.groups) {
return false;
}
return requiredGroups.every((group) => userInfo.groups!.includes(group));
}
}
import { Request, Response, NextFunction } from 'express';
import session from 'express-session';
declare module 'express-session' {
interface SessionData {
oidcState?: string;
codeVerifier?: string;
tokens?: TokenSet;
userInfo?: UserInfo;
}
}
function createKanidmAuthMiddleware(oidc: KanidmOIDC, requiredGroups?: string[]) {
return async (req: Request, res: Response, next: NextFunction) => {
if (req.session.tokens && req.session.userInfo) {
if (req.session.tokens.expired()) {
try {
const newTokens = await oidc.refreshTokens(req.session.tokens.refresh_token!);
req.session.tokens = newTokens;
} catch {
return res.redirect('/auth/login');
}
}
if (requiredGroups && !oidc.hasRequiredGroups(req.session.userInfo, requiredGroups)) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
return next();
}
return res.redirect('/auth/login');
};
}
3.4 WHEN configuring LDAP integration
ldap_url: ldap://idm.example.com:389
---
apiVersion: v1
kind: ConfigMap
metadata:
name: kanidm-ldap-config
namespace: auth
data:
ldap-config.yaml: |
# LDAP client configuration for Kanidm
servers:
- url: ldaps://idm.example.com:636
start_tls: false # Already using LDAPS
tls_verify: true
tls_ca_cert: /etc/ldap/certs/ca.crt
bind:
method: simple
dn_template: "spn={{ .Username }}@example.com,dc=idm,dc=example,dc=com"
search:
base_dn: "dc=idm,dc=example,dc=com"
user_filter: "(|(uid={{ .Username }})(mail={{ .Username }}))"
group_filter: "(member={{ .UserDN }})"
user_attrs:
- uid
- mail
- displayName
- memberOf
group_attrs:
- cn
- member
timeout:
connect: 5s
read: 10s
---
apiVersion: v1
kind: Secret
metadata:
name: gitlab-ldap-secret
namespace: gitlab
stringData:
ldap.yaml: |
main:
label: 'Kanidm'
host: 'idm.example.com'
port: 636
uid: 'uid'
encryption: 'simple_tls'
verify_certificates: true
ca_file: '/etc/gitlab/ldap/ca.crt'
bind_dn: 'dn=token'
password: '${LDAP_BIND_TOKEN}'
active_directory: false
allow_username_or_email_login: true
base: 'dc=idm,dc=example,dc=com'
user_filter: '(class=person)'
group_base: 'dc=idm,dc=example,dc=com'
admin_group: 'gitlab_admins'
attributes:
username: 'uid'
email: 'mail'
name: 'displayName'
3.5 WHEN implementing passkey/WebAuthn registration
async fn login(username: &str, password: &str) -> Result<Token, Error> {
client.auth_simple_password(username, password).await
}
use kanidm_client::KanidmClient;
use kanidm_proto::v1::{
AuthAllowed, AuthCredential, AuthIssueSession, AuthMech, AuthRequest, AuthState,
};
use webauthn_rs::prelude::*;
pub struct PasskeyAuth {
client: KanidmClient,
}
impl PasskeyAuth {
pub async fn start_authentication(
&self,
username: &str,
) -> Result<AuthenticationContext, anyhow::Error> {
let auth_state = self.client
.auth_step_init(username)
.await?;
let allowed = match auth_state.state {
AuthState::Choose(allowed) => allowed,
_ => anyhow::bail!("Unexpected auth state"),
};
let use_passkey = allowed.iter().any(|a| matches!(a, AuthAllowed::Passkey));
let use_totp = allowed.iter().any(|a| matches!(a, AuthAllowed::Totp));
if use_passkey {
let challenge_state = self.client
.auth_step_begin(AuthMech::Passkey)
.await?;
match challenge_state.state {
AuthState::Continue(allowed) => {
for method in allowed {
if let AuthAllowed::Passkey = method {
return Ok(AuthenticationContext::Passkey {
challenge: challenge_state,
});
}
}
}
_ => anyhow::bail!("Unexpected state after passkey begin"),
}
}
if use_totp {
let totp_state = self.client
.auth_step_begin(AuthMech::Totp)
.await?;
return Ok(AuthenticationContext::Totp {
state: totp_state,
});
}
anyhow::bail!("No supported authentication methods available")
}
pub async fn complete_passkey_auth(
&self,
credential: PublicKeyCredential,
) -> Result<AuthToken, anyhow::Error> {
let response = self.client
.auth_step_passkey(&credential)
.await?;
match response.state {
AuthState::Success(token) => Ok(token),
AuthState::Denied(_) => anyhow::bail!("Authentication denied"),
_ => anyhow::bail!("Unexpected auth state after passkey"),
}
}
pub async fn complete_totp_auth(
&self,
totp_code: &str,
) -> Result<AuthToken, anyhow::Error> {
if !totp_code.chars().all(|c| c.is_ascii_digit()) || totp_code.len() != 6 {
anyhow::bail!("Invalid TOTP code format");
}
let response = self.client
.auth_step_totp(totp_code.parse()?)
.await?;
match response.state {
AuthState::Success(token) => Ok(token),
AuthState::Denied(reason) => anyhow::bail!("Authentication denied: {}", reason),
_ => anyhow::bail!("Unexpected auth state after TOTP"),
}
}
pub async fn register_passkey(
&self,
token: &str,
passkey_label: &str,
) -> Result<CreationChallengeResponse, anyhow::Error> {
self.client.set_token(token).await;
let challenge = self.client
.idm_account_credential_passkey_register_start(passkey_label)
.await?;
Ok(challenge)
}
pub async fn complete_passkey_registration(
&self,
credential: RegisterPublicKeyCredential,
) -> Result<(), anyhow::Error> {
self.client
.idm_account_credential_passkey_register_finish(&credential)
.await?;
Ok(())
}
}
pub enum AuthenticationContext {
Passkey {
challenge: AuthState,
},
Totp {
state: AuthState,
},
}
4. Anti-Patterns
Do not:
- Disable PKCE for OAuth2 clients
- Use wildcard redirect URIs
- Store credentials outside Kanidm
- Allow password-only authentication for admins
- Skip TLS for LDAP connections
- Grant direct permissions instead of groups
- Use long-lived API tokens without rotation
- Disable WebAuthn/passkey support
- Trust X-Forwarded headers from untrusted sources
5. Testing
ALWAYS test Kanidm configurations:
#!/bin/bash
set -euo pipefail
KANIDM_URL="${KANIDM_URL:-https://idm.example.com}"
echo "=== Kanidm Security Tests ==="
6. Pre-Generation Checklist
Before generating any Kanidm code: