Osy#betaRun it on Osyrin: osyrin.com

Reference / Security

Passkeys — sign in with the device, no password and no code

use Osysharp.UserAccounts@1; · StartPasskeySignIn() · SignInWithPasskey(token, answer) · AddPasskey(…) · RemovePasskey(…) · RemovePassword(…) · MyPasskeys()

Every app on the UserAccounts kit signs people in with passkeys: a key pair their phone, laptop or security key makes for the app and unlocks with a PIN, a fingerprint or a face. A passkey is both steps at once, so it needs no password and no code, and a look-alike page cannot collect it. An account may hold several, name and remove them, and turn password sign-in off once it has one; a lost device is recovered by another passkey or by the reset link.

preview1 example compiled by CIsecurityauthenticationkit

Summary#

use Osysharp.UserAccounts; gives an app passkeys with nothing more to write: "Sign in with a passkey" on the sign-in page wherever the browser can make one, a Passkeys section on the account page (add, name, remove), "Set up a passkey instead" for an account the app requires two-step sign-in of, and "Turn off password sign-in" once an account has a passkey. The flows are ordinary functions, so an app with a sign-in page of its own calls them itself.

Signature#

StartPasskeySignIn() -> PasskeyStart                                  // { Token, Options } for Passkey.Get
SignInWithPasskey(<string> token, <string> answer) -> SignInStep      // a ticket — or MustVerify
AddPasskey(<string> email, <string> password = "", <string> code = "", <string> passkeyToken = "",
           <string> passkeyResponse = "", <string> enrolment = "", <string> addToken = "",
           <string> addResponse = "", <string> name = "") -> PasskeyAdding
RemovePasskey(<string> email, <string> credentialId, <string> password = "", <string> code = "",
              <string> passkeyToken = "", <string> passkeyResponse = "") -> string
RemovePassword(<string> email, <string> passkeyToken, <string> passkeyResponse) -> string   // a fresh ticket
RenamePasskey(<string> credentialId, <string> name)                   // as the signed-in account
MyPasskeys() -> PasskeysFacts                                         // { Passkeys, PasswordOff }

Description#

Why is a passkey both steps at once?#

A passkey is something the person HAS (the device holding the private key, which never leaves it) and something they ARE or KNOW (what unlocks it). The kit asks the device to check the person every time — user verification is required, not preferred — and refuses an answer that says it did not. So a passkey sign-in answers a ticket with no code after it, even for an account the app requires two-step sign-in of (AccountsSetup.SecondFactor): it replaces the password and the authenticator app together.

How does a sign-in go?#

StartPasskeySignIn() mints a challenge and answers the options for the browser; Passkey.Get(options) asks the device, which offers every passkey it holds for the app — nobody types an address — and answers what it signed, or "" when the person closed the prompt; SignInWithPasskey(token, answer) checks it and answers the step: a ticket for Session.SignIn, or MustVerify when the app requires a proven address and this one is not.

How does an account add one?#

In two halves, as the browser needs. The first AddPasskey call takes proof that it is the account's owner — the password (and a code, with two-step sign-in on), or one of its passkeys (passkeyToken + passkeyResponse, a confirmation StartPasskeySignIn began), or the enrolment token a sign-in that answered MustEnrol carried — and answers the options for Passkey.Create. The second call, with addToken and the browser's answer, keeps it under name and mails the owner. For an enrolment it also answers the ticket: the passkey IS the second step that sign-in was waiting for.

What may never be removed?#

The last way in. A passkey is removed only while the account keeps a password, another passkey or a linked provider, and the password only while it keeps a passkey. Removing takes the same proof adding does.

What does turning password sign-in off do?#

RemovePassword REMOVES the password — confirmed with one of the account's passkeys, which proves at the same moment that the account can sign in without it. With no password stored, nothing that knows only passwords can sign the account in: the app's own sign-in, the command line's, an operator's import. Every other session ends. MyPasskeys() says PasswordOff, and the account page says so in the Password section.

What if every device is lost?#

Another passkey signs in — which is why an account may hold several (the Mac, the phone, a hardware key in a drawer). With none left, "Forgot your password?" mails a link that sets a password again and turns password sign-in back on, told by mail; the account then signs in as usual, with its second step if it has one.

What holds?#

  • Every answer is checked against a challenge the sign-in flow minted, kept as its hash, alive five minutes, and spent by the first answer that comes back, right or wrong; an add's challenge is never a sign-in's.
  • The browser's answer must be for this app — the origin and the host of App.BaseUrl — and the device must have checked the person.
  • A signature counter that does not move forward is refused; synced passkeys report zero every time, which is not a regression.
  • A passkey is one account's: its credential id is unique in the app, and its user handle must be its account's.
  • Only the sign-in flow writes a passkey's key, id, account and counter; its owner may rename it and read only its own.
  • A stranger who signed up first with somebody's address loses their passkey the moment the owner proves the address, with everything else they set up.

Where does it work?#

Wherever the page is a secure context — https://, or http://localhost and its subdomains in development — on the app's own address. A passkey is made for the host it was made on, so one made on acme.osyrin.app is not offered on help.acme.com. Passkey.Supported() is false where it cannot work, and the kit's buttons hide.

Examples#

A sign-in button of your own#

app Desk {
  use Osysharp.Ui;
  use Osysharp.Identity;
  use Osysharp.Mail;
  use Osysharp.UserAccounts;
  use Osysharp.EmailTemplates;
  model "model/**/*.osy";
}

using Osysharp.Identity;
using Osysharp.UserAccounts;

[Role] enum AppRole { Authenticator, Member }

app.Accounts = new AccountsSetup { AppName = "Harbour Desk", SignupRole = AppRole.Member };

[Route("/quick")] [AllowAnonymous] [Render(CSR)]
component QuickSignIn() {
  bool supported = false;
  string problem = "";
  on mount { supported = Passkey.Supported(); }

  action UsePasskey() {
    problem = "";
    try {
      var start = StartPasskeySignIn();
      var answer = Passkey.Get(start.Options);
      if (answer == "") { return; }            // the person closed the prompt
      var step = SignInWithPasskey(start.Token, answer);
      if (step.Ticket != "") { Session.SignIn(step.Ticket, "/"); }
    }
    catch (Exception ex) { problem = ex.Message; }
  }

  render {
    Stack(gap: 3) {
      if (supported) { Button("Sign in with a passkey", onPress: UsePasskey); }
      else { Text("This browser cannot use passkeys."); }
      if (problem != "") { Text(problem); }
    }
  }
}

[Route("/")] component Home() { render { Text("The desk"); } }

See also#

Related

The UserAccounts kit — sign-in done properly, once

`Osysharp.UserAccounts` is the default way into an app: sign-in with a password or a provider (Google, GitHub, …)…

WebAuthn.* and Passkey.* — the two halves of a passkey

A passkey (W3C Web Authentication) has a server half and a browser half. `WebAuthn.*` runs on the server: it makes the…

auth bootstrap (login, before anyone is signed in)

Under deny-all, login faces a paradox: it must read a user row *before* anyone is authenticated. `app.AuthBootstrap`…

Totp.NewSecret, Totp.Uri, Totp.Verify, Totp.Code

Authenticator-app codes (RFC 6238 — the six-digit, thirty-second codes every authenticator app shows), which is what a…