Sign Users Up and In
A visitor of the shop creates an account, signs in, and from then on every request says who sends
it. Nothing of this is written by hand: the gateway stores passwords and issues tokens itself, and
serves the resources for it under /identity/. You declare which route creates an account.
Whoever sends a request is an identity: an id, and nothing more. The account, with the
customer’s name, is the shop’s own entity. The two are tied by having the same id.
§Before the first user
The gateway signs users in with tokens, and needs a key to issue them. It is the only setting without a default, and the Context of Create an Application already has it:
# context.toa.yaml
configuration:
identity.tokens:
keys:
- id: key0
key: $IDENTITY_TOKENS_ENCRYPTION_KEY0 On your machine npm run env generates the value into .env. For a deployment it is generated
with npx toa key and provided as a secret. Keep Users Signed In describes the key list.
§Sign up
The shop’s accounts are a component, accounts: an associated entity with the
customer’s name. Any application with users could use it, so it has no namespace: its directory
is components/accounts and its routes are under /accounts/. Create the manifest:
# components/accounts/manifest.toa.yaml
name: accounts
entity:
associated: true
properties:
name:
type: string The account holds no money. A customer’s balance is in the wallet, billing.wallets, which is kept
under the same id.
Give the account an operation that creates it, and mark the route that calls the operation with auth:incept, in the same manifest:
# components/accounts/manifest.toa.yaml
operations:
create:
type: transition
query: false
input:
type: object
properties:
name: { type: string }
required: [name]
exposition:
/:
POST:
auth:incept: id
io:input: [name]
io:output: [id, name]
endpoint: create // components/accounts/operations/create.ts
import type { CreateInput, State } from '../types/index.d.ts'
export async function transition (input: CreateInput, entry: State) {
entry.name = input.name
return entry
} $ npm run restart The value of auth:incept names the property of the operation’s output that holds the id of the
new account. The client sends the username and password the visitor chose as Basic credentials,
that is, base64(username:password):
POST /accounts/ HTTP/1.1
host: api.store.example
authorization: Basic Ym9iOnNlY3JldCMxMjM0
content-type: application/yaml
accept: application/yaml
name: Bob 201 Created
authorization: Token eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIi...
id: 2428c31ecb6e4a51a24ef52f0c4181b9
name: Bob The account exists, an identity with the same id exists, and the username and password belong to
it. The authorization header of the response carries a token: the client keeps it and sends it
with every later request, so the visitor is signed in already.
The route needs no other auth directive: auth:incept admits a request that has no identity yet.
§Sign in
There is no sign-in route to declare. Credentials may be sent with any request, and the request
that carries them is answered with a token. The usual first request is GET /identity/, which
exists in every application and answers who the caller is:
GET /identity/ HTTP/1.1
host: api.store.example
authorization: Basic Ym9iOnNlY3JldCMxMjM0
accept: application/yaml 200 OK
authorization: Token eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIi...
cache-control: no-store
id: 2428c31ecb6e4a51a24ef52f0c4181b9
roles: [] The client now has the id and a token, and continues with the token in place of the password:
GET /identity/ HTTP/1.1
host: api.store.example
authorization: Token eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIi... A wrong password, or a username nobody has, is refused:
401 Unauthorized Signing out is the client deleting its token. A client never stores the password.
§Reach the caller’s account
§By the id in the path
Put the account’s id into the path and guard the route with auth:id, which admits only the
identity with that id:
# components/accounts/manifest.toa.yaml
exposition:
/:id:
auth:id: id
io:output: [id, name]
GET: observe GET /accounts/2428c31ecb6e4a51a24ef52f0c4181b9/ HTTP/1.1
host: api.store.example
authorization: Token eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIi...
accept: application/yaml 200 OK
id: 2428c31ecb6e4a51a24ef52f0c4181b9
name: Bob The same request without credentials is answered 401 Unauthorized, and with the token of another
customer 403 Forbidden. The client knows the id from the reply to the sign-up or from GET /identity/. Restrict Who Can Call a Route applies the same directive to
orders.
§By an operation that is told who calls
When the client should not need the id, auth:delegate puts the caller’s identity into the
operation’s input:
# components/accounts/manifest.toa.yaml
operations:
echo:
type: computation
input:
type: object
properties:
identity:
type: object
properties:
id: { type: string }
roles: { type: array, items: { type: string } }
required: [id, roles]
exposition:
/echo:
GET:
auth:delegate: identity
io:output: [id, name, roles]
cache:control: no-store
endpoint: echo // components/accounts/operations/echo.ts
import type { Context, EchoInput } from '../types/index.d.ts'
export async function computation ({ identity }: EchoInput, context: Context) {
const account = await context.local.observe({ query: { id: identity.id } })
if (account instanceof Error)
return account
return { ...account, roles: identity.roles }
} GET /accounts/echo/ answers every caller with their own account. The reply differs per
caller at one URL, so the route tells caches not to store it. auth:delegate has the details.
§Guests
A visitor fills a cart before signing up. GET /identity/ without credentials gives such a
visitor an identity:
GET /identity/ HTTP/1.1
host: api.store.example
accept: application/yaml 201 Created
authorization: Token eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIi...
id: 332017649c814649b25ee466c1fe4534
roles: [] This identity is transient: it exists only as the token of the reply. The client keeps the token
and reaches routes guarded by auth:id with it, a cart at /store/carts/:customer/ for example.
When the client loses the token, the identity is gone.
A sign-up request without credentials works the same way. The account is created, and the reply carries the token of a transient identity with the account’s id:
POST /accounts/ HTTP/1.1
host: api.store.example
content-type: application/yaml
accept: application/yaml
name: Bob 201 Created
authorization: Token eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIi...
id: 2428c31ecb6e4a51a24ef52f0c4181b9
name: Bob A transient identity becomes a lasting one when a way to sign in is added to it: a password, an email address or a passkey. The id stays, so whatever the guest
owned is still theirs.
§Sign in or sign up with one request
auth:assert: true makes a route serve returning and new users alike: known credentials are used,
new ones create an identity.
# components/accounts/manifest.toa.yaml
exposition:
/echo:
GET:
auth:assert: true
auth:delegate: identity
io:output: [id, name, roles]
cache:control: no-store
endpoint: echo GET /accounts/echo/ HTTP/1.1
host: api.store.example
authorization: Basic bmV3OnBhJCR3MHJkIQ== Known credentials are answered 200, new ones 201 with an identity under a fresh id, and both
with a token.
auth:assertadmits nobody itself. Another directive on the route must: hereauth:delegate.- A request without credentials is answered
401. - The credentials are
BasicorBearer(a token of Google or Apple). - A wrong password for a known username is
401.
No account has to be created first: accounts is an associated entity, so echo answers a new identity with a blank account
under its id.
§An identity without an account
POST /identity/ creates an identity for new credentials and nothing else. It exists in every
application:
POST /identity/ HTTP/1.1
host: api.store.example
authorization: Basic Ym9iOnNlY3JldCMxMjM0
accept: application/yaml 201 Created
authorization: Token eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIi...
id: 7f1c5d0be0a54b2c9d2f4ec1a6b8e9aa
roles: [] It suits staff and scripts, which have no account in the shop. A route of your own does the same
with auth:incept: null.
§Test it
Scenarios drive sign-up and sign-in as a client does, with the steps toa create wrote; Write a Test explains them.
# features/accounts.feature
Feature: Accounts
Scenario: Signing up and signing in
When the request is sent:
"""
POST /accounts/ HTTP/1.1
authorization: Basic #{{ basic | set bob.basic }}
content-type: application/yaml
accept: application/yaml
name: Bob
"""
Then the response is received:
"""
201 Created
authorization: Token ${{ bob.token }}
id: ${{ bob.id }}
name: Bob
"""
When the request is sent:
"""
GET /accounts/${{ bob.id }}/ HTTP/1.1
authorization: Token ${{ bob.token }}
accept: application/yaml
"""
Then the response is received:
"""
200 OK
name: Bob
"""
When the request is sent:
"""
GET /identity/ HTTP/1.1
authorization: Basic ${{ bob.basic }}
accept: application/yaml
"""
Then the response is received:
"""
200 OK
id: ${{ bob.id }}
"""
Scenario: An account is read by its owner only
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:
"""
GET /accounts/${{ bob.id }}/ HTTP/1.1
"""
Then the response is received:
"""
401 Unauthorized
"""
When the request is sent:
"""
GET /accounts/${{ bob.id }}/ HTTP/1.1
authorization: Token ${{ mallory.token }}
"""
Then the response is received:
"""
403 Forbidden
""" $ npm run restart
$ npm run features #{{ basic | set bob.basic }} makes up a username and a password, encodes them and remembers the
result as bob.basic, so the scenario can run again without meeting its own user. ${{ bob.token }} in a response remembers what arrived there, and in a later request sends it.
A scenario that needs somebody signed in, and does not care how, starts with one of the two
requests above that take no credentials: the sign-up when it needs an account, GET /identity/ when an identity is enough.
§Where to go next
The articles follow the files; a shop usually needs them in this order:
- Keep Users Signed In: what the client does with the token.
- Restrict Who Can Call a Route: a customer reaches only their own orders.
- Give Staff Wider Access: staff reach all orders.
- Sign In with Google or Apple.
- Sign In with an Emailed Code or a Passkey.
- Let Another App Act for a User: third-party applications and AI clients.
Manage Passwords is for the account page, Serve Several Domains and Screen Requests for what is decided from the request itself, and the reference lists every resource, setting and status. Why identities are built this way is in the model.
§When signing up fails
Request to an auth:incept route | Reply |
|---|---|
| Credentials that already belong to an identity | 403 Forbidden |
| A known username with a wrong password, or a token that cannot be read | 401 Unauthorized |
A scheme other than Basic or Bearer | 401 Unauthorized |
| An identity that is banned | 401 Unauthorized |
Basic credentials that are not base64(username:password) | 422, code INVALID_CREDENTIALS |
| A username or password that breaks the rules | 422, code INVALID_USERNAME or INVALID_PASSWORD |
The first two are refused before the operation is called. The others are found after it ran, and the operation is not undone: the account exists, without an identity. Have the client check the username and password beforehand.
When the operation returns an error, or its output lacks the named property, no identity is created and the operation’s reply is sent as it is.
§Things to know
- One domain, one population of users. Usernames and tokens belong to the domain the request arrived at. A shop served on a single domain can ignore this; Serve Several Domains describes the rest.
- The last way to sign in can be removed. Nothing stops a user from deleting their only
password, provider or passkey, which leaves an identity nobody can sign in to. If accounts must
stay recoverable, have the client refuse; it learns what is left from
GET /identity/credentials/:identity/.