← Auth & IdP overview

Passkey login flow

assay-engine v0.2.0's auth-passkey module wraps webauthn-rs and exposes WebAuthn register + authenticate ceremonies behind four HTTP endpoints (and four Lua wrappers).

Configure the relying party

Passkeys need a stable origin. The engine derives it from server.public_url; override the relying-party id and label in engine.toml if you want them to differ from the default (the host of the public URL):

# engine.toml
[server]
public_url = "https://auth.example.com"

[auth.passkey]
rp_id = "auth.example.com"     # defaults to host of public_url
rp_name = "Acme Identity"      # defaults to "Assay"

HTTP surface

MethodPathPurpose
POST/auth/passkey/register/startBegin registration ceremony — returns publicKey options + opaque state
POST/auth/passkey/register/finishVerify the browser's navigator.credentials.create() response, persist the credential
POST/auth/passkey/auth/startBegin authentication ceremony — returns challenge + allowed credentials
POST/auth/passkey/auth/finishVerify the browser's navigator.credentials.get() response, mint a session

Lua API — assay.auth.passkey

The assay.auth stdlib module exposes the same ceremonies. The state blob round-trips opaquely between start and finish — keep it in the session/local storage on the browser side and pass it back unchanged.

local auth = require("assay.auth")
local c    = auth.client({ engine_url = "http://localhost:3000" })

-- 1. Registration
local opts, state = c.passkey:start_register(user_id, user_name, display_name)
-- ship `opts` to the browser → navigator.credentials.create(opts)
-- collect `reg_response` from the browser
local cred = c.passkey:finish_register(reg_response, state)
-- credential is now stored in auth.passkeys for `user_id`

-- 2. Authentication
local challenge, state2 = c.passkey:start_auth(user_id)
-- ship `challenge` to the browser → navigator.credentials.get(challenge)
-- collect `auth_response` from the browser
local sess = c.passkey:finish_auth(auth_response, state2)
-- sess = { session_id, csrf_token, user }

Browser side (JS sketch)

// Registration
const start = await fetch("/auth/passkey/register/start", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ user_id, user_name, display_name }),
}).then(r => r.json());

const cred = await navigator.credentials.create({ publicKey: start.options });

await fetch("/auth/passkey/register/finish", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ reg_response: cred, state: start.state }),
});

// Authentication mirrors the same shape with auth/start + auth/finish,
// using navigator.credentials.get(...).

Storage

Each credential lands in auth.passkeys:

credential_id   BLOB / BYTEA  PRIMARY KEY
user_id         TEXT          REFERENCES auth.users(id) ON DELETE CASCADE
public_key      BLOB / BYTEA
sign_count      INTEGER       -- monotonic, replay-protection
transports      TEXT          -- JSON array (usb, nfc, ble, internal, hybrid)
created_at      REAL / DOUBLE PRECISION

The dashboard's Users pane shows every credential per user, with the ability to remove a single key (e.g. when a device is lost). Removing a user cascades to their passkeys via the foreign-key.

See Auth & IdP overview for the rest of the surface, and Compare vs Ory for how this stacks up against Kratos's WebAuthn flow.