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.installmakes 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 forverify,loginorreset, and returns the raw value to send by mail. The database keeps its digest.Auth.verifyMailbox,Auth.signInWithLinkandAuth.resetPassworduse 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 namedAuth.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 bydigest, andAuth.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 fromAuth.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 aTokenscomponent whenfreshis set; the app never shows it again.Auth.tokensOf(db, person)lists the tokens. A row names its token bydigest, andAuth.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 (noneor self-attestedpacked) 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. TheSignIncomponent 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 withOAuth.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 theIdentitythe 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 untilconfirm(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)andOidc.jwks(issuer)are the documents the app serves at/.well-known/openid-configurationand/.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: theauthorization_codeor therefresh_tokengrant. A public client (secretDigest"") sends no secret; every client uses PKCE.oidcUserinfo(db, token, now)reads the claims of an access token fromoidcToken.
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
SignInstep 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, astests/web/auth/run.gizdoes.- Moving an existing GizStack account store to this module. That is GizStack's migration.