Skip to content

feat(auth): sign in with a passkey, and manage passkeys in settings - #107

Merged
fullynocturnal merged 3 commits into
mainfrom
feat/passkeys
Oct 24, 2026
Merged

fullynocturnal merged 3 commits into
mainfrom
feat/passkeys

Conversation

@fullynocturnal

@fullynocturnal fullynocturnal commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator

Adds the client side of the passkey support that shipped in server 1.7.0.

What changes

  • Sign-in. When GET /v1/auth/config reports passkeys_available, the sign-in form gets a "Sign in with a passkey" button under the password one. It solves the login captcha first when the instance asks for one, runs the discoverable ceremony (no email), and hands the tokens to the same success path as a password login, so email verification, the watch handoff and push registration all behave the same. Backing out of the system sheet returns to the form with no error.
  • Settings, Account, Passkeys. Shown only when the instance offers passkeys. Add (password, plus the 2FA or a recovery code when the account has 2FA, and an optional name), rename, and remove. Removal asks for nothing, matching the server. A key whose usable is false says it was made under a previous address of the instance and needs re-adding.
  • The copy says plainly that the password keeps working and that System Safety still asks for it, since that is the part of the design most likely to surprise someone.

Distribution split

The ceremony goes through androidx.credentials. On Android 14+ that uses the platform provider in the core library; below 14 it needs credentials-play-services-auth, which is proprietary. That dependency is playImplementation only, the same way FCM is handled, so:

  • .play: passkeys from Android 9.
  • .open: passkeys on Android 14+. Older devices get "Passkeys aren't supported on this device" instead of a crash or a silent no-op.

R8 keep rule for the Play Services provider added to proguard-rules.pro (it is loaded by reflection).

Raw JSON passthrough

The options from begin and the credential sent to complete are opaque to the app and are carried byte for byte through a small RawJson Moshi adapter (nextSource / valueSink). Decoding them as Map<String, Any?> would turn every integer into a double, so alg: -7 would reach the authenticator as -7.0 and be rejected.

Errors

Credential Manager failures map to short messages: no passkey on this device, this device already has one for the account, no provider. The SecurityError case, which is what a missing or wrong assetlinks.json produces, says the operator needs to add the app to PASSKEY_ANDROID_APPS, because self-hosters are the people who will hit it and the platform's own message does not point there.

Server failures go through toUserMessage(preferDetail = true) from #106, since every passkey refusal (expired challenge, duplicate key, lockout, suspension) comes with a detail written for the person in front of it, and that also gets the technical-errors register for free. The step-up 401 in the add dialog keeps prompt wording ("Incorrect password", "Enter your two-factor code").

Server-side requirements for testing

  • PASSKEYS_ENABLED=true (off by default in 1.7.0).
  • PASSKEY_ANDROID_APPS listing the package and signing fingerprint of the build under test. Debug builds are signed with the local debug keystore, so testing one needs that fingerprint added too (|-separated).

Not in this PR

  • The wear app. It has no sign-in surface of its own to put a passkey on.

Verification

  • compileOpenDebugKotlin, compilePlayDebugKotlin, and minifyPlayReleaseWithR8 / minifyOpenReleaseWithR8 pass.
  • testOpenDebugUnitTest passes, including new RawJsonAdapterTest (integers survive the round trip; the credential is embedded as an object, not a string) and PasskeysViewModelTest (an instance without passkeys never lists them; a new key is sent with the device's credential and joins the list; cancelling the sheet is not an error and never calls complete; a wrong password stays in the dialog; a 409 shows the server's own sentence).
  • Tested on a live instance running commit sheaf-project/sheaf@018a60a

Uses the passkey API added in server 1.7.0. The login screen offers
"Sign in with a passkey" when /v1/auth/config reports passkeys_available,
and Settings > Account gains a Passkeys screen to add (password, plus the
2FA or recovery code when 2FA is on), rename and remove them.

The ceremony goes through androidx.credentials. Its Play Services
provider is proprietary, so only the play flavour carries it; the open
flavour gets passkeys on Android 14+, where the platform provider is
built in.

WebAuthn options and credentials are passed through as raw JSON. Moshi's
Map decoding turns integers into doubles (alg -7 becomes -7.0), which the
authenticator rejects.
The passkey screens had their own status-to-sentence table and read the
error body with string(), which consumes it. Now that toUserMessage reads
the server's detail and honours the technical-errors setting, failures go
through it with preferDetail, since every passkey refusal comes with a
detail that says what to do next. The step-up 401 keeps its prompt wording.
@fullynocturnal
fullynocturnal merged commit 78481bc into main Oct 24, 2026
1 check passed
@fullynocturnal
fullynocturnal deleted the feat/passkeys branch October 24, 2026 00:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant