BoxLang 🚀 A New JVM Dynamic Language Learn More...
WebAuthn passkeys for BoxLang: let people register a passkey and sign
in with it, no password. It uses only JDK crypto
(java.security), with no third-party jars.
"none": it trusts the key the
authenticator hands over and doesn't check where the authenticator
came from. That's what nearly every site wants.getPublicKey() (SPKI) or, when that's missing, from the
COSE key inside attestationObject. A small CBOR decoder
is built in.challengeSeconds.Requires BoxLang 1.19+.
install-bx-module bx-passkeys # into BoxLang HOME
install-bx-module bx-passkeys --local # into ./boxlang_modules
box install bx-passkeys # with CommandBox
To try a local checkout before it's on ForgeBox
(install-bx-module only installs from ForgeBox), copy the
module's files into your project's boxlang_modules:
mkdir -p boxlang_modules/bx-passkeys && cp -R /path/to/bx-passkeys/{ModuleConfig.bx,box.json,bifs,models,client,sql} boxlang_modules/bx-passkeys/
box install /path/to/bx-passkeys also works, but it
installs into BoxLang HOME. Run it on a checkout without the
boxlang_modules/ test link (rm -rf
boxlang_modules), or the copy follows the link back into itself.
Set these in boxlang.json:
"modules": {
"bxPasskeys": {
"settings": {
"rpId": "example.com",
"rpName": "Example",
"store": { "datasource": "app", "table": "passkey_credentials" }
}
}
}
Or pass them straight in: passkeyRelyingParty( { rpId:
"example.com" } ).
| Setting | Default | Meaning |
|---|---|---|
rpId
| "localhost"
| Your site's domain. Passkeys are bound to it, so they also work on its subdomains. |
rpName
| the rpId | The name shown in the browser's passkey sheet. |
origins
| []
| The exact origins sign-ins may come from, e.g. [
"https://example.com" ]. When it's empty, the
module allows https on the rpId itself (any port),
and plain http only on localhost. |
allowSubdomains
| false
| With origins empty, also allow
https on any subdomain of the rpId. Leave it off if
anyone else can serve pages on a subdomain (user content,
previews): such a page could relay sign-ins. |
challengeSeconds
| 300
| How long a challenge stays good. This is also the browser's timeout. |
algorithms
| [ -7, -8, -257 ]
| COSE algorithm ids to accept, in order of preference. |
userVerification
| "preferred"
| Set it to "required" to refuse an
authenticator that didn't check a PIN or biometric. |
residentKey
| "required"
| Discoverable credentials, so people can sign in without typing a username. |
maxPerUser
| 0
| The most passkeys one user may have. 0 means
no limit. |
sessionKey
| "passkeyChallenge"
| The key in your session struct where pending challenges are kept, one for registration and one for sign-in. |
store
| { table, datasource }
| Settings for the default TableCredentialStore. |
passkeyRelyingParty( overrides = {}, store ) returns a
RelyingParty built from the module settings. Without a
store, it uses a TableCredentialStore. You
can also build one yourself with new
bxModules.bxPasskeys.models.RelyingParty( settings, store ).
Every method that takes sessionData reads or writes your
session struct. In a web app that's session. With
BoxExpress it's req.session.
// POST /passkeys/options
rp = passkeyRelyingParty()
return rp.registrationOptions( { id: user.id, name: user.email, displayName: user.name }, session )
// POST /passkeys body = what createPasskey() returned
try {
credential = rp.register( { id: user.id }, session, requestBody )
return { id: credential.credentialId, name: credential.name }
} catch ( "Passkeys.Invalid" e ) {
// 400, e.message is safe to show
}
user.id is your app's id for the user, sent to the
authenticator as UTF-8 bytes (at most 64). Don't put names or emails
in it.
// POST /sign-in/passkey/options
return passkeyRelyingParty().signInOptions( session )
// POST /sign-in/passkey body = what signWithPasskey() returned
try {
result = passkeyRelyingParty().verify( session, requestBody )
// result = { userId, credentialId, userVerified, signCount, credential }
// sign result.userId in; rotate the session id
} catch ( "Passkeys.Invalid" e ) {
// 401
}
To re-confirm a user who is already signed in (before a sensitive
action, say), pass their id: signInOptions( session, user.id
). Only that user's passkeys are offered, and
verify() refuses anyone else's.
client/passkeys.js is a small ES module, with types in
client/passkeys.d.ts. Copy it into your front end:
import { createPasskey, signWithPasskey, passkeysSupported } from './passkeys.js'
const post = (path, body) => fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body ?? {}) }).then((r) => r.json())
// add a passkey
const registration = await createPasskey(await post('/passkeys/options'))
if (registration) await post('/passkeys', registration)
// sign in
const assertion = await signWithPasskey(await post('/sign-in/passkey/options'))
if (assertion) await post('/sign-in/passkey', assertion)
Both functions return null when the person closes the
browser's sheet.
examples/flow.bxs registers a passkey, signs in with it
and shows a replay being refused, with the test authenticator standing
in for the browser:
box install && boxlang setup-tests.bxs && boxlang examples/flow.bxs
A store implements bxModules.bxPasskeys.models.CredentialStore:
function findByCredentialId( required string credentialId ) // struct or null
array function listForUser( required string userId ) // newest first
void function add( required struct credential )
boolean function updateUsage( required string credentialId, required numeric signCount, required date lastUsedAt )
boolean function remove( required string credentialId )
A credential is { credentialId, userId, publicKey, algorithm,
signCount, transports, name, createdAt, lastUsedAt }.
credentialId and publicKey (SPKI) are
base64url strings. Compare credentialId and
userId case-sensitively.
updateUsage must check and write in one step: save only
while the new signCount is above the stored one (or both
are 0), and return whether it saved. That's what keeps two sign-ins
racing with the same counter from both getting through.
TableCredentialStore( { datasource, table } )
uses one table with plain parameterized SQL. Create the
table from sql/mssql.sql, sql/mysql.sql or
sql/postgres.sql. The scripts give
credential_id and user_id binary
collations, so they compare case-sensitively; keep that if you write
your own.MemoryCredentialStore()
holds passkeys in memory, for tests and demos.To list or remove a user's passkeys in your account settings, call
the store directly: rp.getStore().listForUser( user.id ).
On registration:
clientDataJSON has type webauthn.create,
an allowed origin, and isn't from a cross-origin frame.authenticatorData carries the SHA-256 of the rpId, the
user-present flag (and the user-verified flag when that's required),
and attested credential data.authenticatorData and is at most 1023 bytes. It's
stored in canonical base64url, whatever padding the browser sent.authenticatorData carries the COSE key, which is one of
the accepted algorithms and can be read. When the browser also sends
an SPKI key, it must be the same key.maxPerUser.On sign-in, the same challenge, origin, rpId and flag checks apply, plus:
userHandle, if the browser sent one.algorithms.authenticatorData ‖
SHA-256(clientDataJSON) verifies.Every refusal throws Passkeys.Invalid with a message you
can show the user. A bad setting throws Passkeys.Configuration.
The module doesn't do attestation verification, authenticator metadata (MDS) or any extensions.
box install # TestBox
boxlang setup-tests.bxs # once: links boxlang_modules/bxPasskeys to this folder
boxlang run-tests.bxs
The table-store spec uses an in-memory Derby database, so it needs
the bx-derby module (install-bx-module bx-derby).
MIT
0.2.0
origins empty, only https://<rpId> is allowed now, not
its subdomains. Set the new allowSubdomains: true (or list origins) to
allow them.CredentialStore.updateUsage() returns a boolean and must save
only while the counter moves forward (or stays 0), in one step. Custom stores
need updating.TableCredentialStore updates with sign_count < ?, and the memory store
checks under its lock.authenticatorData; a browser SPKI key
is checked against it rather than trusted on its own.maxPerUser is checked at registration too.algorithms.credential_id and user_id (MSSQL,
MySQL); name is bound as nvarchar so non-ASCII names survive on MSSQL.MemoryCredentialStore matches user ids exactly and reads under its lock.RelyingParty; one SecureRandom and Base64
coder are shared.0.1.0
"none", using only
JDK crypto (java.security).getPublicKey() or the COSE key in attestationObject (built-in CBOR
decoder).TableCredentialStore and
MemoryCredentialStore ship with the module.passkeyRelyingParty() BIF and a browser client helper.
$
box install bx-passkeys