IV Users and Access

Sign In with an Emailed Code or a Passkey

A customer types their email address, receives a six-digit code and is in. Or they confirm with a fingerprint, and the device does the rest. Neither needs a password, and both end like every other sign-in: with a token in the response.

§Emailed codes

The gateway makes and checks the codes. Delivering a code is the shop’s job: an operation asks for a code and sends it by email, SMS or whatever reaches the user.

§Send a code

Add an operation to accounts that gets a code for an address from identity.otp.issue and emails it, and a route that anyone can call:

# components/accounts/manifest.toa.yaml
operations:
  otp:
    type: effect
    input:
      type: object
      properties:
        authority: { type: string }
        email: { type: string, format: email }
        identity: { type: string }
      required: [authority, email]

exposition:
  /otp:
    POST:
      auth:anonymous: true
      map:authority: authority
      io:input: [email]
      io:output: false
      endpoint: otp
// components/accounts/operations/otp.ts
import type { Context, OtpInput } from '../types/index.d.ts'

export async function effect ({ authority, email, identity }: OtpInput, context: Context) {
  const otp = await context.remote.identity.otp.issue({
    input: { authority, username: email, identity }
  })

  if (otp instanceof Error)
    return otp

  return await context.remote.notifications.send({
    input: { to: email, text: `Your code is ${otp.code}` }
  })
}

notifications.send is the shop’s operation that sends an email; Call an External Service writes it. map:authority supplies the authority the request arrived at; a code works only there. identity is used further down.

$ npm run restart

The client asks for a code:

POST /accounts/otp/ HTTP/1.1
host: api.store.example
content-type: application/yaml

email: bob@example.com
201 Created

The route is open to everyone and sends mail, so limit how often it can be called: Input and Output describes throttling.

The input of identity.otp.issue:

PropertyMeaning
authorityRequired. The authority the code works in.
usernameRequired, 1 to 256 characters: an email address, a phone number, any string that names the user.
identityAn identity id to tie the username to once the code is confirmed. Such a code does not sign in.
lifetimeSeconds this code is valid, 1 to 3600. Defaults to the configured lifetime.

The output is { code }, a string of six digits.

§Sign in with the code

The client sends the address and the code the user typed as base64(username:code) under the OTP scheme:

GET /identity/ HTTP/1.1
host: api.store.example
authorization: OTP Ym9iQGV4YW1wbGUuY29tOjEyMzQ1Ng==
accept: application/yaml
200 OK
authorization: Token eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIi...

id: 6f3d2c1b0a9e4f8d7c6b5a4e3d2c1b0a
  • An address is the same identity every time. The first sign-in of an address nobody has seen creates the identity.
  • A code works once. A second request with it, a wrong code and an expired one are all answered 401 Unauthorized.

§Make an address a way into an account

Bob signed up with a password, or as a guest, and now confirms his email. A code issued with identity is for that: once Bob gives it back, the address is tied to his identity, and from then on signing in with the address is signing in as Bob.

# components/accounts/manifest.toa.yaml
exposition:
  /:id:
    /otp:
      POST:
        auth:id: id
        map:authority: authority
        map:segments:
          identity: id
        io:input: [email]
        io:output: false
        endpoint: otp

The same otp operation serves both routes; here map:segments passes the id from the path as identity, and auth:id makes sure only Bob can tie an address to Bob.

POST /accounts/2428c31ecb6e4a51a24ef52f0c4181b9/otp/ HTTP/1.1
host: api.store.example
authorization: Token eyJhbGciOiJkaXIi...
content-type: application/yaml

email: bob@example.com
201 Created

The same request with another customer’s token is answered 403 Forbidden.

Issuing the code ties nothing: whoever asks for it may name an address that is not theirs. Bob reads the code in his mail and the client sends it back with his token:

PUT /identity/otp/2428c31ecb6e4a51a24ef52f0c4181b9/ HTTP/1.1
host: api.store.example
authorization: Token eyJhbGciOiJkaXIi...
content-type: application/yaml

username: bob@example.com
code: '493817'
204 No Content

Only Bob may send it, and the code works only for the identity it was issued with, once. Such a code does not sign in: a sign-in with it is refused, and the code stays for this request. A refusal is 422 with a code:

CodeReason
EXPIREDThe code is wrong, used, expired or issued for another identity.
TOO_MANY_ATTEMPTSToo many wrong codes for the address; see Limits.
EXISTSThe address is a way into another identity already. The code is spent.

§Move a sign-in to another device

An identity can get a code for itself over HTTP, with nothing declared:

POST /identity/otp/2428c31ecb6e4a51a24ef52f0c4181b9/ HTTP/1.1
host: api.store.example
authorization: Token eyJhbGciOiJkaXIi...
accept: application/yaml
201 Created

code: '493817'

The username of such a code is the identity’s id: the other device signs in with OTP base64(<identity id>:<code>) and is that identity. One device shows the code, the other takes it.

The request must come from the identity itself or from one with the role system:identity:otp, which may get a code for anyone.

§Limits

# context.toa.yaml
configuration:
  identity.otp:
    lifetime: 300 # seconds a code is valid; 1 to 3600
    attempts: 5   # wrong codes allowed per username within `lifetime`

The values shown are the defaults. After attempts wrong codes for one username, every code for it is refused, the right one included, until lifetime has passed since the first failure. A successful sign-in resets the count.

Every refusal is 401 for the client. The gateway’s log names the reason: EXPIRED for a code that is wrong, used or expired, TOO_MANY_ATTEMPTS, INVALID_CREDENTIALS for credentials that are not base64(username:code).

§Test it

A scenario has no mailbox, so it checks the emailed flow up to the request and the refusals, and signs in with a code where the code comes back over HTTP. Its steps are the ones toa create wrote, which Write a Test explains, and its first request goes to the sign-up route:

# features/codes.feature
Feature: Sign-in codes

  Scenario: A code is sent to an address only for the caller's own account
    When the request is sent:
      """
      POST /accounts/ HTTP/1.1
      content-type: application/yaml
      accept: application/yaml

      name: Bob
      """
    Then the response is received:
      """
      201 Created

      id: ${{ bob.id }}
      """
    When the request is sent:
      """
      GET /identity/ HTTP/1.1
      accept: application/yaml
      """
    Then the response is received:
      """
      201 Created
      authorization: Token ${{ mallory.token }}
      """
    When the request is sent:
      """
      POST /accounts/${{ bob.id }}/otp/ HTTP/1.1
      authorization: Token ${{ mallory.token }}
      content-type: application/yaml

      email: mallory@example.com
      """
    Then the response is received:
      """
      403 Forbidden
      """

  Scenario: A wrong code
    When the request is sent:
      """
      GET /identity/ HTTP/1.1
      authorization: OTP Ym9iQGV4YW1wbGUuY29tOjAwMDAwMA==
      """
    Then the response is received:
      """
      401 Unauthorized
      """

  Scenario: Moving a sign-in to another device
    When the request is sent:
      """
      GET /identity/ HTTP/1.1
      accept: application/yaml
      """
    Then the response is received:
      """
      201 Created
      authorization: Token ${{ phone.token }}

      id: ${{ laptop.username }}
      """
    When the request is sent:
      """
      POST /identity/otp/${{ laptop.username }}/ HTTP/1.1
      authorization: Token ${{ phone.token }}
      accept: application/yaml
      """
    Then the response is received:
      """
      201 Created

      code: '${{ laptop.password }}'
      """
    When the request is sent:
      """
      GET /identity/ HTTP/1.1
      authorization: OTP #{{ basic laptop }}
      accept: application/yaml
      """
    Then the response is received:
      """
      200 OK

      id: ${{ laptop.username }}
      """
$ npm run restart
$ npm run features

#{{ basic laptop }} encodes laptop.username and laptop.password as base64(username:password), which is the form OTP credentials have too; the scenario remembers the identity’s id and the code under those names.

§Passkeys

A passkey is a key that the user’s device holds and unlocks with a fingerprint, a face or a PIN. The browser’s WebAuthn API does the work on the device; the gateway serves the other side under /identity/passkeys/. Nothing is declared for it.

Every step is two requests: ask for a challenge, then send what the device produced. Between them the client calls navigator.credentials.create() or navigator.credentials.get().

A passkey belongs to the web origin it was made on and works only from there. The gateway takes the origin from the origin header, which a browser sends by itself.

§Add a passkey to an identity

Ask for a creation challenge for the identity. Send no credentials with challenge requests.

POST /identity/passkeys/challenges/2428c31ecb6e4a51a24ef52f0c4181b9/ HTTP/1.1
host: api.store.example
content-type: application/yaml
accept: application/yaml

type: creation
201 Created

identity: 2428c31ecb6e4a51a24ef52f0c4181b9
challenge: 5Yb0mHcFqv3s0xN6a8sU0c1B6Jm9Zl2kq7R4tW8yE1o
timeout: 60000
pubKeyCredParams:
  - type: public-key
    alg: -7
  - type: public-key
    alg: -257
authenticatorSelection:
  residentKey: required
  requireResidentKey: true
excludeCredentials: []

These are the options for navigator.credentials.create(), except rp and user, which the client adds: its own name and id as the relying party, and the user’s name with identity as the user id. excludeCredentials lists the passkeys the identity already has.

Send the result, serialized as JSON, with a label for the user to recognize the passkey by:

POST /identity/passkeys/2428c31ecb6e4a51a24ef52f0c4181b9/ HTTP/1.1
host: api.store.example
origin: https://store.example
authorization: Token eyJhbGciOiJkaXIi...
content-type: application/json
accept: application/yaml

{
  "label": "Personal iPhone",
  "id": "Xk3…",
  "type": "public-key",
  "response": {
    "attestationObject": "o2NmbXRk…",
    "clientDataJSON": "eyJ0eXBlIjoi…",
    "authenticatorData": "SZYN5YgO…",
    "publicKey": "MFkwEwYH…",
    "publicKeyAlgorithm": -7,
    "transports": ["internal", "hybrid"]
  },
  "authenticatorAttachment": "platform",
  "clientExtensionResults": {}
}
201 Created

id: 54ed430804324e81b39756ea4708b546
aid: adce0002-35bc-c60a-648b-0b25f1f05503
synced: true
label: Personal iPhone
CREATED: 1754899200000

The body is what PublicKeyCredential.toJSON() returns, plus label (required, 1 to 64 characters). This request carries credentials: only the identity itself may add a passkey.

§Sign up with a passkey

A new user has no identity to put into the path. Ask for a creation challenge without one:

POST /identity/passkeys/challenges/ HTTP/1.1
host: api.store.example
content-type: application/yaml
accept: application/yaml

type: creation
201 Created
authorization: Token eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIi...

identity: 8a7b6c5d4e3f4a2b9c1d0e9f8a7b6c5d
challenge: 3nD0y…
timeout: 60000
…

The reply makes a guest identity: identity is its id and the authorization header carries its token. The client runs navigator.credentials.create() and sends the result to POST /identity/passkeys/8a7b6c5d4e3f4a2b9c1d0e9f8a7b6c5d/ with that token, as above. Once the passkey is stored, the identity lasts.

When signing up must also run the shop’s create operation, sign the visitor up without credentials first and add the passkey to the identity that reply returns.

§Sign in with a passkey

Ask for a request challenge:

POST /identity/passkeys/challenges/ HTTP/1.1
host: api.store.example
content-type: application/yaml
accept: application/yaml

type: request
201 Created

challenge: Qm9v…
timeout: 60000
allowCredentials: []

Pass it to navigator.credentials.get(); the user picks a passkey. Send the result, again without credentials:

POST /identity/passkeys/ HTTP/1.1
host: api.store.example
origin: https://store.example
content-type: application/json
accept: application/yaml

{
  "id": "Xk3…",
  "type": "public-key",
  "response": {
    "authenticatorData": "SZYN5YgO…",
    "clientDataJSON": "eyJ0eXBlIjoi…",
    "signature": "MEUCIQ…",
    "userHandle": "MjQyOGMz…"
  },
  "clientExtensionResults": {}
}
201 Created
authorization: Token eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIi...

identity: 2428c31ecb6e4a51a24ef52f0c4181b9

The token in the header is the identity’s; the client continues with it.

To offer only the passkeys of a known identity, ask for the challenge at POST /identity/passkeys/challenges/:identity/ with type: request; allowCredentials then lists them.

§List and remove passkeys

GET /identity/passkeys/2428c31ecb6e4a51a24ef52f0c4181b9/ HTTP/1.1
host: api.store.example
authorization: Token eyJhbGciOiJkaXIi...
accept: application/yaml
- id: 54ed430804324e81b39756ea4708b546
  aid: adce0002-35bc-c60a-648b-0b25f1f05503
  synced: true
  label: Personal iPhone
  CREATED: 1754899200000
DELETE /identity/passkeys/2428c31ecb6e4a51a24ef52f0c4181b9/54ed430804324e81b39756ea4708b546/ HTTP/1.1
host: api.store.example
authorization: Token eyJhbGciOiJkaXIi...
204 No Content

Both are open to the identity alone. aid and synced are explained where all ways to sign in are listed.

§Settings

# context.toa.yaml
configuration:
  identity.passkeys:
    algorithms: [-7, -257]
    timeout: 60000
    residence: required
    verification: preferred
KeyMeaningDefault
algorithmsAccepted COSE algorithm ids, offered as pubKeyCredParams.[-7, -257]
timeoutMilliseconds the user has to answer a challenge.60000
residenceresidentKey of the authenticator selection: required, preferred or discouraged.required
verificationuserVerification: required, preferred or discouraged. With required, an answer without user verification is refused.none

§When a passkey is refused

A challenge is answered once and within its timeout. The second request of a step is refused with 422 and a code:

CodeReason
FAILEDThe answer did not verify: an unknown or expired challenge, another origin, a bad signature.
INVALIDThe answer verified as not valid.
MISSSigning in with a passkey nobody registered under this authority.

A challenge request that carries credentials is answered 403, and so is a sign-in request that does.

A scenario cannot play the device’s part, so it checks what surrounds it: that a challenge is handed out, and to whom.

# features/passkeys.feature
Feature: Passkeys

  Scenario: A visitor asks for a challenge to sign up with
    When the request is sent:
      """
      POST /identity/passkeys/challenges/ HTTP/1.1
      content-type: application/yaml
      accept: application/yaml

      type: creation
      """
    Then the response is received:
      """
      201 Created
      authorization: Token ${{ visitor.token }}

      challenge: ${{ challenge }}
      identity: ${{ visitor.id }}
      """
    When the request is sent:
      """
      POST /identity/passkeys/challenges/ HTTP/1.1
      authorization: Token ${{ visitor.token }}
      content-type: application/yaml

      type: creation
      """
    Then the response is received:
      """
      403 Forbidden
      """
Search
Search the documentation