Cookbook / Public APIs
API keys with scopes
Issue keys that are stored only as hashes, authenticate them on every request, and limit what each key may do.
For a public API, customers want long-lived keys they can create, scope and revoke. generate_key makes a key with a 256-bit secret. You show it to its owner once and store only a hash. ApiKeyAuth authenticates each request in constant time and attaches the key's scopes, and require_scope guards routes.
use axeon::auth::api_key::{generate_key, ApiKey, ApiKeyAuth, ApiKeyRecord, KeyHash};
use axeon::auth::require_scope;
use axeon::{json, Json, Server, ServerError};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
/// Whoever owns a key, attached to authenticated requests.
#[derive(Clone)]
struct Org {
name: String,
}
/// What you would keep in a database: the key's ID, its hash and its scopes.
#[derive(Clone)]
struct Stored {
hash: KeyHash,
scopes: Vec<String>,
org: Org,
}
type Keys = Arc<Mutex<HashMap<String, Stored>>>;
fn main() {
let keys: Keys = Arc::default();
// Create a demo key for an organisation. In a real app this is an admin action.
let demo = generate_key("acme_live");
keys.lock().unwrap().insert(
demo.id.clone(),
Stored {
hash: demo.hash,
scopes: vec!["catalog:read".into()],
org: Org { name: "Acme".into() },
},
);
println!("demo key (shown once): {}", demo.key);
let lookup_keys = keys.clone();
let mut app = Server::new();
app.middleware(ApiKeyAuth::new("acme_live", move |id: String| {
let keys = lookup_keys.clone();
async move {
// Replace with: SELECT hash, scopes, org FROM api_keys WHERE id = $1 AND revoked_at IS NULL
Ok::<_, ServerError>(keys.lock().unwrap().get(&id).map(|stored| ApiKeyRecord {
hash: stored.hash,
scopes: stored.scopes.clone(),
identity: stored.org.clone(),
}))
}
}));
// Needs the catalog:read scope; a key without it gets 403.
app.route("/v1/products")
.middleware(require_scope("catalog:read"))
.get(|req| async move {
let org = req.extension::<Org>().unwrap();
Json(json!({ "org": org.name, "products": ["lamp", "desk"] }))
});
// Needs a scope the demo key does not have.
app.route("/v1/products")
.middleware(require_scope("catalog:write"))
.post(|_req| async { (201, "created") });
app.get("/v1/whoami", |req| async move {
req.extension::<ApiKey>().map(|key| Json(json!({ "key_id": key.id, "scopes": key.scopes })))
});
app.listen("127.0.0.1:3000").expect("server failed to start");
}
# Use the key printed at startup.
curl -i http://127.0.0.1:3000/v1/products # 401
curl http://127.0.0.1:3000/v1/products -H "authorization: Bearer $KEY" # 200
curl http://127.0.0.1:3000/v1/products -H "x-api-key: $KEY" # same, other header
curl -i -X POST http://127.0.0.1:3000/v1/products -H "x-api-key: $KEY" # 403, insufficient_scope
Notes#
- A key looks like
acme_live_<id>_<secret>. The prefix lets secret scanners recognise a leaked key, and the ID is the public part you look the key up by. Show the full key to its owner exactly once. - Store
KeyHash::to_hex()in aTEXTcolumn (oras_bytes()inBYTEA). Keys carry 256 random bits, so a fast hash is enough; slow password hashes matter only for secrets people choose. - Lookups are cached for 60 seconds (
cache_ttl), so a revoked key keeps working for up to that long. UseDuration::ZEROif revocation must be immediate. - Unknown key IDs are never cached and each one reaches your lookup function. Put a rate limit by IP in front of this middleware.
on_used(interval, flush)reports the IDs that were used, in batches, for alast_used_atcolumn, instead of one write per request.- Scopes support wildcards: a granted
catalog:*coverscatalog:read, and*covers everything. Roles work the same way withrequire_role. For checks that depend on the request, callreq.require_scope("..")inside the handler.