IV Users and Access

Manage Passwords

The shop wants passwords of at least twelve characters, a sign-up form that says at once that a username is taken, an account page where a customer changes the password and sees the ways they can sign in, and a limit on guessing. All of it is configuration and requests to resources the gateway already serves under /identity/basic/.

§Set the rules for usernames and passwords

# context.toa.yaml
configuration:
  identity.basic:
    username:
      - ^S{3,64}$
    password:
      - ^.{12,64}$
      - d

Each key holds a list of regular expressions, and a value must match every one of them. Without the setting, a username is 1 to 128 characters and a password 8 to 32. The Context is read when the application is restarted:

$ npm run restart

A request to the sign-up route with a password that is too short is refused:

POST /accounts/ HTTP/1.1
host: api.store.example
authorization: Basic Ym9iOnNob3J0
content-type: application/yaml
accept: application/yaml

name: Bob
422 Unprocessable Entity

code: INVALID_PASSWORD

A username that breaks the rules is refused with the code INVALID_USERNAME. A password may contain colons.

§Tell whether a username is free

GET /identity/basic/usernames/Ym9i/ HTTP/1.1
host: api.store.example

The last segment is the username, Base64 URL encoded. The reply is 204 No Content when the username is taken and 404 Not Found when it is free. Send no credentials with it.

§Change the password

PATCH /identity/basic/2428c31ecb6e4a51a24ef52f0c4181b9/ HTTP/1.1
host: api.store.example
authorization: Basic Ym9iOnNlY3JldCMxMjM0
content-type: application/yaml

password: new-secret#5678
200 OK

The body holds username, password or both. The request is authenticated with the current password: have the user type it. A token is not enough:

403 Forbidden

Basic authentication scheme is required to access this resource
  • The identity must be the one in the path, or hold the role system:identity:basic. Anyone else is answered 403.
  • A value that breaks the rules is answered 422.
  • The change signs the user out on every device: tokens issued before it stop working within refresh seconds, and custom tokens of the identity are revoked.

§Add a password to an identity

A guest, or a user who signed in with Google or a passkey, adds a username and password to their identity:

POST /identity/basic/332017649c814649b25ee466c1fe4534/ HTTP/1.1
host: api.store.example
authorization: Token eyJhbGciOiJkaXIi...
content-type: application/yaml

username: alice
password: secret#1234
201 Created

Any credentials of that identity admit the request. An identity has one username and password: a second POST is answered 422 with the code EXISTS.

§Remove the password

DELETE /identity/basic/332017649c814649b25ee466c1fe4534/ HTTP/1.1
host: api.store.example
authorization: Token eyJhbGciOiJkaXIi...
204 No Content

A new one can be added afterwards.

§List the ways to sign in

The account page shows what the user can sign in with:

GET /identity/credentials/2428c31ecb6e4a51a24ef52f0c4181b9/ HTTP/1.1
host: api.store.example
authorization: Token eyJhbGciOiJkaXIi...
accept: application/yaml
basic:
  username: bob
federation:
  - id: 09200d0bcc2448c5863e3f50ae18af84
    iss: https://accounts.google.com
    CREATED: 1754899200000
passkeys:
  - id: 54ed430804324e81b39756ea4708b546
    aid: adce0002-35bc-c60a-648b-0b25f1f05503
    synced: true
    label: Personal iPhone
    CREATED: 1754899200000

Only the identity itself may read it. basic is null when there is no password; federation and passkeys are always lists. Nothing secret is returned.

  • federation[].iss is the provider. Map issuers to names and logos in the client.
  • passkeys[].aid identifies the model of the authenticator (its AAGUID), which a client can look up in a catalog to show a name and an icon. synced says the passkey is backed up and likely available on more than one device.
  • Each id is what the DELETE routes of providers and passkeys take.

Tokens and emailed codes are not listed.

§Limit password guessing

Failed sign-ins can be counted per client address, whatever the way of signing in. It is off until the Context asks for it:

# context.toa.yaml
exposition:
  ip: cf-connecting-ip
  bouncer:
    attempts: 20
    interval: 60

An address may fail attempts times in a row and earns them back at attempts per interval seconds. With nothing left, a request that carries credentials is answered:

429 Too Many Requests
retry-after: 30
  • attempts defaults to 20 and interval to 60; bouncer: {} takes both.
  • bouncer requires ip, the name of the header the client address is read from. See the client address.
  • A request without credentials is not counted, and neither is one whose address is unknown.

§Test it

The steps are the ones toa create wrote, which Write a Test explains. The background signs a customer up at the sign-up route.

# features/passwords.feature
Feature: Passwords

  Background:
    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 }}
      """

  Scenario: The password is changed with the current password
    When the request is sent:
      """
      PATCH /identity/basic/${{ bob.id }}/ HTTP/1.1
      authorization: Token ${{ bob.token }}
      content-type: application/yaml

      password: new-secret#5678
      """
    Then the response is received:
      """
      403 Forbidden
      """
    When the request is sent:
      """
      PATCH /identity/basic/${{ bob.id }}/ HTTP/1.1
      authorization: Basic ${{ bob.basic }}
      content-type: application/yaml

      password: new-secret#5678
      """
    Then the response is received:
      """
      200 OK
      """
    When the request is sent:
      """
      GET /identity/ HTTP/1.1
      authorization: Basic ${{ bob.basic }}
      """
    Then the response is received:
      """
      401 Unauthorized
      """

  Scenario: A password that breaks the rules is refused
    When the request is sent:
      """
      PATCH /identity/basic/${{ bob.id }}/ HTTP/1.1
      authorization: Basic ${{ bob.basic }}
      content-type: application/yaml

      password: short
      """
    Then the response is received:
      """
      422 Unprocessable Entity
      """
$ npm run restart
$ npm run features

§An identity with a password and no account

POST /identity/basic/ creates an identity from a username and a password in the body. It takes no credentials:

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

username: carol
password: secret#1234
201 Created

id: 9c1f0a6d5b7e4f3a8d2c1b0a9e8f7d6c

The reply carries no token; the client signs in next with GET /identity/. A customer signs up through the shop’s own sign-up route, which creates the account as well.

ReplyWhen
201 CreatedThe identity is created.
409 ConflictThe username is taken.
422, code INVALID_USERNAMEThe username breaks the rules.
422, code INVALID_PASSWORDThe password breaks the rules.

§The first administrator

A new deployment has no users, so nobody can give anybody a role. Name the username whose identity receives the system role the moment it is created:

# context.toa.yaml
configuration:
  identity.basic:
    principal:
      authority: local
      username: root

authority is the name of the domain the username is created under; local is the name the generated Context gives localhost:8000. Create it with POST /identity/ or POST /identity/basic/, and continue with Give Staff Wider Access. The principal’s username cannot be changed afterwards: the attempt is answered 422 with the code PRINCIPAL_LOCKED.

§How passwords are stored

Passwords are hashed with bcrypt. Two settings:

# context.toa.yaml
configuration:
  identity.basic:
    rounds: 10
    pepper: $IDENTITY_BASIC_PEPPER

pepper is a secret: write it as a $NAME reference and provide the value as Configuration describes. Set it before the first user signs up, and do not change it: stored passwords are checked with it.

§Reference

§identity.basic

KeyMeaningDefault
usernameRegular expressions a username must match, all of them.^.{1,128}$
passwordRegular expressions a password must match, all of them.^.{8,32}$
roundsbcrypt cost, 4 to 31.10
pepperA secret mixed into every hash.none
principalauthority and username of the identity that is given system.none

§Ways to authenticate a request

authorization: <Scheme> <credentials>
SchemeCredentialsSee
Basicbase64(username:password)Sign Users Up and In
Tokena token the gateway issuedKeep Users Signed In
Beareran id_token of a trusted provider, or a token the gateway issuedGoogle or Apple, other apps
Codean authorization code of a trusted providerGoogle or Apple
OTPbase64(username:code)emailed codes

The scheme name is not case-sensitive. A passkey has no scheme: it signs in through a request of its own.

Whatever the scheme, the response carries a Token, and the client continues with that. Whatever the scheme, a failure looks the same: credentials that are malformed, unknown, wrong or expired leave the request without an identity, and a route that needs one answers 401 Unauthorized. So does a scheme the gateway does not know.

Search
Search the documentation