GizStack
View .mdOpen in ChatGPTOpen in Claude

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

TableHolds
usersA person: email, name, password hash and salt (empty when the app keeps no passwords), created, signed-out time, verified time
sessionsA session: the digest of its token, the person, the device label, created, last seen, expiry, revoked time
orgs, members, invitesA team, who is in it with which role, and invitations (stored as digests)
account_tokensA mailbox proof: a verification, a sign-in link or a password reset, each one use, with its own expiry
api_tokensAn API token (t. prefix, bearer), by digest, with its name, last characters, expiry and revoke
passkeysA passkey: its credential id, its COSE algorithm, its COSE public key and its signature counter
identitiesA provider's subject (provider and subject) linked to a person, with whether the link is active and the directory's profile
two_factorA person's authenticator secret (base32), the digest of their backup code, whether two-factor sign-in is on and the last step used
group_rolesA 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.

ComponentShowsForms it posts
SignInAn email (and a password, when password is on), an optional link button, and the providersaction, linkAction
AccountThe name, the address and whether it is verifieda rename, a reset (when resetAction is set), a sign-out
MembersThe members of a team with their roles; for a manager, the change, the removal and an invitationchangeAction, removeAction, inviteAction
TeamSwitcherThe current team and a menu of the teams, folded with <details>none (links)
TokensThe API tokens, and the fresh one oncecreateAction, revokeAction
DevicesThe open devices, with a revoke for each other device and a sign-out of allrevokeAction, 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.