Accounts and sign-in

Everyone who signs in to the site has an account: customers who register, and the editors and admins who run the site. This chapter shows how people sign up and sign in, how to add a “Continue with Google” or “Continue with Pocket ID” button, and how to decide who gets into the admin. By the end you will have people signing in with a password and, if you want it, with Google or Pocket ID, and you will know who can reach the admin.

Accounts need nothing to be installed. A site always has them.

Roles

Every account has an email address, a name and one of three roles:

  • customer is what registering makes. Customers use the site's own pages: sign in, register, forgotten password, reset password and account. You edit those pages under Content → System, like any other page.
  • editor works on content, the catalogue and forms in the admin at /admin.
  • admin can do all of that and also sees Orders (with the shop), Users and Settings. When the public API or an AI assistant is switched on, admins also see API tokens.

A customer who signs in at /admin is told the admin is not for them and sent back to the site. Registering never grants a staff role; an admin does that.

Add the people who run the site

  1. In the admin, open Users and choose Add user.
  2. Enter a Name and an Email, and pick the Role.
  3. Tick “Send an invitation email” and choose Create. The account starts without a password, and the email gives the person a link to choose one. Sending it needs a real email provider (Going live). Without one, use the command below, or open the person under Users later and send a password reset.

You can also make an account from the command line, in your site's folder. This sets the password straight away, and it is the way in when you have no admin yet. Use --role editor or --role customer for the other roles:

pnpm cms user:create-admin --email you@example.com --password 'choose-something-long'

On a server that runs Docker, put docker compose run --rm app in front of the command, as in Run it on a server.

Open a person under Users to edit their name or email, change their Access (the role), send a password reset or a confirmation email, or choose “Mark as confirmed” for an address you know is theirs.

Let visitors register

Public sign-up is off by default, so the register form and the link to it show nothing until you turn it on. Sign-in itself works either way, and so does every account an admin makes.

  1. In the admin, open Settings → Users.
  2. Under Sign-up, tick “Allow sign-ups” and save.

A visitor who registers is sent a confirmation link, and the account signs in once they click it. “Forgotten your password?” on the sign-in form sends a reset link. Customers change their name, email and password on their account page, and a changed email is confirmed at the new address before the account moves.

Sign-in, registration and the reset request each run a small proof-of-work check in the visitor's browser, plus a CSRF token, so bots have little to work with. Nothing is shown to the visitor. Where the check cannot run, such as a plain-http address on your local network, add security: { captcha: false } to site.config.ts.

Guest accounts

With the shop, a guest checkout creates an account for the email address used. It has no password and the address is unconfirmed, so the order has an owner to come back to. The page after payment offers to set a password straight away, and a reset link does the same later. Until a password is set, the order opens from the link in its emails.

Sign-in providers

A sign-in provider lets people use an account they already have instead of a new password. Each one adds a button such as “Continue with Google”. The button appears on both sign-in pages: the site's sign-in form, and the admin's sign-in page, which is also the page Claude or ChatGPT sends you to when you connect it.

Each provider is its own package, and you install only the ones you use. They are listed under auth.providers in site.config.ts. With none listed, people sign in with an email address and a password. The setup for Google comes first, then Pocket ID, and “What happens when someone uses a button” explains what a visitor gets after pressing one.

Sign in with Google

  1. Install the Google provider in your site's folder:
pnpm add @jamiemcconnell/cms-auth-google
  1. List it in site.config.ts:
site.config.ts
import { google } from '@jamiemcconnell/cms-auth-google';

export default defineConfig({
  // …
  auth: { providers: [google()] },
});
  1. Create an OAuth client in Google Cloud. This is the record that tells Google which website may ask people to sign in. Google renames its menus now and then, so if a label below differs, look for the nearest match.
    1. Open the Google Cloud Console, go to APIs & Services → Credentials, and choose or create a project.
    2. If the project has no OAuth consent screen, set one up. Choose External, or Internal if everyone who signs in belongs to your own Google Workspace. Give it an app name, a support email and a developer contact email. The site only asks for the person's name and email address.
    3. Choose Create credentials → OAuth client ID, and set the application type to Web application.
    4. Add an authorised redirect URI: your CMS_SITE_URL followed by /api/auth/callback/google, such as https://example.com/api/auth/callback/google. Add one for each address you sign in from, such as http://127.0.0.1:4321/api/auth/callback/google for development. Copy each exactly: the scheme, the host, the port, and no trailing slash.
    5. Create the client and copy its client ID and client secret.
    6. Leave the consent screen in Testing while you try it; only the test users you list can sign in. Choose Publish app when you are ready for everyone. With only the name and email scopes, Google does not need to review the app first.
  2. Put the two values in the site's .env:
.env
GOOGLE_CLIENT_ID=your-client-id
GOOGLE_CLIENT_SECRET=your-client-secret
  1. Restart the site and open /admin. “Continue with Google” is under the password form. A site that lists google() without both variables does not start; the error names the missing ones, because a button that cannot work is never shown.

If your secrets live under other names, say so in the config: google({ clientIdEnv: 'MY_GOOGLE_ID', clientSecretEnv: 'MY_GOOGLE_SECRET' }).

Options

  • hostedDomain restricts sign-in to one Google Workspace domain, as in google({ hostedDomain: 'example.com' }). Use '*' for any Workspace domain. Personal Google accounts are refused either way.
  • prompt sets what Google asks the person. select_account, the account chooser, is the default; consent, login and none are the others.

Sign in with Pocket ID or another OpenID provider

A self-hosted identity provider such as Pocket ID, which signs people in with passkeys, works like Google. So do Keycloak, Authentik, Authelia and Zitadel, which all speak OpenID Connect. There are two differences. You tell the site where your provider lives, because it has no fixed address. And the provider can optionally decide who is an editor or an admin.

  1. Install the package:
pnpm add @jamiemcconnell/cms-auth-oidc
  1. List the provider in site.config.ts. Pocket ID has a preset:
site.config.ts
import { pocketId } from '@jamiemcconnell/cms-auth-oidc';

export default defineConfig({
  // …
  auth: { providers: [pocketId()] },
});

Any other provider is named by you, as in oidc({ id: 'keycloak', label: 'Keycloak' }). The id is lowercase letters, digits and dashes. It becomes part of the callback address and of the environment variable names below. The label is what the button says: “Continue with Keycloak”. You can list Google and several OpenID providers together; their buttons follow the order of the list.

  1. Create a client in Pocket ID. A client is the record that tells the provider which website may ask people to sign in. Other providers have the same idea under another name.
    1. In Pocket ID, sign in as an administrator, open Administration → OIDC Clients and choose Add OIDC Client.
    2. Give it a name, such as your site's name.
    3. Under Callback URLs, add your CMS_SITE_URL followed by /api/auth/callback/pocket-id, such as https://example.com/api/auth/callback/pocket-id. In the admin, Settings → Users → Sign-in providers shows the exact address for every listed provider, with a Copy button. For another provider the last part is its id. Add one callback for each address you sign in from, copied exactly, with no trailing slash.
    4. Keep PKCE on. Leave the client confidential if you will use a secret, which is the usual choice for a site with a server.
    5. Save, then copy the client ID and the client secret.
  2. Put the provider's address and the client's details in the site's .env. The issuer is the address you browse to Pocket ID at:
.env
OIDC_POCKET_ID_ISSUER=https://id.example.com
OIDC_POCKET_ID_CLIENT_ID=your-client-id
OIDC_POCKET_ID_CLIENT_SECRET=your-client-secret

The names follow the provider's id in capitals, with dashes turned into underscores. For keycloak they are OIDC_KEYCLOAK_ISSUER, OIDC_KEYCLOAK_CLIENT_ID and OIDC_KEYCLOAK_CLIENT_SECRET. Three details:

  • The issuer must be an https address with no path after the host, unless the provider uses one (Keycloak does: https://keycloak.example.com/realms/main). Plain http is accepted for localhost and 127.0.0.1 only, for development.
  • The client secret is optional. Leave it out for a public client that relies on PKCE alone.
  • A missing variable, or an issuer that is not https, stops the site starting with ADAPTER_ENV_MISSING or CONFIG_INVALID, naming it. Those are mistakes in the setup, so the site refuses to run without the sign-in you asked for.
  1. Restart the site and open /admin. “Continue with Pocket ID” is under the password form. The person needs a Pocket ID account, and the rules in “What happens when someone uses a button” decide what happens next.

What happens when someone uses a button

Whichever provider you use, the same rules apply when someone presses its button:

  • On the site, an address the site has never seen becomes a customer account, already confirmed because the provider vouches for the address. An address it knows signs in to that account, including a guest account, so a guest who comes back through a provider finds their orders.
  • In the admin, only existing accounts can sign in. An unknown address is refused, saying there is no account for it, and no customer account is made. Create the editor or admin first, using the address the provider reports.
  • An account registered with a password but never confirmed is not linked. Otherwise whoever registered the address first could end up inside its owner's session. Sign in with the password first, or choose “Mark as confirmed” under Users.
  • Public sign-up still applies. A newcomer becomes a customer through a provider only while “Allow sign-ups” is on.

Go further with an OpenID provider

The settings below are for Pocket ID and the other OpenID providers. Google has no equivalent.

Which email addresses count as confirmed

A provider says whether it has checked an email address with the email_verified claim in the token it returns. The site treats an address as confirmed only when the provider says exactly true. That matters for linking onto an existing account, for being allowed in as staff, and for a new account starting out confirmed.

Pocket ID marks the addresses of users an administrator creates by hand as not verified, including your own. Until that changes you would sign in as a customer. There are two ways round it:

  • Add trustEmail: true to the provider, as in pocketId({ trustEmail: true }). Every address from this provider then counts as confirmed. Use it only when you are the only person who can create accounts there. Never use it with a provider that lets anyone register with any address.
  • Or get Pocket ID to report the addresses as verified, and leave trustEmail off. Look for an email-verified option on the user or in its settings, which varies between versions.

Let the provider's groups decide who is staff

Without roles, everyone who signs in through the provider is an ordinary account, and you set your editors and admins under Users. With roles, the provider decides:

site.config.ts
pocketId({
  trustEmail: true,
  roles: { admin: ['cms-admins'], editor: ['cms-editors'] },
});

In Pocket ID, open Administration → User Groups, create groups with those names, and add people to them. Use each group's name, which is what appears in the token, not its friendly name. The site asks for the groups scope by itself once roles is set. To read the groups from a differently named claim, write roles: { claim: 'your-claim', … }; a dotted name such as realm_access.roles (Keycloak) reaches into the object. At least one of admin and editor is needed.

  • The first sign-in creates staff. Someone in a mapped group who has no account yet gets one with that role. This is the one exception to the admin refusing unknown addresses. Someone in no mapped group is treated as before.
  • Every sign-in updates the role. It becomes the highest the groups grant, admin over editor. Leave every mapped group and the next sign-in makes you a customer. Group names must match exactly, and only the groups you list ever grant anything.
  • It needs a trusted address. A person whose address the provider does not vouch for gets no staff role from their groups.
  • Local admins are left alone. An editor or admin with a password who has never been managed by this provider is never changed by it, so signing in through it cannot demote them.
  • The admin shows who decides. Under Users, an account whose role comes from the provider says “Role managed by Pocket ID” and its role is locked. Choose Change anyway to override it until the next sign-in. Every change of role is written to the audit log.
  • Staff sessions are shorter. Because a role is only as fresh as the last sign-in, staff whose role is managed are signed out after the staff session length and sign in again. It is set under Settings → Users → Staff sessions, from 1 to 90 days, and is 7 by default. The same limit ends the connection of any AI assistant they authorised.

Only one provider in the list may set roles, staffOnly or autoRedirect. The site refuses to start with two.

Make the provider the only way in for staff

Add staffOnly: true and editors and admins must use the provider. A password sign-in by one of them is refused with AUTH_PROVIDER_REQUIRED, and the form tells them to use the button. A wrong password still gets the usual “wrong password” message, so the form never reveals which addresses belong to staff. Customers are unaffected.

The provider is now critical to getting in, so keep a way back:

  • Set CMS_AUTH_LOCAL_STAFF=1 in .env and restart. Staff can use their passwords again while it is set, and every such sign-in is written to the audit log. Remove it when the provider is back.
  • docker compose run --rm app pnpm cms user:create-admin still works on a server, because it writes to the database directly.

Skip the password form

Add autoRedirect: { admin: true, oauth: true } and a signed-out visitor to /admin, or to the page where Claude or ChatGPT connects, goes straight to the provider. Add ?local=1 to the address, as in /admin?local=1, to see the password form anyway. Signing out of the admin lands there too, so you are not carried straight back in. The site's own /login page always shows its form with the button.

If the provider is down

A provider that cannot be reached does not stop the site. The site reads the provider's discovery document when it starts. If there is no answer, it logs oidc provider unreachable with the reason, hides that provider's button, and tries again about once a minute. Settings → Users → Sign-in providers shows the provider as Unreachable. A sign-in that names it meanwhile is refused with AUTH_PROVIDER_UNREACHABLE. When the provider answers again the button comes back without a restart.

With staffOnly on, staff passwords stay refused during an outage, so an outage cannot be used to get round the provider. Use CMS_AUTH_LOCAL_STAFF=1 as above.

Change the words around the buttons

Buttons are added for you. Every listed provider gets one on each sign-in screen, in list order, under the password form. There is nothing to switch on in the editor, and pages built before you added a provider show its button without being edited. The Sign-in form block on the site's sign-in page holds the words, so you can change them in your own voice:

  • Social label, “Continue with {provider}”.
  • Social divider, the rule between the password form and the buttons.
  • Social error text, shown when a sign-in through a provider fails.
  • Staff provider text, shown when staff must use a provider.

{provider} becomes the provider's name. The admin's sign-in page, which is also the one Claude and ChatGPT use, has fixed English.

When something goes wrong

  • Google says “Error 400: redirect_uri_mismatch”, or another provider says the redirect address is not allowed. The address registered with the provider is not the one the site sent. Compare it character by character with Settings → Users → Sign-in providers: scheme, host, port, no trailing slash. CMS_SITE_URL must be the address you really browse to. Changes at Google can take a few minutes to apply.
  • The site will not start and the error names a variable. A listed provider is missing one of its variables. Set it in .env and restart.
  • The admin says there is no account for the address. The admin only accepts existing accounts. Create the user first under Users, or add the person to a mapped group.
  • I signed in but I am a customer, not an admin. The address is not confirmed (see above), or you are not in a mapped group. Check that the provider reports email_verified as true or that trustEmail is on, that the group's name matches the one in roles exactly, and that the groups claim is in the token. Roles are only updated at sign-in, so sign in again after fixing it.
  • Google says “Access blocked”. The consent screen is in Testing and your address is not a listed test user, or the client belongs to a different project. Add yourself as a test user, or publish the app.
  • I am locked out. Set CMS_AUTH_LOCAL_STAFF=1, restart, and sign in with a password; pnpm cms user:create-admin makes an admin if you have none. If autoRedirect keeps sending you away, use /admin?local=1.
  • The button does not appear. The provider is not in auth.providers, the site was not restarted, the provider is unreachable, or the page is cached.

Run it on a server lists the environment variables, and Troubleshooting covers problems that are not about sign-in.