Authentication Plugin Development
This guide covers designing, building, and deploying dynamic authentication plugins for Keystone using WebAssembly (WASM).
Overview
Dynamic auth plugins extend Keystone’s authentication without recompiling or forking:
- Compile once to
.wasm, distribute to every Keystone node - Three operating modes: full authentication authority, claims transformer, or request router
- Curated host functions - plugins cannot access arbitrary storage or network, only what’s explicitly granted
- Namespace-scoped identity - plugins can only authenticate users they themselves provision (except via admin-authorized linking)
All code in this guide is derived from the real, compiled reference plugin
fixture used by Keystone’s own test suite
(crates/auth-plugin-runtime/tests/fixtures/reference-plugin/src/lib.rs) and
the actual wire contract types
(crates/auth-plugin-runtime/src/{auth_contract,mapping_contract,route_contract}.rs).
If your plugin’s JSON doesn’t round-trip against those types, the host rejects
it as malformed (ADR 0025 §7) - there is no leniency in the decoder.
See ADR 0025 for the complete threat model and design rationale.
Quick Start: Hello-World Plugin
Build and test a minimal full_auth plugin.
1. Setup
# Add Rust target for WebAssembly
rustup target add wasm32-unknown-unknown
# Create a new Rust library
cargo new --lib my_plugin
cd my_plugin
2. Dependencies
Edit Cargo.toml:
[package]
name = "my_plugin"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"] # REQUIRED: produces .wasm, not .rlib
[dependencies]
extism-pdk = "1"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
3. Plugin Code
Every host function Keystone exposes takes exactly one JSON-encoded string
argument and returns exactly one JSON-encoded string - never multiple typed
arguments. The #[host_fn] extern "ExtismHost" block below is how you declare
that ABI to extism-pdk; you build/parse the JSON yourself with serde_json.
Edit src/lib.rs:
#![allow(unused)]
fn main() {
use extism_pdk::{host_fn, plugin_fn, FnResult, Json};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
// Host functions Keystone registers for this plugin, gated by its
// `capabilities` config entry (ADR 0025 §6). One JSON string in, one JSON
// string out - this is the actual ABI, not the typed multi-arg signature a
// higher-level SDK might expose in other languages.
#[host_fn]
extern "ExtismHost" {
fn provision_user(request_json: String) -> String;
fn find_user(external_id_json: String) -> String;
}
// The `payload` shape is whatever your plugin's own config block declares
// under `identity.<method_name>` in the client's auth request - you define
// this struct to match what your clients will send.
#[derive(Debug, Deserialize)]
pub struct MyPayload {
pub username: String,
}
#[derive(Debug, Deserialize)]
pub struct AuthPluginRequest {
pub payload: MyPayload,
#[serde(default)]
pub headers: HashMap<String, String>,
#[serde(default)]
pub remote_addr: Option<String>,
}
// Wire-format requirement: internally tagged on "decision", snake_case.
// `{"decision":"allow","resolved_identity":"...","claims":{...}}` /
// `{"decision":"deny","reason":"..."}` - any other shape is rejected as
// malformed before your plugin's logic is even considered to have run.
#[derive(Debug, Serialize)]
#[serde(rename_all = "snake_case", tag = "decision")]
pub enum AuthPluginResponse {
Allow {
resolved_identity: String,
claims: HashMap<String, serde_json::Value>,
},
Deny {
reason: String,
},
}
/// Authenticate a user (full_auth mode).
#[plugin_fn]
pub fn authenticate(req: Json<AuthPluginRequest>) -> FnResult<Json<AuthPluginResponse>> {
let payload = req.0.payload;
// Simple demo: accept any non-empty username. In a real plugin, verify
// against an external service, check a signature, etc. *before* calling
// provision_user - provision_user/find_user only bind an already-verified
// external identity to a Keystone user, they perform no verification of
// their own.
if payload.username.is_empty() {
return Ok(Json(AuthPluginResponse::Deny {
reason: "empty username".to_string(),
}));
}
// provision_user's request body: {"external_id": "...", "user": {...}}.
// `user` accepts only `domain_id`, `name`, `enabled` (optional), `extra`
// (optional map) - an intentionally narrow allowlist (ADR §6.B "Field
// sanitization"); there is no `id`, `password`, or admin-ish option
// field a plugin can set.
let provision_request = serde_json::json!({
"external_id": payload.username,
"user": {
"domain_id": "default",
"name": payload.username,
},
});
// The host function itself takes/returns a single JSON string - encode
// the request, decode the response yourself.
let handle_json = unsafe { provision_user(provision_request.to_string())? };
// provision_user's success response is a bare JSON string (the opaque
// handle) - NOT `{"handle": "..."}`.
let resolved_identity: String = serde_json::from_str(&handle_json)?;
let mut claims = HashMap::new();
claims.insert(
"external_username".to_string(),
serde_json::Value::String(payload.username),
);
Ok(Json(AuthPluginResponse::Allow {
resolved_identity,
claims,
}))
}
}
4. Build
cargo build --release --target wasm32-unknown-unknown
ls -la target/wasm32-unknown-unknown/release/my_plugin.wasm
5. Deploy
Compute the SHA-256 checksum:
sha256sum target/wasm32-unknown-unknown/release/my_plugin.wasm
# Output: 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08 my_plugin.wasm
Add to Keystone config:
[auth_plugins]
plugins = my_plugin
[auth_plugin.my_plugin]
path = /etc/keystone/plugins/my_plugin.wasm
sha256 = 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
mode = full_auth
capabilities = provision_user,find_user
provision_domain_id = default
timeout_ms = 750
fuel_limit = 50000000
memory_limit_mb = 32
[auth]
methods = password,token,my_plugin
Copy the .wasm to every Keystone node and restart:
cp target/wasm32-unknown-unknown/release/my_plugin.wasm /etc/keystone/plugins/
systemctl restart keystone
6. Test
curl -X POST http://keystone:5000/v3/auth/tokens \
-H "Content-Type: application/json" \
-d '{
"auth": {
"identity": {
"methods": ["my_plugin"],
"my_plugin": {
"username": "alice"
}
}
}
}'
# On success: HTTP 201, X-Subject-Token header
Operating Modes Deep Dive
full_auth: Plugin Authenticates Users
Plugin is the terminal authentication authority. It decides who is allowed and optionally provisions users.
Entry point: authenticate(AuthPluginRequest) -> AuthPluginResponse
When to use:
- Custom SSO bridges (proprietary token formats, legacy directory protocols)
- Real-time risk scoring or step-up decisions
- Non-standard credential verification (e.g., certificate chains)
Capabilities available:
http_fetch- call external servicesprovision_user- create new users (first login)find_user- look up provisioned users (idempotent login)assign_role- grant roles to users
Identity binding (security):
- Plugin cannot assert arbitrary
user_idvalues provision_userandfind_useroperate on(plugin_name, external_id)namespace- External ID must come from verified credential (not plugin-chosen)
- Only users the plugin itself provisioned are reachable
- Pre-existing users require admin-authorized linking (see “Admin Identity Linking” below)
Example: OIDC-like SSO
#![allow(unused)]
fn main() {
use extism_pdk::{host_fn, plugin_fn, FnResult, Json};
use std::collections::HashMap;
#[host_fn]
extern "ExtismHost" {
fn http_fetch(request_json: String) -> String;
fn provision_user(request_json: String) -> String;
}
#[plugin_fn]
pub fn authenticate(req: Json<AuthPluginRequest>) -> FnResult<Json<AuthPluginResponse>> {
let token = req.0.payload.get("token").and_then(|v| v.as_str()).ok_or("No token")?;
// http_fetch's request body: {"method": "GET", "url": "...", "headers": {...}, "body": null}.
// Authentication to the upstream service (if any) is injected by the
// host from `http_fetch_auth_header`/`http_fetch_auth_secret_env`
// config - it is never something this plugin supplies itself.
let fetch_request = serde_json::json!({
"method": "GET",
"url": "https://idp.example.com/userinfo",
"headers": {"Authorization": format!("Bearer {token}")},
});
let response_json = unsafe { http_fetch(fetch_request.to_string())? };
// http_fetch's response: {"status": 200, "headers": {...}, "body": "..."}
// - body is the raw response text; parse it yourself.
let response: serde_json::Value = serde_json::from_str(&response_json)?;
let status = response.get("status").and_then(|v| v.as_u64()).unwrap_or(0);
if status != 200 {
return Ok(Json(AuthPluginResponse::Deny {
reason: format!("IDP returned {status}"),
}));
}
let body: serde_json::Value = serde_json::from_str(
response.get("body").and_then(|v| v.as_str()).unwrap_or("{}"),
)?;
let sub = body.get("sub").and_then(|v| v.as_str()).ok_or("No 'sub' claim")?;
let provision_request = serde_json::json!({
"external_id": sub,
"user": {
"domain_id": "default",
"name": body.get("name").and_then(|v| v.as_str()).unwrap_or(sub),
},
});
let handle_json = unsafe { provision_user(provision_request.to_string())? };
let resolved_identity: String = serde_json::from_str(&handle_json)?;
let mut claims = HashMap::new();
if let Some(email) = body.get("email") {
claims.insert("email".to_string(), email.clone());
}
if let Some(groups) = body.get("groups") {
claims.insert("groups".to_string(), groups.clone());
}
Ok(Json(AuthPluginResponse::Allow {
resolved_identity,
claims,
}))
}
}
mapping: Plugin Produces Claims, Mapping Engine Decides
Plugin generates claims; the Mapping Engine (not the plugin) makes the identity decision via configured rules.
Entry point: mapping(AuthPluginRequest) -> MappingResponse
When to use:
- Plugins that transform existing credentials into claims (header/JWT parsing, protocol adapters)
- Authenticating pre-existing SCIM-provisioned users (no namespace scoping needed)
- Plugins that don’t make terminal
Allow/Denydecisions
No identity binding: Plugin cannot provision or name users. Mapping Engine
uses claims to match against MappingRuleSet rules, resolving to real users if
rules fire.
A __keystone_workload_id claim is required. Every mapping-mode
response’s claims map must include a string-valued __keystone_workload_id
key - it is the Mapping Engine’s unique_workload_id (ADR 0020 §3), which has
no dedicated field on MappingResponse::Claims. A response missing it, or where
it isn’t a string, is rejected as malformed (MissingWorkloadId) before the
Mapping Engine ever sees it. Unlike every other __keystone-prefixed key, this
one is left in the claims map so mapping rules can also reference it directly.
Admin setup required:
- Deploy plugin with
mode = mapping - Create
MappingRuleSetrules withprovider_id = "wasm:{plugin_name}" - Rules decide identity resolution (e.g., match claims to existing user by email)
- Plugin claims are namespaced: no risk of injecting privilege-relevant claims
Example: Parse custom header and produce claims
#![allow(unused)]
fn main() {
use extism_pdk::{plugin_fn, FnResult, Json};
use std::collections::HashMap;
// Wire shape mirrors AuthPluginResponse's tagging convention:
// {"decision":"claims","claims":{...}} / {"decision":"deny","reason":"..."}.
// There is no `Allow` variant - a mapping-mode plugin cannot terminate
// authentication, only feed the engine that does.
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "snake_case", tag = "decision")]
pub enum MappingResponse {
Claims { claims: HashMap<String, serde_json::Value> },
Deny { reason: String },
}
#[plugin_fn]
pub fn mapping(req: Json<AuthPluginRequest>) -> FnResult<Json<MappingResponse>> {
// Extract custom header (must be in this plugin's exposed_headers config
// - anything not explicitly allowlisted there is simply absent here,
// never silently forwarded).
let header_value = req
.0
.headers
.get("X-Custom-Auth")
.ok_or("missing X-Custom-Auth header")?;
let parts: Vec<&str> = header_value.split(':').collect();
if parts.len() < 2 {
return Ok(Json(MappingResponse::Deny {
reason: "malformed header".to_string(),
}));
}
let mut claims = HashMap::new();
// Required on every mapping-mode response - see note above.
claims.insert(
"__keystone_workload_id".to_string(),
serde_json::Value::String(parts[1].to_string()),
);
claims.insert("realm".to_string(), serde_json::Value::String(parts[0].to_string()));
claims.insert(
"email".to_string(),
serde_json::Value::String(format!("{}@example.com", parts[1])),
);
Ok(Json(MappingResponse::Claims { claims }))
}
}
Corresponding Mapping Engine rule (POST /v4/mappings, ADR 0020 §9.A):
{
"mapping": {
"domain_id": "default",
"source": { "type": "wasm_plugin", "plugin_name": "my_mapping_plugin" },
"domain_resolution_mode": { "type": "fixed" },
"enabled": true,
"rules": [
{
"name": "any-claim",
"match": {
"all_of": [
{
"type": "condition",
"matches_regex": { "claim": "realm", "regex": ".*" }
}
]
},
"identity": {
"user_name": "{realm}-{email}",
"user_domain_id": "default",
"is_system": false
},
"authorizations": [],
"groups": []
}
]
}
}
route: Plugin Routes Requests Pre-Dispatch
Plugin sees the raw, pre-dispatch request before method dispatch; can redirect to a different method or pass through unchanged. It never authenticates anyone.
Entry point: route(RouteRequest) -> RouteResponse
When to use:
- Clients that always send a fixed method name (can’t be changed)
- Conditional routing based on credential content (e.g.,
application_credentialthat might be different handler-specific formats) - Credential-shape-based dispatching without authentication
Important: Plugin does NOT authenticate. Target method still performs full verification against whatever payload it receives.
Constraints (enforced by host):
- Cannot touch
scope(project/domain/system) -RouteResponsecarries noscopefield at all, only relabels which method handles the request - Can only route to methods in this plugin’s
route_targetsallowlist - a response naming any other method is rejected as malformed, not corrected - Can never target
adminortrustmethods, regardless ofroute_targets - Single-shot - a request already routed once is never re-routed, by the same router or a different one
- Target method receives exactly the payload this plugin specifies - re-verification by the target is what makes that safe, not any assertion this plugin makes
Example: application_credential routing
#![allow(unused)]
fn main() {
use extism_pdk::{plugin_fn, FnResult, Json};
use std::collections::HashMap;
// `methods`, not `requested_methods` - the actual field name.
#[derive(Debug, serde::Deserialize)]
pub struct RouteRequest {
pub methods: Vec<String>,
#[serde(default)]
pub payloads: HashMap<String, serde_json::Value>,
#[serde(default)]
pub headers: HashMap<String, String>,
#[serde(default)]
pub remote_addr: Option<String>,
}
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "snake_case", tag = "decision")]
pub enum RouteResponse {
Passthrough,
Route {
target_method: String,
payload: serde_json::Value,
},
Deny {
reason: String,
},
}
#[plugin_fn]
pub fn route(req: Json<RouteRequest>) -> FnResult<Json<RouteResponse>> {
// `payloads` only ever contains blocks for methods this plugin's
// `inspect_methods` config declared - a router configured to look at
// `application_credential` never sees an unrelated `password` block,
// even on a request carrying both.
let Some(payload) = req.0.payloads.get("application_credential") else {
return Ok(Json(RouteResponse::Passthrough));
};
let Some(cred_id) = payload.get("application_credential_id").and_then(|v| v.as_str()) else {
return Ok(Json(RouteResponse::Passthrough));
};
if let Some(rest) = cred_id.strip_prefix("tf-") {
return Ok(Json(RouteResponse::Route {
target_method: "hacked_appcred_handler".to_string(),
payload: serde_json::json!({ "external_id": rest }),
}));
}
// Every other application_credential request passes through unmodified.
Ok(Json(RouteResponse::Passthrough))
}
}
Config:
[auth_plugin.tf_router]
mode = route
inspect_methods = application_credential
route_targets = hacked_appcred_handler
capabilities = # empty; add http_fetch if the router needs to query an external service
Host Functions API
Request Objects
Every entry point receives a request whose exact shape depends on the mode:
authenticate/mapping:AuthPluginRequest { payload, headers, remote_addr }payload: serde_json::Value- rawidentity.<method>block from the client’s auth request, exactly as received; deserialize it into whatever shape your plugin expectsheaders: HashMap<String, String>- allowlisted subset of inbound HTTP headers (only names in this plugin’sexposed_headersconfig; a fixed denylist -Authorization,Cookie,X-Auth-Token,X-Service-Token,X-Subject-Token,Proxy-Authorization- can never appear here regardless of config)remote_addr: Option<String>- trusted client address (resolved via[auth_plugins].trusted_proxies), never a raw, spoofableX-Forwarded-Forvalue;Noneif no trusted address could be established
route:RouteRequest { methods, payloads, headers, remote_addr }- see theroutemode section above; runs pre-dispatch on the client’s fullidentity.methodslist, not a single method’s isolated payload
HTTP Fetching
Capability: http_fetch.
Request/response shape (single JSON string in, single JSON string out, like every host function):
// Request
{
"method": "GET", // GET, POST, PUT, PATCH, DELETE, HEAD
"url": "https://...", // host must be in this plugin's allowed_hosts
"headers": { "Content-Type": "application/json" },
"body": null, // UTF-8 string, or omit/null for no body
}
// Response
{
"status": 200,
"headers": { "content-type": "application/json" },
"body": "...", // UTF-8 (lossy) response body
}
Constraints:
- Only
allowed_hostsare reachable (config-time allowlist) - All resolved IPs are checked against private/loopback/link-local/multicast/ cloud-metadata ranges (no SSRF), re-resolved at connect time (no DNS rebinding)
- Auth secrets are injected by the host from
http_fetch_auth_header+http_fetch_auth_secret_envconfig, applied after your headers and replacing any header of the same name you supplied - the secret is never visible to your plugin’s code or the distributed.wasmfile - No redirects by default; opt in per-plugin with
http_fetch_follow_redirects = true, and even then the whole redirect chain shares onetimeout_msbudget, not one budget per hop
Example:
#![allow(unused)]
fn main() {
let fetch_request = serde_json::json!({
"method": "POST",
"url": "https://idp.example.com/validate",
"headers": {"Content-Type": "application/json"},
"body": serde_json::json!({"token": token}).to_string(),
});
let response_json = unsafe { http_fetch(fetch_request.to_string())? };
let response: serde_json::Value = serde_json::from_str(&response_json)?;
let status = response.get("status").and_then(|v| v.as_u64()).unwrap_or(0);
if status != 200 {
return Ok(Json(AuthPluginResponse::Deny {
reason: format!("IDP returned {status}"),
}));
}
}
Provisioning Users
full_auth mode only.
Request:
{
"external_id": "...", // plugin-derived identifier, never a Keystone user_id
"user": {
"domain_id": "...", // must be in provision_domain_id / allowed_provision_domains
"name": "...",
"enabled": true, // optional
"extra": {}, // optional
},
}
Response: a bare JSON string - the opaque handle. Not
{"handle": "..."}.
Semantics:
- Creates a new Keystone
User(if not already created) - Records mapping
(plugin_name, external_id) -> user_id - Returns opaque handle (not the real
user_id) - present it back verbatim inAllow.resolved_identity - Idempotent: same
external_idon repeat calls returns a handle to the same user - Atomic: race-safe with concurrent requests
- Domain-scoped:
user.domain_idmust be in this plugin’sprovision_domain_idorallowed_provision_domains; onlydomain_id,name,enabled,extraare accepted - there is noidor password field a plugin can set
Use case: First login for a new external identity
Finding Users
full_auth mode only.
Request: a bare JSON string - the external_id.
Response: the handle as a JSON string, or null if not found.
Semantics:
- Looks up user by
(plugin_name, external_id)only - Not a general username search
- Cannot reach users provisioned by other plugins or via other methods
- For admin-linked identities: domain restriction is re-checked on every call (prevents stale links after a user is moved to a different domain)
Use case: Returning user login (idempotent after first provision)
Assigning Roles
full_auth mode only.
Request:
{
"resolved_identity": "...", // a handle THIS invocation's provision_user/find_user produced
"role": "member",
"target": { "scope": "project", "project_id": "..." },
// or: { "scope": "domain", "domain_id": "..." }
}
Response: an empty JSON value on success (traps/errors on rejection - see below).
Constraints (host-enforced):
rolemust be in this plugin’sassign_role_allowedlist- Target project/domain must be in
provision_domain_id/allowed_provision_domains - System scope is never allowed - there is no
systemvariant oftargetat all, so a plugin has no way to even express that request resolved_identitymust be a handle this exact invocation’s ownprovision_user/find_usercall produced - the same anti-impersonation constraint as identity binding itself
Use case: Grant initial roles on first user provisioning
Error Handling
A host function call returns Err (traps the guest call via ?) on any
violation of the constraints above - a disallowed domain, a role outside
assign_role_allowed, a host outside allowed_hosts, an invalid
resolved_identity, and so on. The failure reason is not returned to your
plugin in a structured, inspectable way - it fails the whole invocation closed
(ADR 0025 §7). Design your plugin to check what it can up front (e.g. only call
assign_role with roles you know are configured) rather than relying on host
errors for control flow.
Admin Identity Linking
full_auth plugins can authenticate pre-existing users (e.g., SCIM-provisioned)
via admin-authorized linking, without the plugin provisioning them.
API
Create link:
curl -X POST http://keystone:5000/v4/auth_plugins/{plugin_name}/identity_links \
-H "X-Auth-Token: $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"identity_link": {
"external_id": "sso_user_123",
"user_id": "existing-keystone-uuid"
}
}'
RBAC-tiered (ADR §4): system-scope admin may link any user; a domain-scoped
admin/manager may link only a non-system user in their own domain.
Re-linking an external_id that already has an entry is rejected
(409 Conflict) - DELETE the existing link first.
Note: SCIM convenience fields (
scim_provider_id,scim_external_id) are documented in ADR 0025 §4 but not yet implemented. Only the direct{external_id, user_id}body is accepted today.
Delete link:
curl -X DELETE http://keystone:5000/v4/auth_plugins/{plugin_name}/identity_links/{external_id} \
-H "X-Auth-Token: $ADMIN_TOKEN"
Also revokes the unlinked user’s live tokens.
Bulk revocation (on plugin compromise):
curl -X POST http://keystone:5000/v4/auth_plugins/{plugin_name}/revoke_all \
-H "X-Auth-Token: $ADMIN_TOKEN"
System-admin only. Disables every user the plugin provisioned or was linked to, deletes those identity links, and revokes their tokens. It does not revoke role assignments the plugin granted - review those separately against the CADF audit trail before re-enabling any disabled account.
Plugin Logic
No change needed in plugin code - find_user(external_id) works the same
whether the entry was created by provision_user or by an admin link.
Response Bounds & Safety
Claims Size
- Max 64 entries in the
claimsmap - Max 256 bytes per key
- Max 4 KiB per value
- Total response JSON capped at 64 KiB (rejected unparsed if exceeded)
Claims Namespacing
All full_auth-mode plugin claims are automatically namespaced under
plugin_claims.<plugin_name>.<key> in the policy input - never merged into the
top level. This prevents injection of privilege-relevant top-level keys.
#![allow(unused)]
fn main() {
// Plugin returns (Allow.claims):
// {"email": "user@example.com", "groups": ["admin"]}
// OPA sees (input.credentials):
// {"plugin_claims": {"my_plugin": {"email": "...", "groups": ["admin"]}}}
// A policy can read: input.credentials.plugin_claims.my_plugin.email
// It cannot inject top-level keys like is_system, effective_roles, etc. -
// those simply aren't where plugin claims live.
}
mapping-mode claims are not namespaced this way - they flow into the Mapping
Engine’s rule matching instead (ADR 0020), which has its own,
separately-reviewed guarantee that claim values only ever drive rule matching,
never become privilege directly.
Reserved Keys
A claim key named exactly plugin_claims, or prefixed with __keystone, is
rejected (the whole response fails closed) - except __keystone_workload_id in
mapping mode, which is required (see the mapping mode section above).
Testing & Debugging
Local Unit Tests
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_authenticate_valid() {
let req = AuthPluginRequest {
payload: MyPayload { username: "alice".to_string() },
headers: Default::default(),
remote_addr: Some("192.0.2.1".to_string()),
};
// Exercise your plugin's logic directly - `authenticate` itself
// requires a live extism host context for its `unsafe { provision_user(..) }`
// call, so unit-test the decision logic your plugin builds around it,
// not the wasm entry point end to end. End-to-end coverage belongs in
// integration tests (below) against a real Keystone instance.
assert!(!req.payload.username.is_empty());
}
}
}
Integration Testing
Test against a real Keystone instance (see Administrator Plugin Operations):
# 1. Build and copy plugin
cargo build --release --target wasm32-unknown-unknown
SHA=$(sha256sum target/wasm32-unknown-unknown/release/my_plugin.wasm | cut -d' ' -f1)
cp target/wasm32-unknown-unknown/release/my_plugin.wasm /etc/keystone/plugins/
# 2. Update keystone.conf with plugin config and SHA
# 3. Restart Keystone
systemctl restart keystone
# 4. Test authentication
curl -X POST http://keystone:5000/v3/auth/tokens \
-H "Content-Type: application/json" \
-d '{
"auth": {
"identity": {
"methods": ["my_plugin"],
"my_plugin": {"username": "test"}
}
}
}'
Debugging
Plugin load failures - keystone_auth_plugin_load_failure{plugin_name} on
/metrics, plus a CRITICAL log line naming the plugin and the mismatch:
grep "keystone_auth_plugin_load_failure" /var/log/keystone/keystone.log
Rate limit hits - check if the plugin is being rate-limited:
grep "rate_limited" /var/log/keystone/keystone.log
Timeouts/fuel/memory - a resource-bound violation fails the specific
invocation closed, audited via the plugin’s CADF trail (wasm_plugin.* events,
ADR §6.E) rather than a distinct log grep target - check the audit event
outcome/reason for the plugin’s authenticate/mapping/route calls.
Real-World Example: OAuth2 Provider Validation
Complete plugin that validates tokens from an external OAuth2 provider, provisioning a local user on first login and reusing it on subsequent ones.
#![allow(unused)]
fn main() {
use extism_pdk::{host_fn, plugin_fn, FnResult, Json};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[host_fn]
extern "ExtismHost" {
fn http_fetch(request_json: String) -> String;
fn provision_user(request_json: String) -> String;
fn find_user(external_id_json: String) -> String;
}
#[derive(Debug, Deserialize)]
pub struct OAuthPayload {
pub access_token: String,
}
#[derive(Debug, Deserialize)]
pub struct AuthPluginRequest {
pub payload: OAuthPayload,
#[serde(default)]
pub headers: HashMap<String, String>,
#[serde(default)]
pub remote_addr: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "snake_case", tag = "decision")]
pub enum AuthPluginResponse {
Allow {
resolved_identity: String,
claims: HashMap<String, serde_json::Value>,
},
Deny { reason: String },
}
#[plugin_fn]
pub fn authenticate(req: Json<AuthPluginRequest>) -> FnResult<Json<AuthPluginResponse>> {
let token = req.0.payload.access_token;
let introspect_request = serde_json::json!({
"method": "POST",
"url": "https://oauth.example.com/introspect",
"headers": {"Accept": "application/json"},
"body": serde_json::json!({"token": token}).to_string(),
});
let fetch_response_json = unsafe { http_fetch(introspect_request.to_string())? };
let fetch_response: serde_json::Value = serde_json::from_str(&fetch_response_json)?;
let status = fetch_response.get("status").and_then(|v| v.as_u64()).unwrap_or(0);
if status != 200 {
return Ok(Json(AuthPluginResponse::Deny {
reason: format!("introspect endpoint returned {status}"),
}));
}
let body: serde_json::Value = serde_json::from_str(
fetch_response.get("body").and_then(|v| v.as_str()).unwrap_or("{}"),
)
.map_err(|e| format!("failed to parse introspect response body: {e}"))?;
let is_active = body.get("active").and_then(|v| v.as_bool()).unwrap_or(false);
if !is_active {
return Ok(Json(AuthPluginResponse::Deny {
reason: "token is not active".to_string(),
}));
}
let sub = body
.get("sub")
.and_then(|v| v.as_str())
.ok_or("no 'sub' claim in introspect response")?;
// Idempotent lookup first - avoids re-provisioning on every login.
let find_result_json = unsafe { find_user(serde_json::to_string(sub)?)? };
let existing: Option<String> = serde_json::from_str(&find_result_json)?;
let resolved_identity = if let Some(handle) = existing {
handle
} else {
let username = body
.get("preferred_username")
.and_then(|v| v.as_str())
.unwrap_or(sub);
let provision_request = serde_json::json!({
"external_id": sub,
"user": { "domain_id": "default", "name": username },
});
let handle_json = unsafe { provision_user(provision_request.to_string())? };
serde_json::from_str(&handle_json)?
};
Ok(Json(AuthPluginResponse::Allow {
resolved_identity,
claims: extract_claims(&body),
}))
}
fn extract_claims(token_claims: &serde_json::Value) -> HashMap<String, serde_json::Value> {
let mut claims = HashMap::new();
for key in ["email", "groups", "realm_access"] {
if let Some(value) = token_claims.get(key) {
claims.insert(key.to_string(), value.clone());
}
}
claims
}
}
Best Practices
- Keep plugins single-purpose - one auth logic, not multiple concerns
- Fail closed - when in doubt, deny the login, log the reason
- Validate external data - never trust HTTP responses or plugin input
- Use http_fetch carefully - SSRF protection is automatic, but credential handling must be reviewed
- Set reasonable timeouts -
timeout_msshould be shorter than your external service’s SLA + overhead, and covers the whole invocation including any redirect chain - Test failure paths - network timeouts, malformed responses, slow services
- Document claims - which external attributes map to which plugin claims
- Version plugins - git tag releases, pin SHA-256 checksums in deployments,
and bump
valid_sincealongsidesha256when a change should invalidate outstanding tokens (full_authmode only - see ADR §4 “Plugin Version Binding”) - Audit identity changes - every host-function call and
authenticate/mapping/routeoutcome is CADF-audited (wasm_plugin.*events, ADR §6.E) - Monitor rate limits - tune
invocation_rate_limit_per_minuteandmax_concurrent_invocationsbased on load
References
- ADR 0025 - Dynamic Auth Plugins - Threat model, design rationale, all constraints
- Administrator Plugin Operations - Deployment, configuration, operations
crates/auth-plugin-runtime/tests/fixtures/reference-plugin/src/lib.rs- the real, compiled reference plugin this guide’s examples are derived from- Extism PDK - Language SDKs (Rust, Go, Python, JS, C, Zig, …)
- Security Model - Authentication and authorization invariants