# Accounts and sign-in with GizWeb

GizWeb's identity module, `Auth`, gives an app its people: accounts, sessions, mailbox
proofs, teams with roles, API tokens, devices, passkeys and sign-in with an external
provider. The library keeps the rules and the rows. Your app writes the routes and the
pages, and it passes the words and the actions to the components.

- Every record is a row in the app's database (GizDB or SQL). `Auth.install` makes the
  tables.
- A session is a row. The cookie holds an opaque id. The database keeps only its digest,
  so a stolen database does not sign anyone in.
- Passwords are an option. An app that does not keep them never stores a hash.
- The working example is `tests/web/auth/run.giz`. It runs every rule below against a
  GizDB folder.

## The tables

| Table | Holds |
| --- | --- |
| `users` | A person: email, name, password hash and salt (empty when the app keeps no passwords), created, signed-out time, verified time |
| `sessions` | A session: the digest of its token, the person, the device label, created, last seen, expiry, revoked time |
| `orgs`, `members`, `invites` | A team, who is in it with which role, and invitations (stored as digests) |
| `account_tokens` | A mailbox proof: a verification, a sign-in link or a password reset, each one use, with its own expiry |
| `api_tokens` | An API token (`t.` prefix, bearer), by digest, with its name, last characters, expiry and revoke |
| `passkeys` | A passkey: its credential id, its COSE algorithm, its COSE public key and its signature counter |
| `identities` | A provider's subject (provider and subject) linked to a person, with whether the link is active and the directory's profile |
| `two_factor` | A person's authenticator secret (base32), the digest of their backup code, whether two-factor sign-in is on and the last step used |
| `group_roles` | A directory group of a connection, mapped to a team and a role |

## Accounts and passwords

- `Auth.signUp(sys, db, email, name, password, now)` makes an account. An empty password
  makes an account that signs in by link, by passkey or by provider only.
- `Auth.signIn(db, email, password)` checks a password. A failed sign-in gives the same
  message whether the address exists or not.
- `Auth.linkFor(sys, db, email, purpose, now)` makes a one-use proof for `verify`, `login`
  or `reset`, and returns the raw value to send by mail. The database keeps its digest.
- `Auth.verifyMailbox`, `Auth.signInWithLink` and `Auth.resetPassword` use a proof. A reset
  also ends every session of the person.
- `Auth.insertPerson(db, person)` adds an account with the id it already has (an importer's
  principal id and its hash). The address is kept in lower case, and a taken one is refused.

## Sessions and devices

- `Auth.startSession(sys, db, person, label, now)` makes a session and returns its token.
  Set it as the cookie named `Auth.COOKIE` (`__Host-s`).
- `Auth.current(db, secret, request, now)` reads the cookie or a bearer token. It answers
  the person, or nothing for a session that is revoked, expired or signed out.
- `Auth.signOut(db, token, now)` ends this device. `Auth.signOutEverywhere(db, person, now)`
  ends all of them.
- `Auth.devicesOf(db, person, now)` lists the open sessions, newest first. A row names its
  session by `digest`, and `Auth.revokeDevice(db, person, digest, now)` ends one. Another
  person's digest changes nothing.
- A session that is not used for a while is still valid until its expiry. The last-seen
  time is updated at most once a minute.

## Teams and roles

`Auth.Teams` holds the rules of a team and `Auth.Gate` reads them. The roles are `owner`,
`manager`, `editor`, `viewer` and `commenter`. A manager invites people and changes the roles
of people who are not owners. Only an owner changes an owner, and a team keeps at least one
owner.

- `Auth.Teams.create(sys, db, person, name, reserved, now)` makes a team, with the founder as
  its owner.
- `Auth.Teams.invite(sys, db, actor, team, role, now)` returns the raw invitation once. The
  database keeps its digest. `Auth.Teams.join(db, person, raw, now)` accepts it, once.
- `Auth.Gate.allows(member, needed)` answers the one policy a route checks. Pass it the member row
  from `Auth.Teams.membership`, when the person is in the team.

## API tokens

- `Auth.newApiToken(sys, db, person, name, expires, now)` returns the token once. Show it
  with a `Tokens` component when `fresh` is set; the app never shows it again.
- `Auth.tokensOf(db, person)` lists the tokens. A row names its token by `digest`, and
  `Auth.revokeApiToken(db, person, digest, now)` revokes one.

## Passkeys

- `Auth.Passkey.challenge(sys, secret, now)` makes the challenge for a ceremony. It lasts five
  minutes and keeps no table: its time and random bytes are sealed with an HMAC under the app
  secret.
- `Auth.addPasskey(...)` checks an attestation (`none` or self-attested `packed`) and stores
  the key. `Auth.signInWithPasskey(...)` checks the assertion and refuses a signature counter
  that does not grow.
- Three algorithms are accepted: EdDSA on Ed25519 (-8), ES256 on P-256 (-7) and RS256 (-257).
  A key of another type is refused at registration.
- The browser side (the call to `navigator.credentials`) is the app's script. The `SignIn`
  component has no passkey button yet.

## Sign-in with a provider

`Auth.OAuth` runs the authorization code flow with PKCE (S256), a one-use state and an
OpenID nonce.

- `OAuth.start(sys, secret, provider, callback, next)` returns the URL to send the browser to
  and the cookie to set with `OAuth.setCookie`. The cookie is signed with the app secret and
  lasts ten minutes.
- On the callback, `OAuth.flowOf(secret, cookie, provider, state)` checks the cookie and the
  state. The cookie is single use: clear it whatever the answer.
- `OAuth.exchange(sys, provider, callback, code, flow)` exchanges the code on the server and
  returns the `Identity` the provider vouches for. A provider with an ID token (OpenID
  Connect) gives its subject and verified email in that token. GitHub gives them from its
  profile and its primary verified email.
- `Auth.signInWithIdentity(sys, db, identity, now)` finds the person. A linked subject is its
  person. Otherwise a verified account of the address is linked; an unverified one is taken
  over, as the mailbox owner, with its password and sessions ended; a new address makes an
  account. The link is kept, so a person can sign in with several providers.

The provider's endpoints and client are the app's configuration. `OAuth.Provider` holds them.

An enterprise SSO connection is a provider of its own, named `Auth.ssoProvider(connection)`
(`sso:` and the connection id). The directory's sync calls `Auth.linkSso(db, connection,
subject, person, email, active, profile, now)` for each user. A link that is not active
refuses sign-in, so a user the directory removed cannot sign in with it. The profile is kept as
JSON text.

Directory groups give team roles. `Teams.mapGroup(db, connection, group, team, role)` maps a
group to a role an invitation can give (never owner). `Teams.syncGroups(db, person, connection,
groups, now)` puts the person in the mapped teams. A group only raises a role, never lowers one,
and never changes an owner, so a group removed from the directory keeps its roles.

## Two-factor sign-in

`web/auth/totp.giz` is RFC 6238 with HMAC-SHA1, six digits and thirty-second steps, the codes
that common authenticator apps make.

- `start(sys, db, person, now)` makes a new secret, in base32, for the app to show. It is off
  until `confirm(db, person, code, now)` checks a code of it. A person who has it on must turn
  it off first.
- A code is accepted once per step: a code of a step that was used already is refused.
- `newBackupCode(sys, db, person)` returns a backup code once; only its digest is kept, and it
  signs in once. `disable(db, person)` turns the sign-in off.
- `challenge(key, person, now)` makes the second step's challenge, good for five minutes;
  `complete(db, key, challenge, code, now)` checks the code or the backup code and returns the
  person.

The routes and the second page are the app's. `Auth.signIn` checks the password only.

## OpenID Connect issuer

`web/auth/oidc.giz` is the issuer of this app for OpenID Connect: the code flow with PKCE (S256),
an ID token (ES256), an access token and a refresh token that rotates on each use. The config
is the JSON file that `GIZSTACK_OIDC` names; `OidcConfig` is its shape. The app reads it once with
`Oidc.load(sys, path)`, which also reads the key file the config names (the greatest `kid` signs,
and every key is published).

- `Oidc.discovery(issuer)` and `Oidc.jwks(issuer)` are the documents the app serves at
  `/.well-known/openid-configuration` and `/.well-known/jwks.json`.
- `Oidc.authorize(sys, db, issuer, ask, now)` makes a one-use code for the person the app has
  signed in. It returns the redirect address with the code. An unknown client or redirect
  address is refused, and nothing is sent to that address.
- `oidcToken(sys, db, issuer, req, now)` answers the token request: the `authorization_code` or the
  `refresh_token` grant. A public client (`secretDigest` "") sends no secret; every client uses PKCE.
- `oidcUserinfo(db, token, now)` reads the claims of an access token from `oidcToken`.

## Carried sessions

A refresh session of the earlier system moves as the SHA-256 (hex) of its refresh cookie, not
the cookie. `importCarried(db, refreshToken, person, now, expires)` keeps one. At a request that
carries the `refreshToken` cookie, `adoptRefresh(sys, db, refreshToken, now)` gives a new session
of the person, once: the row is used up in the same step. The caller sets the session cookie and
clears `refreshToken`. A person signed out after the import gets nothing.

## Sub-modules are imported by file, for now

`Auth.Teams`, `Auth.Gate`, `Auth.OAuth`, `Auth.Passkey` and `Auth.Totp` are exported from
`web/auth/main.giz`, but a checked program cannot reach them through `Auth` yet: the compiler
does not resolve a namespace that is re-exported with `export * as`. Until that is fixed, import
the file, as `tests/web/auth/run.giz` does (`web/auth/team.giz`, `web/auth/gate.giz`,
`web/auth/oauth.giz`, `web/auth/passkey.giz`, `web/auth/totp.giz`). The functions are the same.

## Components

Each component renders with its words from the caller, and needs no script. A form posts to
the action the app gives it. A manager sees the controls of a team, and a viewer does not.

| Component | Shows | Forms it posts |
| --- | --- | --- |
| `SignIn` | An email (and a password, when `password` is on), an optional link button, and the providers | `action`, `linkAction` |
| `Account` | The name, the address and whether it is verified | a rename, a reset (when `resetAction` is set), a sign-out |
| `Members` | The members of a team with their roles; for a manager, the change, the removal and an invitation | `changeAction`, `removeAction`, `inviteAction` |
| `TeamSwitcher` | The current team and a menu of the teams, folded with `<details>` | none (links) |
| `Tokens` | The API tokens, and the fresh one once | `createAction`, `revokeAction` |
| `Devices` | The open devices, with a revoke for each other device and a sign-out of all | `revokeAction`, `signOutAction` |

The components take their styles from `ui.css` (the chat tokens), so they match the rest
of the app's pages.

## Not yet

- The passkey browser script and a sign-in button for it.
- The second page of two-factor sign-in, and a `SignIn` step for it.
- Removing a person from a team when their group leaves the directory.
- `Auth.wrap`, which would connect the routes of these rules to an app in one call. Until
  then an app writes its own routes, as `tests/web/auth/run.giz` does.
- Moving an existing GizStack account store to this module. That is GizStack's migration.
