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:
| Property | Meaning |
|---|---|
authority | Required. The authority the code works in. |
username | Required, 1 to 256 characters: an email address, a phone number, any string that names the user. |
identity | An identity id to tie the username to once the code is confirmed. Such a code does not sign in. |
lifetime | Seconds 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:
| Code | Reason |
|---|---|
EXPIRED | The code is wrong, used, expired or issued for another identity. |
TOO_MANY_ATTEMPTS | Too many wrong codes for the address; see Limits. |
EXISTS | The 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 | Key | Meaning | Default |
|---|---|---|
algorithms | Accepted COSE algorithm ids, offered as pubKeyCredParams. | [-7, -257] |
timeout | Milliseconds the user has to answer a challenge. | 60000 |
residence | residentKey of the authenticator selection: required, preferred or discouraged. | required |
verification | userVerification: 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:
| Code | Reason |
|---|---|
FAILED | The answer did not verify: an unknown or expired challenge, another origin, a bad signature. |
INVALID | The answer verified as not valid. |
MISS | Signing 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
"""