diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 19be20469..3b6e11d23 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -37,9 +37,10 @@ jobs: export DEBIAN_FRONTEND=noninteractive echo "The build of boringssl requires golang." echo "The github runner seems to have some version of golang already; installing golang-go breaks." - which go || sudo apt-get install golang-go + which go || (sudo apt-get update && sudo apt-get install golang-go) - sudo apt-get install -y build-essential libcap-dev xz-utils zip unzip strace curl discount git python3 zlib1g-dev cmake ccache + sudo apt-get update && sudo apt-get install -y --no-install-recommends \ + build-essential libcap-dev xz-utils zip unzip strace curl discount git python3 zlib1g-dev cmake ccache # Install OpenSSL 1.1 (required for Meteor/Node compatibility) # Ubuntu 24.04 only ships OpenSSL 3.x, so we need to install 1.1 from older release @@ -107,7 +108,7 @@ jobs: # the constant below. make lint | tee lint.log num_problems=$(cat lint.log | grep ' problems (0 errors, ' | awk '{print $2}') - [ "$num_problems" = 774 ] + [ "$num_problems" = 704 ] - name: setup java 11 uses: actions/setup-java@v4 with: diff --git a/docs/superpowers/plans/2026-05-06-passkey-auth.md b/docs/superpowers/plans/2026-05-06-passkey-auth.md new file mode 100644 index 000000000..d25e5bbac --- /dev/null +++ b/docs/superpowers/plans/2026-05-06-passkey-auth.md @@ -0,0 +1,1331 @@ +# Passkey Authentication Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add WebAuthn/passkey support as a standalone login method to Sandstorm, matching existing auth patterns. + +**Architecture:** New passkey auth provider follows the email-token/LDAP pattern: server-side `Accounts.registerLoginHandler`, client-side Blaze templates, admin enable/disable toggle. Uses `@simplewebauthn/server` and `@simplewebauthn/browser` for WebAuthn ceremony handling. Credential/account separation model: one credential user per person with an array of registered passkeys. + +**Tech Stack:** Meteor 2.3.5, Blaze templates, MongoDB, `@simplewebauthn/server` v11+, `@simplewebauthn/browser` v11+ + +**Spec:** `docs/superpowers/specs/2026-05-06-passkey-auth-design.md` + +--- + +## File Structure + +**New files:** +- `shell/imports/server/accounts/passkey/passkey-server.js`: Server-side login handler, Meteor methods for registration/authentication, in-memory challenge storage +- `shell/imports/client/accounts/passkey/passkey-client.js`: `loginWithPasskey()`, `registerPasskey()`, feature detection, error handling +- `shell/imports/client/accounts/passkey/passkey-templates.html`: Blaze templates for login button and account settings passkey management +- `shell/public/passkey.svg`: FIDO Alliance passkey icon + +**Modified files:** +- `shell/package.json`: Add npm dependencies +- `shell/imports/sandstorm-db/db.js:162`: Add sparse index +- `shell/imports/sandstorm-db/profile.js:65-107,199-257,259-289,283-289`: Add passkey branches to publications, `fillInProfileDefaults`, `getIntrinsicName`, `getServiceName` +- `shell/imports/sandstorm-accounts-packages/accounts.js`: Register `Accounts.loginServices.passkey` +- `shell/imports/client/admin/login-providers.js:11-85`: Add passkey to `idpData` array +- `shell/imports/client/admin/login-providers.html:468`: Add `adminLoginProviderConfigurePasskey` template +- `shell/imports/client/accounts/account-settings.html:83-84`: Add passkey management section +- `shell/imports/client/accounts/account-settings.js`: Add passkey management logic +- `shell/server/main.ts:69-70`: Add server import +- `shell/client/main.ts:67-70,110-116`: Add client imports +- `shell/i18n/en.i18n.json`: Add translation keys + +--- + +### Task 1: Install npm Dependencies + +**Files:** +- Modify: `shell/package.json` + +- [ ] **Step 1: Add @simplewebauthn/server and @simplewebauthn/browser to package.json** + +In `shell/package.json`, add two entries to the `"dependencies"` object: + +```json +"@simplewebauthn/browser": "^11.0.0", +"@simplewebauthn/server": "^11.0.0", +``` + +Add them alphabetically. They go right after the `"@root/pem"` entry: + +```json +"@root/pem": "^1.0.4", +"@simplewebauthn/browser": "^11.0.0", +"@simplewebauthn/server": "^11.0.0", +"@types/fibers": "^3.1.1", +``` + +- [ ] **Step 2: Install dependencies** + +Run: `cd shell && meteor npm install` +Expected: Dependencies install successfully, `package-lock.json` updated. + +- [ ] **Step 3: Commit** + +```bash +git add shell/package.json shell/package-lock.json +git commit -m "feat(passkey): add @simplewebauthn/server and @simplewebauthn/browser dependencies" +``` + +--- + +### Task 2: Database Index and Profile Integration + +**Files:** +- Modify: `shell/imports/sandstorm-db/db.js` +- Modify: `shell/imports/sandstorm-db/profile.js` + +- [ ] **Step 1: Add sparse index for passkey credentialId lookup** + +In `shell/imports/sandstorm-db/db.js`, after line 162 (`suspended.willDelete` index), add: + +```javascript +Meteor.users.ensureIndexOnServer("services.passkey.keys.credentialId", { sparse: 1 }); +``` + +- [ ] **Step 2: Add passkey fields to credentialDetails publication** + +In `shell/imports/sandstorm-db/profile.js`, inside the `credentialDetails` publication's `fields` object (after the `services.saml.displayName` entry at line 104), add: + +```javascript + "services.passkey.userHandle": 1, + "services.passkey.keys.credentialId": 1, + "services.passkey.keys.friendlyName": 1, + "services.passkey.keys.createdAt": 1, + "services.passkey.keys.lastUsedAt": 1, + "services.passkey.keys.transports": 1, + "services.passkey.keys.deviceType": 1, + "services.passkey.keys.backedUp": 1, +``` + +Note: `publicKey` and `counter` are intentionally excluded from the client publication. + +- [ ] **Step 3: Add passkey branch to fillInProfileDefaults** + +In `shell/imports/sandstorm-db/profile.js`, in the `fillInProfileDefaults` function, after the `} else if (services.saml) {` block (line 245-247) and before the `} else {` block (line 248-250), add: + +```javascript + } else if (services.passkey) { + profile.name = profile.name || "Passkey User"; + profile.handle = profile.handle || filterHandle("passkey_" + services.passkey.userHandle.slice(0, 8)); +``` + +- [ ] **Step 4: Add passkey branch to getIntrinsicName** + +In `shell/imports/sandstorm-db/profile.js`, in the `getIntrinsicName` function, after the `} else if (services.saml) {` block (line 277-278) and before the `} else {` block (line 279-281), add: + +```javascript + } else if (services.passkey) { + return services.passkey.userHandle; +``` + +- [ ] **Step 5: Commit** + +```bash +git add shell/imports/sandstorm-db/db.js shell/imports/sandstorm-db/profile.js +git commit -m "feat(passkey): add database index and profile integration for passkey credentials" +``` + +--- + +### Task 3: Server-Side Passkey Handler + +**Files:** +- Create: `shell/imports/server/accounts/passkey/passkey-server.js` +- Modify: `shell/server/main.ts` + +- [ ] **Step 1: Create the passkey server module** + +Create `shell/imports/server/accounts/passkey/passkey-server.js`: + +```javascript +import { Meteor } from "meteor/meteor"; +import { Match, check } from "meteor/check"; +import { Random } from "meteor/random"; +import { Accounts } from "meteor/accounts-base"; +import { SHA256 } from "meteor/sha"; + +import { + generateRegistrationOptions, + verifyRegistrationResponse, + generateAuthenticationOptions, + verifyAuthenticationResponse, +} from "@simplewebauthn/server"; + +import { SandstormDb } from "/imports/sandstorm-db/db"; +import { globalDb } from "/imports/db-deprecated"; + +// Challenge storage: in-memory Map keyed by connection.id +// Challenges expire after 5 minutes and are single-use. +const CHALLENGE_EXPIRY_MS = 5 * 60 * 1000; +const pendingChallenges = new Map(); + +function storeChallenge(connectionId, challenge) { + pendingChallenges.set(connectionId, { + challenge, + createdAt: Date.now(), + }); +} + +function consumeChallenge(connectionId) { + const entry = pendingChallenges.get(connectionId); + pendingChallenges.delete(connectionId); + if (!entry) return null; + if (Date.now() - entry.createdAt > CHALLENGE_EXPIRY_MS) return null; + return entry.challenge; +} + +// Periodic cleanup of expired challenges +SandstormDb.periodicCleanup(CHALLENGE_EXPIRY_MS, function () { + const now = Date.now(); + for (const [key, entry] of pendingChallenges) { + if (now - entry.createdAt > CHALLENGE_EXPIRY_MS) { + pendingChallenges.delete(key); + } + } +}); + +function getRpId() { + return new URL(Meteor.absoluteUrl()).hostname; +} + +function getExpectedOrigin() { + const url = Meteor.absoluteUrl(); + // Strip trailing slash + return url.endsWith("/") ? url.slice(0, -1) : url; +} + +// Base64url encode/decode helpers for public key storage +function uint8ArrayToBase64url(uint8Array) { + return Buffer.from(uint8Array).toString("base64url"); +} + +function base64urlToUint8Array(base64url) { + return new Uint8Array(Buffer.from(base64url, "base64url")); +} + +Meteor.methods({ + "passkey.generateRegistrationOptions": async function () { + if (!this.userId) { + throw new Meteor.Error(403, "Must be logged in to register a passkey."); + } + + if (!Accounts.loginServices.passkey.isEnabled()) { + throw new Meteor.Error(403, "Passkey login service is disabled."); + } + + // Find existing passkey credential for this account, if any + const account = Meteor.users.findOne({ _id: this.userId }); + if (!account || !account.loginCredentials) { + throw new Meteor.Error(403, "Must be logged in to an account."); + } + + let userHandle; + let existingKeys = []; + // Look through linked credentials for an existing passkey credential + for (const cred of account.loginCredentials) { + const credUser = Meteor.users.findOne({ _id: cred.id }); + if (credUser && credUser.services && credUser.services.passkey) { + userHandle = credUser.services.passkey.userHandle; + existingKeys = credUser.services.passkey.keys || []; + break; + } + } + + // Generate a new userHandle if no existing passkey credential + if (!userHandle) { + userHandle = uint8ArrayToBase64url(Random.secret(32)); + } + + const rpID = getRpId(); + const rpName = globalDb.getServerTitle() || "Sandstorm"; + + const options = await generateRegistrationOptions({ + rpName, + rpID, + userName: rpID, + userDisplayName: "Sandstorm User", + userID: base64urlToUint8Array(userHandle), + excludeCredentials: existingKeys.map(function (key) { + return { + id: key.credentialId, + transports: key.transports, + }; + }), + authenticatorSelection: { + residentKey: "required", + userVerification: "preferred", + }, + attestation: "none", + timeout: 300000, + }); + + storeChallenge(this.connection.id, options.challenge); + + return { options, userHandle }; + }, + + "passkey.verifyRegistration": async function (attestationResponse, friendlyName) { + check(attestationResponse, Object); + check(friendlyName, Match.Optional(String)); + + if (!this.userId) { + throw new Meteor.Error(403, "Must be logged in to register a passkey."); + } + + if (!Accounts.loginServices.passkey.isEnabled()) { + throw new Meteor.Error(403, "Passkey login service is disabled."); + } + + const expectedChallenge = consumeChallenge(this.connection.id); + if (!expectedChallenge) { + throw new Meteor.Error(403, "Challenge expired or not found. Please try again."); + } + + const rpID = getRpId(); + const expectedOrigin = getExpectedOrigin(); + + let verification; + try { + verification = await verifyRegistrationResponse({ + response: attestationResponse, + expectedChallenge, + expectedOrigin, + expectedRPID: rpID, + }); + } catch (err) { + throw new Meteor.Error(403, "Passkey registration failed: " + err.message); + } + + if (!verification.verified || !verification.registrationInfo) { + throw new Meteor.Error(403, "Passkey registration failed."); + } + + const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo; + + const now = new Date(); + const name = friendlyName || + ("Passkey (" + now.toISOString().slice(0, 10) + ")"); + + const keyEntry = { + credentialId: credential.id, + publicKey: uint8ArrayToBase64url(credential.publicKey), + counter: credential.counter, + transports: credential.transports || [], + deviceType: credentialDeviceType, + backedUp: credentialBackedUp, + friendlyName: name, + createdAt: now, + lastUsedAt: now, + }; + + // Find or retrieve the userHandle from the registration options we stored + const account = Meteor.users.findOne({ _id: this.userId }); + let passkeyCredentialId = null; + + for (const cred of (account.loginCredentials || [])) { + const credUser = Meteor.users.findOne({ _id: cred.id }); + if (credUser && credUser.services && credUser.services.passkey) { + passkeyCredentialId = credUser._id; + break; + } + } + + if (passkeyCredentialId) { + // Existing passkey credential: add new key + Meteor.users.update( + { _id: passkeyCredentialId }, + { $push: { "services.passkey.keys": keyEntry } } + ); + } else { + // New passkey credential: need to get the userHandle + // We pass it back from generateRegistrationOptions, but the client needs to send it + // Actually, we need to look it up. The client received it from generateRegistrationOptions. + // For now, derive it from the attestation response's userHandle field. + // The userHandle was passed to the client in generateRegistrationOptions. + // We need the client to send it back. Let's accept it as a parameter. + throw new Meteor.Error(500, + "Internal error: no existing passkey credential found. " + + "Use passkey.verifyRegistrationNewUser instead."); + } + + return { credentialId: credential.id, friendlyName: name }; + }, + + "passkey.verifyRegistrationNewUser": async function (attestationResponse, userHandle, friendlyName) { + check(attestationResponse, Object); + check(userHandle, String); + check(friendlyName, Match.Optional(String)); + + if (!this.userId) { + throw new Meteor.Error(403, "Must be logged in to register a passkey."); + } + + if (!Accounts.loginServices.passkey.isEnabled()) { + throw new Meteor.Error(403, "Passkey login service is disabled."); + } + + const expectedChallenge = consumeChallenge(this.connection.id); + if (!expectedChallenge) { + throw new Meteor.Error(403, "Challenge expired or not found. Please try again."); + } + + const rpID = getRpId(); + const expectedOrigin = getExpectedOrigin(); + + let verification; + try { + verification = await verifyRegistrationResponse({ + response: attestationResponse, + expectedChallenge, + expectedOrigin, + expectedRPID: rpID, + }); + } catch (err) { + throw new Meteor.Error(403, "Passkey registration failed: " + err.message); + } + + if (!verification.verified || !verification.registrationInfo) { + throw new Meteor.Error(403, "Passkey registration failed."); + } + + const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo; + + const now = new Date(); + const name = friendlyName || + ("Passkey (" + now.toISOString().slice(0, 10) + ")"); + + const keyEntry = { + credentialId: credential.id, + publicKey: uint8ArrayToBase64url(credential.publicKey), + counter: credential.counter, + transports: credential.transports || [], + deviceType: credentialDeviceType, + backedUp: credentialBackedUp, + friendlyName: name, + createdAt: now, + lastUsedAt: now, + }; + + // Create new credential user + const credentialUserId = SHA256("passkey:" + userHandle); + const user = { + _id: credentialUserId, + services: { + passkey: { + userHandle: userHandle, + keys: [keyEntry], + }, + }, + }; + + Accounts.insertUserDoc({}, user); + + // Link the credential to the current account + Accounts.linkCredentialToAccount( + this.connection.sandstormDb, + this.connection.sandstormBackend, + credentialUserId, + this.userId, + true // allowLogin + ); + + return { credentialId: credential.id, friendlyName: name }; + }, + + "passkey.generateAuthenticationOptions": async function () { + if (!Accounts.loginServices.passkey.isEnabled()) { + throw new Meteor.Error(403, "Passkey login service is disabled."); + } + + const rpID = getRpId(); + + const options = await generateAuthenticationOptions({ + rpID, + allowCredentials: [], + userVerification: "preferred", + timeout: 300000, + }); + + storeChallenge(this.connection.id, options.challenge); + + return options; + }, + + "passkey.rename": function (credentialId, newName) { + check(credentialId, String); + check(newName, String); + + if (!this.userId) { + throw new Meteor.Error(403, "Must be logged in."); + } + + if (!newName.trim()) { + throw new Meteor.Error(400, "Name cannot be empty."); + } + + // Find the credential user that has this key and is linked to the current account + const account = Meteor.users.findOne({ _id: this.userId }); + if (!account || !account.loginCredentials) { + throw new Meteor.Error(403, "Must be logged in to an account."); + } + + for (const cred of account.loginCredentials) { + const result = Meteor.users.update( + { + _id: cred.id, + "services.passkey.keys.credentialId": credentialId, + }, + { + $set: { "services.passkey.keys.$.friendlyName": newName.trim() }, + } + ); + if (result > 0) return; + } + + throw new Meteor.Error(404, "Passkey not found."); + }, + + "passkey.remove": function (credentialId) { + check(credentialId, String); + + if (!this.userId) { + throw new Meteor.Error(403, "Must be logged in."); + } + + const account = Meteor.users.findOne({ _id: this.userId }); + if (!account || !account.loginCredentials) { + throw new Meteor.Error(403, "Must be logged in to an account."); + } + + for (const cred of account.loginCredentials) { + const credUser = Meteor.users.findOne({ _id: cred.id }); + if (!credUser || !credUser.services || !credUser.services.passkey) continue; + + const keys = credUser.services.passkey.keys || []; + const keyIndex = keys.findIndex(function (k) { return k.credentialId === credentialId; }); + if (keyIndex === -1) continue; + + if (keys.length === 1) { + // Last key: remove entire credential and unlink from account + Meteor.users.update( + { _id: this.userId }, + { $pull: { loginCredentials: { id: cred.id } } } + ); + Meteor.users.remove({ _id: cred.id }); + } else { + // Remove just this key + Meteor.users.update( + { _id: cred.id }, + { $pull: { "services.passkey.keys": { credentialId: credentialId } } } + ); + } + + return; + } + + throw new Meteor.Error(404, "Passkey not found."); + }, +}); + +// Login handler +Accounts.registerLoginHandler("passkey", async function (options) { + if (!options.passkey) return undefined; + + if (!Accounts.loginServices.passkey.isEnabled()) { + throw new Meteor.Error(403, "Passkey login service is disabled."); + } + + check(options.passkey, Object); + + const connectionId = this.connection.id; + const expectedChallenge = consumeChallenge(connectionId); + if (!expectedChallenge) { + throw new Meteor.Error(403, "Challenge expired or not found."); + } + + // Find credential user by credentialId + const authResponseId = options.passkey.id; + const credentialUser = Meteor.users.findOne({ + "services.passkey.keys.credentialId": authResponseId, + }); + + if (!credentialUser) { + return { error: new Meteor.Error(403, "Passkey not recognized.") }; + } + + const keys = credentialUser.services.passkey.keys; + const matchingKey = keys.find(function (k) { return k.credentialId === authResponseId; }); + + if (!matchingKey) { + return { error: new Meteor.Error(403, "Passkey not recognized.") }; + } + + const rpID = getRpId(); + const expectedOrigin = getExpectedOrigin(); + + let verification; + try { + verification = await verifyAuthenticationResponse({ + response: options.passkey, + expectedChallenge, + expectedOrigin, + expectedRPID: rpID, + credential: { + id: matchingKey.credentialId, + publicKey: base64urlToUint8Array(matchingKey.publicKey), + counter: matchingKey.counter, + transports: matchingKey.transports, + }, + }); + } catch (err) { + console.error("Passkey authentication failed:", err.message); + return { error: new Meteor.Error(403, "Passkey authentication failed.") }; + } + + if (!verification.verified) { + return { error: new Meteor.Error(403, "Passkey authentication failed.") }; + } + + const { newCounter, credentialBackedUp } = verification.authenticationInfo; + + // Counter regression check + if (matchingKey.counter > 0 && newCounter <= matchingKey.counter) { + console.warn( + "Passkey counter regression detected for credential", + authResponseId, + ": stored =", matchingKey.counter, + ", received =", newCounter + ); + } + + // Update credential state + Meteor.users.update( + { + _id: credentialUser._id, + "services.passkey.keys.credentialId": authResponseId, + }, + { + $set: { + "services.passkey.keys.$.counter": newCounter, + "services.passkey.keys.$.backedUp": credentialBackedUp, + "services.passkey.keys.$.lastUsedAt": new Date(), + }, + } + ); + + return { userId: credentialUser._id }; +}); +``` + +- [ ] **Step 2: Add server import in main.ts** + +In `shell/server/main.ts`, after line 70 (`import "../imports/server/accounts/saml/saml-server";`), add: + +```typescript +import "../imports/server/accounts/passkey/passkey-server"; +``` + +- [ ] **Step 3: Commit** + +```bash +git add shell/imports/server/accounts/passkey/passkey-server.js shell/server/main.ts +git commit -m "feat(passkey): add server-side login handler, registration, and authentication methods" +``` + +--- + +### Task 4: Service Registration + +**Files:** +- Modify: `shell/imports/sandstorm-accounts-packages/accounts.js` + +- [ ] **Step 1: Register the passkey login service** + +In `shell/imports/sandstorm-accounts-packages/accounts.js`, at the end of the file (after line 66), add: + +```javascript +Accounts.loginServices.passkey = { + isEnabled() { + const db = SandstormDb.prototype; + // Use globalDb if available, otherwise check setting directly + if (typeof globalDb !== "undefined") { + return globalDb.getSettingWithFallback("passkey", false); + } + return false; + }, + + getLoginId(credential) { + return credential.services.passkey.userHandle; + }, + + initiateLogin(loginId) { + // This is only called on the client. The actual implementation + // is in passkey-client.js, called by the template event handler. + }, + + loginTemplate: { + name: "passkeyLoginForm", + priority: 1, + data: { name: "passkey", displayName: "Passkey" }, + }, +}; +``` + +Also add the `globalDb` import at the top of the file. After the existing import on line 4: + +```javascript +import { globalDb } from "/imports/db-deprecated"; +``` + +- [ ] **Step 2: Commit** + +```bash +git add shell/imports/sandstorm-accounts-packages/accounts.js +git commit -m "feat(passkey): register Accounts.loginServices.passkey with login template and isEnabled" +``` + +--- + +### Task 5: Client-Side Passkey Templates and Logic + +**Files:** +- Create: `shell/imports/client/accounts/passkey/passkey-templates.html` +- Create: `shell/imports/client/accounts/passkey/passkey-client.js` +- Modify: `shell/imports/client/accounts/login-buttons.js` +- Modify: `shell/client/main.ts` + +- [ ] **Step 1: Create the passkey Blaze templates** + +Create `shell/imports/client/accounts/passkey/passkey-templates.html`: + +```html + + + +``` + +- [ ] **Step 2: Create the passkey client module** + +Create `shell/imports/client/accounts/passkey/passkey-client.js`: + +```javascript +import { Meteor } from "meteor/meteor"; +import { Template } from "meteor/templating"; +import { ReactiveVar } from "meteor/reactive-var"; +import { Accounts } from "meteor/accounts-base"; + +import { + startAuthentication, + startRegistration, + browserSupportsWebAuthn, + WebAuthnError, +} from "@simplewebauthn/browser"; + +import { globalDb } from "/imports/db-deprecated"; + +const loginWithPasskey = function (callback) { + Meteor.call("passkey.generateAuthenticationOptions", function (err, optionsJSON) { + if (err) { + callback(err); + return; + } + + startAuthentication({ optionsJSON }).then(function (authResponse) { + Accounts.callLoginMethod({ + methodArguments: [{ passkey: authResponse }], + userCallback: function (error) { + if (error) { + callback(error); + } else { + callback(); + } + }, + }); + }).catch(function (err) { + if (err instanceof WebAuthnError) { + if (err.code === "ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY" || + err.code === "ERROR_CEREMONY_ABORTED") { + // User cancelled or NotAllowedError: silently restore UI + return; + } + } + callback(new Meteor.Error(403, "Something went wrong. Please try again.")); + }); + }); +}; + +export { loginWithPasskey, browserSupportsWebAuthn }; + +// Login button template +Template.passkeyLoginForm.helpers({ + webAuthnSupported() { + return browserSupportsWebAuthn(); + }, + + loginProviderLabel() { + return "with Passkey"; + }, +}); + +Template.passkeyLoginForm.events({ + "click button.login.oneclick.passkey"(event, instance) { + if (instance.data.linkingNewCredential) { + sessionStorage.setItem("linkingCredentialLoginToken", Accounts._storedLoginToken()); + } + + const loginButtonsSession = Accounts._loginButtonsSession; + loginButtonsSession.resetMessages(); + + loginWithPasskey(function (err) { + if (err) { + loginButtonsSession.errorMessage(err.reason || "Unknown error"); + } else { + // Close login overlays + const grainviews = typeof globalGrains !== "undefined" ? globalGrains.getAll() : []; + grainviews.forEach(function (grainview) { + grainview.disableSigninOverlay(); + }); + } + }); + }, +}); + +// Account settings: passkey management +Template.passkeyManagement.onCreated(function () { + this._passkeyError = new ReactiveVar(null); + this._passkeySuccess = new ReactiveVar(null); + this._renamingCredentialId = new ReactiveVar(null); +}); + +Template.passkeyManagement.helpers({ + passkeyEnabled() { + return Accounts.loginServices.passkey && Accounts.loginServices.passkey.isEnabled(); + }, + + passkeys() { + const user = Meteor.user(); + if (!user || !user.loginCredentials) return []; + + const result = []; + const renamingId = Template.instance()._renamingCredentialId.get(); + + user.loginCredentials.forEach(function (cred) { + const credUser = Meteor.users.findOne({ _id: cred.id }); + if (credUser && credUser.services && credUser.services.passkey) { + (credUser.services.passkey.keys || []).forEach(function (key) { + result.push({ + credentialId: key.credentialId, + friendlyName: key.friendlyName, + createdAt: key.createdAt, + lastUsedAt: key.lastUsedAt, + isRenaming: key.credentialId === renamingId, + }); + }); + } + }); + + return result; + }, + + passkeyError() { + return Template.instance()._passkeyError.get(); + }, + + passkeySuccess() { + return Template.instance()._passkeySuccess.get(); + }, + + formatDate(date) { + if (!date) return ""; + return new Date(date).toLocaleDateString(); + }, +}); + +Template.passkeyManagement.events({ + "click button.passkey-add"(event, instance) { + instance._passkeyError.set(null); + instance._passkeySuccess.set(null); + + Meteor.call("passkey.generateRegistrationOptions", function (err, result) { + if (err) { + instance._passkeyError.set(err.reason || "Failed to start registration."); + return; + } + + const { options: optionsJSON, userHandle } = result; + + startRegistration({ optionsJSON }).then(function (attestationResponse) { + // Prompt for friendly name after ceremony + const friendlyName = prompt("Name this passkey (optional):"); + + // Determine whether to use new user or existing user method + const user = Meteor.user(); + let hasExistingPasskey = false; + if (user && user.loginCredentials) { + user.loginCredentials.forEach(function (cred) { + const credUser = Meteor.users.findOne({ _id: cred.id }); + if (credUser && credUser.services && credUser.services.passkey) { + hasExistingPasskey = true; + } + }); + } + + const methodName = hasExistingPasskey + ? "passkey.verifyRegistration" + : "passkey.verifyRegistrationNewUser"; + + const args = hasExistingPasskey + ? [attestationResponse, friendlyName || null] + : [attestationResponse, userHandle, friendlyName || null]; + + Meteor.call(methodName, ...args, function (err, result) { + if (err) { + instance._passkeyError.set(err.reason || "Registration failed."); + } else { + instance._passkeySuccess.set( + "Passkey \"" + result.friendlyName + "\" registered successfully." + ); + } + }); + }).catch(function (err) { + if (err instanceof WebAuthnError) { + if (err.code === "ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED") { + instance._passkeyError.set("This authenticator is already registered."); + return; + } + if (err.code === "ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY" || + err.code === "ERROR_CEREMONY_ABORTED") { + return; // User cancelled + } + } + instance._passkeyError.set("Something went wrong. Please try again."); + }); + }); + }, + + "click button.passkey-rename"(event, instance) { + const credentialId = event.currentTarget.closest(".passkey-item").dataset.credentialId; + instance._renamingCredentialId.set(credentialId); + }, + + "click button.passkey-rename-save"(event, instance) { + const li = event.currentTarget.closest(".passkey-item"); + const credentialId = li.dataset.credentialId; + const newName = li.querySelector(".passkey-rename-input").value; + + Meteor.call("passkey.rename", credentialId, newName, function (err) { + if (err) { + instance._passkeyError.set(err.reason || "Failed to rename."); + } else { + instance._renamingCredentialId.set(null); + } + }); + }, + + "click button.passkey-rename-cancel"(event, instance) { + instance._renamingCredentialId.set(null); + }, + + "click button.passkey-remove"(event, instance) { + const credentialId = event.currentTarget.closest(".passkey-item").dataset.credentialId; + + if (!confirm("Remove this passkey? This cannot be undone.")) return; + + instance._passkeyError.set(null); + instance._passkeySuccess.set(null); + + Meteor.call("passkey.remove", credentialId, function (err) { + if (err) { + instance._passkeyError.set(err.reason || "Failed to remove."); + } else { + instance._passkeySuccess.set("Passkey removed."); + } + }); + }, +}); +``` + +- [ ] **Step 3: Add client imports in main.ts** + +In `shell/client/main.ts`, after line 69 (`import "../imports/client/accounts/account-settings.html";`), add: + +```typescript +import "../imports/client/accounts/passkey/passkey-templates.html"; +``` + +After line 113 (`import "../imports/client/accounts/saml/saml-client-pt2";`), add: + +```typescript +import "../imports/client/accounts/passkey/passkey-client"; +``` + +- [ ] **Step 4: Commit** + +```bash +git add shell/imports/client/accounts/passkey/passkey-templates.html shell/imports/client/accounts/passkey/passkey-client.js shell/client/main.ts +git commit -m "feat(passkey): add client-side login button, authentication flow, and account settings management" +``` + +--- + +### Task 6: Admin Configuration UI + +**Files:** +- Modify: `shell/imports/client/admin/login-providers.js` +- Modify: `shell/imports/client/admin/login-providers.html` + +- [ ] **Step 1: Add passkey to the idpData array** + +In `shell/imports/client/admin/login-providers.js`, inside the `idpData` function, add a `passkeyEnabled` variable after the `samlEnabled` line (line 20): + +```javascript + const passkeyEnabled = globalDb.getSettingWithFallback("passkey", false); +``` + +Then add a new entry to the return array, after the SAML entry (after line 83, before the closing `];`): + +```javascript + { + id: "passkey", + label: "Passkey", + icon: "/passkey.svg", + enabled: passkeyEnabled, + popupTemplate: "adminLoginProviderConfigurePasskey", + onConfigure() { + configureCallback("passkey"); + }, + }, +``` + +- [ ] **Step 2: Add the admin config modal template** + +In `shell/imports/client/admin/login-providers.html`, before the `adminLoginProviderTable` template (before line 470), add: + +```html + +``` + +- [ ] **Step 3: Add the admin config modal JS handlers** + +In `shell/imports/client/admin/login-providers.js`, at the end of the file (after line 925), add: + +```javascript +// Passkey form. +Template.adminLoginProviderConfigurePasskey.onCreated(function () { + this.errorMessage = new ReactiveVar(undefined); + this.setAccountSettingCallback = setAccountSettingCallback.bind(this); +}); + +Template.adminLoginProviderConfigurePasskey.onRendered(function () { + this.find("button.idp-modal-save").focus(); +}); + +Template.adminLoginProviderConfigurePasskey.events({ + "click .idp-modal-disable"(evt) { + const instance = Template.instance(); + const token = Iron.controller().state.get("token"); + Meteor.call("setAccountSetting", token, "passkey", false, instance.setAccountSettingCallback); + }, + + "click .idp-modal-save"(evt) { + const instance = Template.instance(); + const token = Iron.controller().state.get("token"); + Meteor.call("setAccountSetting", token, "passkey", true, instance.setAccountSettingCallback); + }, + + "click .idp-modal-cancel"(evt) { + const instance = Template.instance(); + instance.data.onDismiss()(); + }, +}); + +Template.adminLoginProviderConfigurePasskey.helpers({ + passkeyEnabled() { + return globalDb.getSettingWithFallback("passkey", false); + }, + + errorMessage() { + const instance = Template.instance(); + return instance.errorMessage.get(); + }, +}); +``` + +- [ ] **Step 4: Commit** + +```bash +git add shell/imports/client/admin/login-providers.js shell/imports/client/admin/login-providers.html +git commit -m "feat(passkey): add admin panel enable/disable toggle for passkey login provider" +``` + +--- + +### Task 7: Account Settings Integration + +**Files:** +- Modify: `shell/imports/client/accounts/account-settings.html` + +- [ ] **Step 1: Add passkey management section to account settings** + +In `shell/imports/client/accounts/account-settings.html`, after the closing `` of the `verified-emails-editor` section (after line 113), add: + +```html + + {{> passkeyManagement}} +``` + +This renders the `passkeyManagement` template defined in `passkey-templates.html`. The template itself handles checking whether passkeys are enabled. + +- [ ] **Step 2: Commit** + +```bash +git add shell/imports/client/accounts/account-settings.html +git commit -m "feat(passkey): add passkey management section to account settings page" +``` + +--- + +### Task 8: Passkey SVG Icon + +**Files:** +- Create: `shell/public/passkey.svg` + +- [ ] **Step 1: Add the FIDO Alliance passkey icon** + +Create `shell/public/passkey.svg` with the FIDO Alliance standard passkey icon. This is a simplified key icon: + +```svg + + + + +``` + +- [ ] **Step 2: Commit** + +```bash +git add shell/public/passkey.svg +git commit -m "feat(passkey): add passkey SVG icon for admin and login UI" +``` + +--- + +### Task 9: i18n Translation Keys + +**Files:** +- Modify: `shell/i18n/en.i18n.json` + +- [ ] **Step 1: Add passkey provider label** + +In `shell/i18n/en.i18n.json`, find the `"providers"` object inside `admin.identityProviders` (around line 577). Add: + +```json +"passkey": "Passkey" +``` + +So it becomes: + +```json +"providers": { + "emailPassLess": "E-mail (passwordless)", + "passkey": "Passkey" +}, +``` + +- [ ] **Step 2: Add admin passkey config translations** + +In `shell/i18n/en.i18n.json`, inside the `admin.identityProviders` object, add a new section for the passkey admin modal (after the `adminIdentityProviderConfigureSaml` section): + +```json +"adminIdentityProviderConfigurePasskey": { + "title": "Passkey", + "explanation": "Passkeys let users sign in with biometrics, security keys, or their phone. No passwords needed. The server derives its identity from its URL automatically.", + "enableButton": "Enable", + "saveButton": "Save", + "disableButton": "Disable", + "cancelButton": "Cancel", + "error": "Error: %s" +}, +``` + +- [ ] **Step 3: Add passkey login button translations** + +In `shell/i18n/en.i18n.json`, inside the `accounts.loginButtons` section, add: + +```json +"passkeyLoginForm": { + "label": "with Passkey" +}, +``` + +- [ ] **Step 4: Add passkey account settings translations** + +In `shell/i18n/en.i18n.json`, inside the `accounts.accountSettings` section, add: + +```json +"passkey": { + "title": "Passkeys", + "noPasskeys": "No passkeys registered.", + "addButton": "Add passkey", + "renameButton": "Rename", + "removeButton": "Remove", + "saveButton": "Save", + "cancelButton": "Cancel", + "added": "Added", + "lastUsed": "last used" +}, +``` + +- [ ] **Step 5: Commit** + +```bash +git add shell/i18n/en.i18n.json +git commit -m "feat(passkey): add i18n translation keys for passkey admin, login, and account settings" +``` + +--- + +### Task 10: Verification and Smoke Test + +- [ ] **Step 1: Verify Meteor app starts without errors** + +Run: `cd shell && meteor` +Expected: App starts successfully, no import errors or crashes. + +- [ ] **Step 2: Verify admin panel shows passkey provider** + +Navigate to `/admin/login` in the browser. Verify the passkey provider row appears in the login provider table with a "Configure" button. Click "Configure" and verify the enable/disable modal appears. + +- [ ] **Step 3: Enable passkey provider and verify login button** + +Enable the passkey provider in the admin panel. Navigate to the login dropdown. Verify the "with Passkey" button appears at the top of the list. If the browser does not support WebAuthn (unlikely in modern browsers), the button should not appear. + +- [ ] **Step 4: Test passkey registration from account settings** + +Log in with another method (email token or dev accounts). Navigate to account settings. Verify the "Passkeys" section appears. Click "Add passkey" and complete the WebAuthn ceremony. Verify the passkey appears in the list with a friendly name. + +- [ ] **Step 5: Test passkey login** + +Log out. Click the "with Passkey" button. Complete the WebAuthn ceremony. Verify successful login. + +- [ ] **Step 6: Test passkey rename and remove** + +Navigate to account settings. Rename a passkey and verify the name updates. Remove a passkey and verify it disappears from the list. + +- [ ] **Step 7: Commit any fixes** + +If any issues were found during testing, fix them and commit: + +```bash +git add -A +git commit -m "fix(passkey): address issues found during smoke testing" +``` + +--- + +## Dependency Graph + +Tasks 1 through 2 can be done in parallel. Task 3 depends on Task 1 (npm packages). Task 4 has no dependencies. Tasks 5 and 6 depend on Tasks 3 and 4. Task 7 depends on Task 5. Task 8 and 9 can be done any time. Task 10 depends on all previous tasks. + +``` +Task 1 (npm deps) ──────┐ + ├── Task 3 (server) ──┐ +Task 2 (db/profile) ────┘ ├── Task 5 (client) ── Task 7 (account settings HTML) + │ +Task 4 (service reg) ──────────────────────────┘ + Task 10 (verification) +Task 8 (icon) ──────────────────────────────────────────────┘ +Task 9 (i18n) ──────────────────────────────────────────────┘ +``` diff --git a/docs/superpowers/specs/2026-05-06-passkey-auth-design.md b/docs/superpowers/specs/2026-05-06-passkey-auth-design.md new file mode 100644 index 000000000..264ca5373 --- /dev/null +++ b/docs/superpowers/specs/2026-05-06-passkey-auth-design.md @@ -0,0 +1,339 @@ +# Passkey Authentication for Sandstorm + +**Date:** 2026-05-06 +**Status:** Draft +**Scope:** Add WebAuthn/passkey support as a standalone login method to Sandstorm + +## Overview + +Add passkey (WebAuthn) authentication to Sandstorm as a first-class login method, matching the existing patterns for email token, Google, GitHub, LDAP, SAML, and OIDC. Passkeys provide phishing-resistant, passwordless login using platform authenticators (Touch ID, Face ID, Windows Hello) and roaming authenticators (YubiKeys, phones via QR code). + +This feature lives entirely within the Meteor shell layer. No changes to C++ code, Cap'n Proto schemas, or the build system. + +## Dependencies + +- `@simplewebauthn/server` (v11+): Server-side WebAuthn registration and authentication verification +- `@simplewebauthn/browser` (v11+): Client-side wrapper for `navigator.credentials` API with cross-browser handling and error identification + +## Data Model + +### Credential User Structure + +Following Sandstorm's credential/account separation model, each passkey user gets a credential user (type: "credential") with `_id: SHA256("passkey:" + userHandle)`. + +```javascript +{ + _id: String, // SHA256("passkey:" + userHandle) + type: "credential", + createdAt: Date, + lastActive: Date, + services: { + passkey: { + userHandle: String, // Random opaque ID (WebAuthn user.id), base64url, max 64 bytes + // Generated once per user, reused across all their passkeys + // Must NOT contain PII + keys: [{ + credentialId: String, // Base64url credential ID from authenticator + publicKey: String, // Base64url-encoded COSE/CBOR public key bytes + // Decode to Uint8Array before passing to verifyAuthenticationResponse + counter: Number, // Signature counter, update after each authentication + transports: [String], // e.g. ["internal", "hybrid"] passed to allowCredentials for UX hints + deviceType: String, // "singleDevice" or "multiDevice" (permanent, from backup eligibility flag) + backedUp: Boolean, // Backup state flag, update on each authentication + friendlyName: String, // User-provided or auto-generated "Passkey (YYYY-MM-DD)" + createdAt: Date, + lastUsedAt: Date, + }] + } + } +} +``` + +### Database Index + +Add a sparse index on `services.passkey.keys.credentialId` in `db.js` to enable lookup during authentication (the authenticator returns its credentialId, and we need to find the matching user). + +### Profile Integration + +Add a `services.passkey` branch to: + +- `SandstormDb.fillInProfileDefaults`: Set `profile.name` to `"Passkey User"`, `profile.handle` to a filtered random string +- `SandstormDb.getIntrinsicName`: Return `services.passkey.userHandle` +- `credentialDetails` publication: Expose `services.passkey.userHandle` and `services.passkey.keys` fields (excluding `publicKey` from the publication as good practice, though public keys are not secret) + +## Server-Side Architecture + +### Challenge Storage + +In-memory `Map` keyed by Meteor `connection.id`. This binds challenges to the specific browser connection, preventing cross-context reuse. + +- **Expiry:** 5 minutes (aligns with W3C recommended client-side timeout default) +- **One-time use:** Delete challenge from Map immediately after successful verification +- **Cryptographic randomness:** Challenges generated by `@simplewebauthn/server` (uses crypto.randomBytes internally) +- **Periodic cleanup:** Sweep expired entries on a timer + +Single-server deployment means in-memory storage is appropriate (same pattern SAML uses with `_loginResultForCredentialToken`). + +### RP ID and Origin + +- **RP ID:** Derived from hostname of `ROOT_URL` via `new URL(Meteor.absoluteUrl()).hostname`. No scheme, no port. This value is cryptographically bound to registered credentials and cannot change after deployment. +- **Expected origin:** Full origin from `Meteor.absoluteUrl()` (scheme + hostname + port if non-standard) +- **RP name:** Derived from `globalDb.getServerTitle()` + +### WebAuthn Parameters (Hardcoded Defaults) + +| Parameter | Value | Rationale | +|-----------|-------|-----------| +| `residentKey` | `"required"` | Needed for discoverable credential / standalone login | +| `userVerification` | `"preferred"` | Lets authenticator decide; doesn't exclude simpler security keys | +| `attestation` | `"none"` | No need to verify authenticator manufacturer | +| `authenticatorAttachment` | (omitted) | Allow both platform and roaming authenticators | +| `timeout` | `300000` (5 minutes) | W3C recommended default; gives time for biometric setup | + +### Meteor Methods + +#### `passkey.generateRegistrationOptions` + +Called from account settings when adding a new passkey. Requires an authenticated user. + +1. Get or generate the user's `userHandle` (random base64url, 32 bytes, generated once and stored) +2. Fetch existing credential IDs for `excludeCredentials` (prevents duplicate authenticator registration) +3. Call `generateRegistrationOptions()` from `@simplewebauthn/server` with: + - `rpName`, `rpID` from server config + - `userName`: placeholder (e.g., server hostname), since Sandstorm sets real name in post-signup profile flow + - `userDisplayName`: placeholder (e.g., "Sandstorm User") + - `userID`: the `userHandle` as `Uint8Array` + - `excludeCredentials`: existing credential IDs with their transports + - `authenticatorSelection`: `{ residentKey: "required", userVerification: "preferred" }` + - `attestation`: `"none"` + - `timeout`: `300000` +4. Store challenge in memory keyed by `this.connection.id` +5. Return options JSON to client + +#### `passkey.verifyRegistration(attestationResponse, friendlyName)` + +Called after the browser WebAuthn ceremony completes. + +1. Retrieve and delete challenge from memory (one-time use) +2. Call `verifyRegistrationResponse()` with the attestation response, expected challenge, origin, and RP ID +3. On success, extract from `registrationInfo`: + - `credential.id` (base64url credentialId) + - `credential.publicKey` (Uint8Array, encode to base64url for storage) + - `credential.counter` + - `credential.transports` + - `credentialDeviceType` + - `credentialBackedUp` +4. If user has no existing passkey credential: create credential user via `Accounts.insertUserDoc` with `services.passkey.userHandle` and the key in `services.passkey.keys` +5. If user has an existing passkey credential: `$push` new key to `services.passkey.keys` +6. If called during account linking: call `Accounts.linkCredentialToAccount` +7. Auto-generate `friendlyName` as `"Passkey (YYYY-MM-DD)"` if none provided + +#### `passkey.generateAuthenticationOptions` + +Called from the login button click. No authentication required. + +1. Call `generateAuthenticationOptions()` with: + - `rpID` from server config + - `allowCredentials`: empty array (discoverable credentials) + - `userVerification`: `"preferred"` + - `timeout`: `300000` +2. Store challenge in memory keyed by `this.connection.id` +3. Return options JSON to client + +### Login Handler + +```javascript +Accounts.registerLoginHandler("passkey", function (options) { + if (!options.passkey) return undefined; + + if (!Accounts.loginServices.passkey.isEnabled()) { + throw new Meteor.Error(403, "Passkey login service is disabled."); + } + + // 1. Retrieve and delete challenge (one-time use) + // 2. Find credential user by credentialId (from options.passkey.id) + // 3. Get the matching key entry from services.passkey.keys + // 4. Call verifyAuthenticationResponse() with: + // - response: options.passkey + // - expectedChallenge: stored challenge + // - expectedOrigin: from Meteor.absoluteUrl() + // - expectedRPID: from hostname + // - credential: { id, publicKey (as Uint8Array), counter, transports } + // 5. On success, update stored credential: + // - counter = authenticationInfo.newCounter + // - backedUp = authenticationInfo.credentialBackedUp + // - lastUsedAt = new Date() + // 6. On counter regression (stored > 0, new <= stored): log warning, do not lock + // 7. Return { userId: credentialUser._id } +}); +``` + +### Passkey Management Methods + +- `passkey.rename(credentialId, newName)`: Update `friendlyName` of a registered key. Requires authenticated user who owns the credential. +- `passkey.remove(credentialId)`: Remove a key from `services.passkey.keys`. Requires authenticated user. If it's the last key in the array, also remove the credential user document and unlink its ID from the account's `loginCredentials` (or `nonloginCredentials`) array via the existing `Accounts.unlinkCredential` pattern. + +## Client-Side Architecture + +### Login Flow + +**Template** (in `passkey-templates.html`): + +```html + +``` + +Matches the SAML button pattern: a single button, no form fields. + +**Click handler flow:** + +1. Call `passkey.generateAuthenticationOptions` Meteor method +2. Pass result directly to `startAuthentication({ optionsJSON })` from `@simplewebauthn/browser` +3. Send authentication response to server via `Accounts.callLoginMethod({ methodArguments: [{ passkey: response }] })` +4. On success: `closeLoginOverlays()` (same as other methods) + +**Safari gesture compliance:** The Meteor method call and `startAuthentication()` must be chained directly in the click handler's execution path without intervening UI updates or extra promise boundaries. + +### Feature Detection + +Use `browserSupportsWebAuthn()` from `@simplewebauthn/browser`. If it returns false, do not render the passkey login button. This check runs in the template helper, not as a global guard. + +### Error Handling + +Catch `WebAuthnError` from `@simplewebauthn/browser`: + +| Error Code | Handling | +|------------|----------| +| `ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY` (NotAllowedError / user cancel) | Silently restore login UI. No error message. | +| `ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED` (InvalidStateError) | Show "This authenticator is already registered." | +| `ERROR_CEREMONY_ABORTED` | Silently restore UI. | +| All others | Show "Something went wrong. Please try again." | + +Never show raw browser errors to users. + +### Service Registration + +In `accounts.js` (or a passkey-specific file imported from it): + +```javascript +Accounts.loginServices.passkey = { + isEnabled() { + return globalDb.getSettingWithFallback("passkey", false); + }, + getLoginId(credential) { + return credential.services.passkey.userHandle; + }, + initiateLogin(loginId) { + loginWithPasskey(); + }, + loginTemplate: { + name: "passkeyLoginForm", + priority: 1, + data: { name: "passkey", displayName: "Passkey" }, + }, +}; +``` + +Priority 1 places the passkey button at the top of the login list, following FIDO Alliance guidance to make passkeys the "path of least resistance." + +### Registration Flow (from Account Settings) + +1. User clicks "Add passkey" in account settings +2. Call `passkey.generateRegistrationOptions` Meteor method +3. Pass result to `startRegistration({ optionsJSON })` from `@simplewebauthn/browser` +4. On success: prompt for friendly name (AFTER the ceremony, not before; matches GitHub's pattern) +5. Send attestation response and friendly name to `passkey.verifyRegistration` +6. Refresh the passkey list in account settings + +### Account Settings UI + +Add a "Passkeys" section to the existing account settings page: + +- List registered passkeys showing: `friendlyName`, `createdAt`, `lastUsedAt` +- "Rename" action per key (inline edit or modal) +- "Remove" action per key (with confirmation) +- "Add passkey" button that triggers the registration flow +- Only visible when the passkey login service is enabled + +## Admin Configuration + +### Settings + +Simple enable/disable toggle stored in `globalDb.collections.settings` with key `"passkey"`, using the existing `setAccountSetting` pattern. + +### IDP Data Entry + +Added to the `idpData` array in `login-providers.js`: + +```javascript +{ + id: "passkey", + label: "Passkey", + icon: "/passkey.svg", + enabled: globalDb.getSettingWithFallback("passkey", false), + popupTemplate: "adminLoginProviderConfigurePasskey", + onConfigure() { configureCallback("passkey"); }, +} +``` + +### Admin Config Modal + +Added to `login-providers.html` (all provider templates live in this single file). Minimal dialog matching the email-token pattern: + +- Brief explanation of what passkeys are +- Enable button +- Disable button (if currently enabled) +- Cancel button + +No additional configuration fields. RP ID is derived from `ROOT_URL`, all WebAuthn parameters are hardcoded to best-practice defaults. + +### Icon + +A new `/passkey.svg` static asset using the FIDO Alliance standard passkey icon. + +## Files to Create + +| File | Purpose | +|------|---------| +| `shell/imports/server/accounts/passkey/passkey-server.js` | Login handler, Meteor methods, challenge storage | +| `shell/imports/client/accounts/passkey/passkey-client.js` | `loginWithPasskey()`, `registerPasskey()`, feature detection | +| `shell/imports/client/accounts/passkey/passkey-templates.html` | Blaze templates for login button and account settings passkey management | +| `shell/public/passkey.svg` | FIDO Alliance passkey icon | + +## Files to Modify + +| File | Change | +|------|--------| +| `shell/server/main.ts` | Add explicit import for `passkey-server.js` | +| `shell/client/main.ts` | Add explicit imports for `passkey-templates.html` and `passkey-client.js` | +| `shell/imports/client/accounts/login-buttons.js` | Add click handler for passkey button | +| `shell/imports/client/admin/login-providers.html` | Add `adminLoginProviderConfigurePasskey` template | +| `shell/imports/client/admin/login-providers.js` | Add passkey entry to `idpData` array | +| `shell/imports/client/accounts/account-settings.html` | Add passkey management section | +| `shell/imports/client/accounts/account-settings.js` | Add passkey management logic (list, add, rename, delete) | +| `shell/imports/sandstorm-accounts-packages/accounts.js` | Register `Accounts.loginServices.passkey` | +| `shell/imports/sandstorm-db/profile.js` | Add `services.passkey` branch to `fillInProfileDefaults`, `getIntrinsicName`, and `credentialDetails` publication | +| `shell/imports/sandstorm-db/db.js` | Add sparse index on `services.passkey.keys.credentialId` | +| `shell/package.json` | Add `@simplewebauthn/server` and `@simplewebauthn/browser` | +| `shell/i18n/en.i18n.json` | Add translation keys under `admin.identityProviders.*` and `accounts.loginButtons.*` namespaces | + +## Security Considerations + +- **Challenges are one-time use:** Deleted from memory immediately after verification to prevent replay +- **Challenges bound to connection:** Keyed by Meteor `connection.id` to prevent cross-context reuse +- **No attestation verification:** Using `attestation: "none"` is appropriate for consumer self-hosted software; enterprise attestation can be added later if needed +- **Counter regression:** Logged as a warning but does not lock the credential; false positives are common (authenticator firmware bugs, platform sync behavior, some authenticators always return 0) +- **Public keys kept server-side:** Not exposed in publications as good practice, though they are not secret by nature +- **RP ID immutability:** Derived from hostname; changing the server URL after passkeys are registered will invalidate all existing passkey credentials (this is inherent to WebAuthn, not a design choice) +- **User handle is opaque:** Random bytes, no PII, per W3C and FIDO guidance + +## Out of Scope + +- Conditional UI / autofill integration (Sandstorm's login dropdown doesn't have a username field) +- Attestation verification and authenticator type restrictions (can be added as advanced admin options later) +- Multi-server challenge storage (Sandstorm is single-server) +- Migration tooling for existing credentials to passkeys (users add passkeys alongside existing methods) diff --git a/shell/.eslintrc.yml b/shell/.eslintrc.yml index 66e409a8a..cb5d9ec93 100644 --- a/shell/.eslintrc.yml +++ b/shell/.eslintrc.yml @@ -14,6 +14,8 @@ env: shared-node-browser: true es6: true es2020: true +globals: + globalGrains: readonly parser: "@typescript-eslint/parser" parserOptions: ecmaVersion: 2020 diff --git a/shell/client/main.ts b/shell/client/main.ts index e3b406f75..30ea9fcaa 100644 --- a/shell/client/main.ts +++ b/shell/client/main.ts @@ -67,6 +67,7 @@ import "../imports/blackrock-payments/client/payments-api-client"; import "../imports/client/accounts/credentials/credentials.html"; import "../imports/client/accounts/email-token/token-templates.html"; import "../imports/client/accounts/account-settings.html"; +import "../imports/client/accounts/passkey/passkey-templates.html"; import "../imports/client/accounts/login-buttons.html"; import "../imports/client/admin/admin.html"; import "../imports/client/admin/app-sources.html"; @@ -110,6 +111,7 @@ import "../imports/client/globals"; import "../imports/client/accounts/credentials/credentials-client"; import "../imports/client/accounts/email-token/token-client"; import "../imports/client/accounts/saml/saml-client-pt2"; +import "../imports/client/accounts/passkey/passkey-client"; import "../imports/client/accounts/account-settings"; import "../imports/client/accounts/accounts-testing"; import "../imports/client/accounts/login-buttons-session"; diff --git a/shell/i18n/en.i18n.json b/shell/i18n/en.i18n.json index 757e6f1e2..812871605 100644 --- a/shell/i18n/en.i18n.json +++ b/shell/i18n/en.i18n.json @@ -575,7 +575,8 @@ "identityProviders": { "name": "Login providers", "providers": { - "emailPassLess": "E-mail (passwordless)" + "emailPassLess": "E-mail (passwordless)", + "passkey": "Passkey" }, "subtext": "How users log in.", "newAdminIdentity": { @@ -726,6 +727,15 @@ "cancelButton": "Cancel", "disableButton": "Disable" }, + "adminIdentityProviderConfigurePasskey": { + "title": "Passkey", + "explanation": "Passkeys let users sign in with biometrics, security keys, or their phone. No passwords needed. The server derives its identity from its URL automatically.", + "enableButton": "Enable", + "saveButton": "Save", + "disableButton": "Disable", + "cancelButton": "Cancel", + "error": "Error: %s" + }, "adminIdentityProviderTable": { "title": "Login provider", "status": "Status" @@ -1521,6 +1531,17 @@ "saveButton": "Save", "continueButton": "Continue", "cancelButton": "Cancel" + }, + "passkey": { + "title": "Passkeys", + "noPasskeys": "No passkeys registered.", + "addButton": "Add passkey", + "renameButton": "Rename", + "removeButton": "Remove", + "saveButton": "Save", + "cancelButton": "Cancel", + "added": "Added", + "lastUsed": "last used" } }, "loginButtons": { @@ -1598,6 +1619,12 @@ }, "_loginButtonsSeparator": { "or": "or" + }, + "passkeyLoginForm": { + "label": "with Passkey", + "emailLabel": "Email", + "emailPlaceholder": "you@example.com", + "continueButton": "Continue with Passkey" } }, "identity": { diff --git a/shell/imports/client/00-startup.js b/shell/imports/client/00-startup.js index 29173c4c1..425417c5f 100644 --- a/shell/imports/client/00-startup.js +++ b/shell/imports/client/00-startup.js @@ -32,7 +32,7 @@ import { GrainViewList } from "/imports/client/grain/grainview-list"; Session.setDefault("shrink-navbar", false); // window.globalGrains is used by test code and must remain exported. -globalGrains = new GrainViewList(globalDb); +window.globalGrains = new GrainViewList(globalDb); // If Meteor._localStorage disappears, we'll have to write our own localStorage wrapper, I guess. // Using window.localStorage is dangerous because it throws an exception if cookies are disabled. diff --git a/shell/imports/client/accounts/account-settings.html b/shell/imports/client/accounts/account-settings.html index 9ec90d562..ac2a42004 100644 --- a/shell/imports/client/accounts/account-settings.html +++ b/shell/imports/client/accounts/account-settings.html @@ -112,6 +112,8 @@

{{_ (con txt "identities.email.title")}}

+ {{> passkeyManagement}} + {{#if isPaymentsEnabled}}

{{_ (con txt "billing.title")}}

diff --git a/shell/imports/client/accounts/passkey/passkey-client.js b/shell/imports/client/accounts/passkey/passkey-client.js new file mode 100644 index 000000000..f010a39b3 --- /dev/null +++ b/shell/imports/client/accounts/passkey/passkey-client.js @@ -0,0 +1,351 @@ +import { Meteor } from "meteor/meteor"; +import { Template } from "meteor/templating"; +import { ReactiveVar } from "meteor/reactive-var"; +import { Accounts } from "meteor/accounts-base"; + +import { + startAuthentication, + startRegistration, + browserSupportsWebAuthn, + WebAuthnError, +} from "@simplewebauthn/browser"; + +const MAX_PASSKEY_NAME_LENGTH = 100; + +function passkeyCeremonyErrorMessage(err, fallbackMessage) { + if (err instanceof WebAuthnError) { + return err.message || (err.cause && err.cause.message) || fallbackMessage; + } + + return (err && err.message) || fallbackMessage; +} + +function passkeyNameTooLongMessage() { + return "Passkey name must be " + MAX_PASSKEY_NAME_LENGTH + " characters or fewer."; +} + +// Identifier-first login: authenticate with existing passkey +function authenticateWithPasskey(options, callback) { + startAuthentication({ optionsJSON: options }).then(function (authResponse) { + Accounts.callLoginMethod({ + methodArguments: [{ passkey: authResponse }], + userCallback: function (error) { + if (error) { + callback(error); + } else { + callback(); + } + }, + }); + }).catch(function (err) { + if (err instanceof WebAuthnError) { + if (err.code === "ERROR_CEREMONY_ABORTED") { + return; + } + } + callback(new Meteor.Error(403, passkeyCeremonyErrorMessage( + err, "Something went wrong. Please try again." + ))); + }); +} + +// Identifier-first registration: create passkey and account +function registerWithPasskey(options, callback) { + startRegistration({ optionsJSON: options }).then(function (attestationResponse) { + Accounts.callLoginMethod({ + methodArguments: [{ passkeyRegister: attestationResponse }], + userCallback: function (error) { + if (error) { + callback(error); + } else { + callback(); + } + }, + }); + }).catch(function (err) { + if (err instanceof WebAuthnError) { + if (err.code === "ERROR_CEREMONY_ABORTED") { + return; + } + } + callback(new Meteor.Error(403, passkeyCeremonyErrorMessage( + err, "Something went wrong. Please try again." + ))); + }); +} + +// Legacy one-click login (used by initiateLogin for returning users in credential list) +const loginWithPasskey = function (callback) { + if (!window.isSecureContext) { + callback(new Meteor.Error(403, "Passkeys require HTTPS, or a localhost development URL.")); + return; + } + + Meteor.call("passkey.generateAuthenticationOptions", function (err, optionsJSON) { + if (err) { + callback(err); + return; + } + + authenticateWithPasskey(optionsJSON, callback); + }); +}; + +export { loginWithPasskey, browserSupportsWebAuthn }; + +// Conditional UI: start in background so autofill shows passkeys +function initConditionalUI() { + if (!browserSupportsWebAuthn()) return; + if (!window.isSecureContext) return; + if (!window.PublicKeyCredential || !window.PublicKeyCredential.isConditionalMediationAvailable) return; + + PublicKeyCredential.isConditionalMediationAvailable().then(function (available) { + if (!available) return; + + Meteor.call("passkey.generateAuthenticationOptions", function (err, optionsJSON) { + if (err) return; + + startAuthentication({ optionsJSON, useBrowserAutofill: true }).then(function (authResponse) { + Accounts.callLoginMethod({ + methodArguments: [{ passkey: authResponse }], + userCallback: function (error) { + if (error) { + console.error("Passkey autofill login failed:", error); + } else { + // Close login overlays + const grainviews = typeof globalGrains !== "undefined" ? globalGrains.getAll() : []; + grainviews.forEach(function (grainview) { + grainview.disableSigninOverlay(); + }); + } + }, + }); + }).catch(function () { + // Conditional UI was cancelled or failed silently; no action needed. + }); + }); + }); +} + +// Login form template +Template.passkeyLoginForm.onCreated(function () { + this._passkeyError = new ReactiveVar(null); +}); + +Template.passkeyLoginForm.onRendered(function () { + initConditionalUI(); +}); + +Template.passkeyLoginForm.helpers({ + webAuthnSupported() { + return browserSupportsWebAuthn(); + }, + + passkeyError() { + return Template.instance()._passkeyError.get(); + }, +}); + +Template.passkeyLoginForm.events({ + "submit form.passkey-login-form"(event, instance) { + event.preventDefault(); + + const email = event.currentTarget.email.value.trim(); + if (!email) { + instance._passkeyError.set("Please enter an email address."); + return; + } + + if (!window.isSecureContext) { + instance._passkeyError.set("Passkeys require HTTPS, or a localhost development URL."); + return; + } + + instance._passkeyError.set(null); + + if (instance.data.linkingNewCredential) { + sessionStorage.setItem("linkingCredentialLoginToken", Accounts._storedLoginToken()); + } + + Meteor.call("passkey.initiateWithEmail", email, function (err, result) { + if (err) { + instance._passkeyError.set(err.reason || "Something went wrong."); + return; + } + + const callback = function (err) { + if (err) { + instance._passkeyError.set(err.reason || "Something went wrong."); + } else { + // Close login overlays + const grainviews = typeof globalGrains !== "undefined" ? globalGrains.getAll() : []; + grainviews.forEach(function (grainview) { + grainview.disableSigninOverlay(); + }); + } + }; + + if (result.mode === "authenticate") { + authenticateWithPasskey(result.options, callback); + } else { + registerWithPasskey(result.options, callback); + } + }); + }, +}); + +// Account settings: passkey management (unchanged from existing code) +Template.passkeyManagement.onCreated(function () { + this._passkeyError = new ReactiveVar(null); + this._passkeySuccess = new ReactiveVar(null); + this._renamingCredentialId = new ReactiveVar(null); +}); + +Template.passkeyManagement.helpers({ + passkeyEnabled() { + return Accounts.loginServices.passkey && Accounts.loginServices.passkey.isEnabled(); + }, + + passkeys() { + const user = Meteor.user(); + if (!user || !user.loginCredentials) return []; + + const result = []; + const renamingId = Template.instance()._renamingCredentialId.get(); + + const allCredentials = (user.loginCredentials || []).concat(user.nonloginCredentials || []); + allCredentials.forEach(function (cred) { + const credUser = Meteor.users.findOne({ _id: cred.id }); + if (credUser && credUser.services && credUser.services.passkey) { + (credUser.services.passkey.keys || []).forEach(function (key) { + result.push({ + credentialId: key.credentialId, + friendlyName: key.friendlyName, + createdAt: key.createdAt, + lastUsedAt: key.lastUsedAt, + isRenaming: key.credentialId === renamingId, + }); + }); + } + }); + + return result; + }, + + passkeyError() { + return Template.instance()._passkeyError.get(); + }, + + passkeySuccess() { + return Template.instance()._passkeySuccess.get(); + }, + + passkeyNameMaxLength() { + return MAX_PASSKEY_NAME_LENGTH; + }, + + formatDate(date) { + if (!date) return ""; + return new Date(date).toLocaleDateString(); + }, +}); + +Template.passkeyManagement.events({ + "click button.passkey-add"(event, instance) { + instance._passkeyError.set(null); + instance._passkeySuccess.set(null); + + if (!window.isSecureContext) { + instance._passkeyError.set("Passkeys require HTTPS, or a localhost development URL."); + return; + } + + Meteor.call("passkey.generateRegistrationOptions", function (err, result) { + if (err) { + instance._passkeyError.set(err.reason || "Failed to start registration."); + return; + } + + const { options: optionsJSON } = result; + const friendlyName = prompt("Name this passkey (optional):"); + if (friendlyName === null) return; + if (friendlyName.trim().length > MAX_PASSKEY_NAME_LENGTH) { + instance._passkeyError.set(passkeyNameTooLongMessage()); + return; + } + + startRegistration({ optionsJSON }).then(function (attestationResponse) { + Meteor.call("passkey.verifyRegistration", + attestationResponse, friendlyName || null, + function (err, result) { + if (err) { + instance._passkeyError.set(err.reason || "Registration failed."); + } else { + instance._passkeySuccess.set( + "Passkey \"" + result.friendlyName + "\" registered successfully." + ); + } + } + ); + }).catch(function (err) { + if (err instanceof WebAuthnError) { + if (err.code === "ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED") { + instance._passkeyError.set("This authenticator is already registered."); + return; + } + if (err.code === "ERROR_CEREMONY_ABORTED") { + return; + } + } + instance._passkeyError.set(passkeyCeremonyErrorMessage( + err, "Something went wrong. Please try again." + )); + }); + }); + }, + + "click button.passkey-rename"(event, instance) { + const credentialId = event.currentTarget.closest(".passkey-item").dataset.credentialId; + instance._renamingCredentialId.set(credentialId); + }, + + "click button.passkey-rename-save"(event, instance) { + const li = event.currentTarget.closest(".passkey-item"); + const credentialId = li.dataset.credentialId; + const newName = li.querySelector(".passkey-rename-input").value; + instance._passkeyError.set(null); + if (newName.trim().length > MAX_PASSKEY_NAME_LENGTH) { + instance._passkeyError.set(passkeyNameTooLongMessage()); + return; + } + + Meteor.call("passkey.rename", credentialId, newName, function (err) { + if (err) { + instance._passkeyError.set(err.reason || "Failed to rename."); + } else { + instance._renamingCredentialId.set(null); + } + }); + }, + + "click button.passkey-rename-cancel"(event, instance) { + instance._renamingCredentialId.set(null); + }, + + "click button.passkey-remove"(event, instance) { + const credentialId = event.currentTarget.closest(".passkey-item").dataset.credentialId; + + if (!confirm("Remove this passkey? This cannot be undone.")) return; + + instance._passkeyError.set(null); + instance._passkeySuccess.set(null); + + Meteor.call("passkey.remove", credentialId, function (err) { + if (err) { + instance._passkeyError.set(err.reason || "Failed to remove."); + } else { + instance._passkeySuccess.set("Passkey removed."); + } + }); + }, +}); diff --git a/shell/imports/client/accounts/passkey/passkey-templates.html b/shell/imports/client/accounts/passkey/passkey-templates.html new file mode 100644 index 000000000..b24fa0ede --- /dev/null +++ b/shell/imports/client/accounts/passkey/passkey-templates.html @@ -0,0 +1,69 @@ + + + diff --git a/shell/imports/client/admin/login-providers.html b/shell/imports/client/admin/login-providers.html index d12d2d487..a43016ecf 100644 --- a/shell/imports/client/admin/login-providers.html +++ b/shell/imports/client/admin/login-providers.html @@ -467,6 +467,42 @@

{{_ (con txt "title")}}

{{/modalDialogWithBackdrop}} + +