IV Users and Access

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:assert admits nobody itself. Another directive on the route must: here auth:delegate.
  • A request without credentials is answered 401.
  • The credentials are Basic or Bearer (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:

  1. Keep Users Signed In: what the client does with the token.
  2. Restrict Who Can Call a Route: a customer reaches only their own orders.
  3. Give Staff Wider Access: staff reach all orders.
  4. Sign In with Google or Apple.
  5. Sign In with an Emailed Code or a Passkey.
  6. 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 routeReply
Credentials that already belong to an identity403 Forbidden
A known username with a wrong password, or a token that cannot be read401 Unauthorized
A scheme other than Basic or Bearer401 Unauthorized
An identity that is banned401 Unauthorized
Basic credentials that are not base64(username:password)422, code INVALID_CREDENTIALS
A username or password that breaks the rules422, 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/.
Search
Search the documentation