Read as markdown — one file, no markup — this is the machine version of the page · По-русски · Guide for humans
ZERNO Sign-in — agent instructions
Contract version: 8. Single sign-in for a website over OpenID Connect. The agent gets a zerno_login brief with the keys from a human and does the integration itself.
You were handed a JSON brief named zerno_login. Read this document in full, do the integration, then run the "Verify" section and report back.
1. Addresses
issuer https://id.zerno.one
discovery https://id.zerno.one/.well-known/openid-configuration
authorize https://id.zerno.one/oauth/v2/authorize
token https://id.zerno.one/oauth/v2/token
userinfo https://id.zerno.one/oidc/v1/userinfo
logout https://id.zerno.one/oidc/v1/end_session
scope the scope field from the brief — paste it whole, do not append anything
2. Environment variables
The names are fixed by the contract — do not rename them.
OIDC_ISSUER=<issuer from the brief>
OIDC_CLIENT_ID=<client_id from the brief>
OIDC_CLIENT_SECRET=<client_secret from the brief>
OIDC_REDIRECT_URI=<redirect_uri from the brief>
OIDC_SCOPE=<the whole scope from the brief>
ZERNO_SECRET=<generate one: openssl rand -base64 32>
ZERNO_SECRET is the only variable that is not in the brief: the site uses it to protect its own session cookie (the @zerno/next package encrypts it, hand-written recipes sign it). If the project already has AUTH_SECRET, use that.
3. Rules
- Sign-in is plain OpenID Connect. Do not write your own login form and do not create a password table.
- Take endpoint addresses from discovery (https://id.zerno.one/.well-known/openid-configuration), not from this text.
- The secret lives only in .env or in the deployment secret store. Never commit it, never ship it in the client bundle. The variable is OIDC_CLIENT_SECRET.
- The redirect_uri in your code must match the one issued in the dashboard character for character, including scheme, port and trailing slash.
- The user identifier is the sub claim. Email is not an identifier: people change it.
- ZERNO_SECRET you generate yourself and store with the rest of the secrets. It is not in the brief and cannot be: it is the site's key, not the provider's.
- Take scope from the brief whole and put it in OIDC_SCOPE. Do not shorten it to "openid email profile": it carries the site's organisation, and without it the sign-in form shows default methods instead of the ones switched on in the dashboard.
- Redirect addresses are set when the key is issued and cannot be appended to an existing client. Production is redirect_uri, local testing is redirect_uri_dev. If you test on localhost, put redirect_uri_dev into OIDC_REDIRECT_URI and restore the production one before deploying. If redirect_uri_dev is missing from the brief, do not invent it and do not reuse the production one: ask the human to re-issue the key with the "I will test sign-in on localhost" option.
- Do not go looking for keys on the machine and do not substitute tokens from elsewhere. Everything you need is in the brief. If something is missing, or this document is unreachable, stop and tell the human exactly what is missing — do not improvise a recipe on our behalf.
- If the site already has its own users, link them by email once and store the sub from then on.
- Do not build an intermediate "click here to sign in" page. The person already clicked "Sign in" — there is nothing to ask twice: the sign-in link goes straight to the provider, and the branded form is waiting there.
- There is exactly one source of session. If the project already has one (Auth.js, Django auth, Devise), keep it: sign-in supplies the identity, the session is issued by whatever the site already carries. Never stack a second auth library next to it, in either direction.
- Switch old sign-in methods off on the server, not just off the form. A page with no button but a live provider behind it is not a disabled method, it is a hidden one; close the public sign-up and OTP endpoints too.
- Show the person errors that come back from the provider. Silently sending them back to the provider is a loop they cannot leave and cannot understand.
- Store the identifier in your database through the project's normal migration. Editing the schema behind the migrations' back works on your machine and breaks the deploy where the schema is applied by migrations.
4. Recipes
Next.js (App Router) (nextjs)
Install: npm i @zerno/next
Redirect URI shape: https://<domain>/api/auth/callback
app/api/auth/[...zerno]/route.ts
import { zernoHandlers } from "@zerno/next";
// Sign-in, callback and sign-out are one route. It also serves a callback
// with a tail (`/api/auth/callback/zerno`), if such an address is registered.
export const { GET, POST } = zernoHandlers();
app/layout.tsx
import { auth, getLoginBadge, getLoginMethods } from "@zerno/next";
import { ZernoProvider } from "@zerno/next/client";
export default async function RootLayout({ children }: { children: React.ReactNode }) {
// The list of methods comes from the server: switch one on in the dashboard
// and the button appears without a deploy. A hard-coded list leads to errors.
// The "sign-in by ZERNO" line under the buttons comes from there too — on a
// paid plan there is none, and then it is simply null.
const [{ user }, methods, badge] = await Promise.all([
auth(),
getLoginMethods(),
getLoginBadge(),
]);
return (
<html lang="ru">
<body>
<ZernoProvider user={user} methods={methods} badge={badge}>
{children}
</ZernoProvider>
</body>
</html>
);
}
components/AuthButtons.tsx
"use client";
import { SignIn, UserButton, useUser } from "@zerno/next/client";
export function AuthButtons() {
return useUser() ? <UserButton /> : <SignIn />;
}
middleware.ts
import { zernoMiddleware } from "@zerno/next/middleware";
// The list of protected paths is the only decision the site makes here.
export default zernoMiddleware({ protect: ["/app", "/settings"] });
export const config = { matcher: ["/((?!_next|favicon.ico).*)"] };
- The
@zerno/nextpackage already does PKCE, thestatecheck, cookie encryption, token refresh and route protection. Do not hand-write those next to it and do not add a second auth library — there is no such thing as two sources of session. - OIDC_REDIRECT_URI is the site's full external address. All redirects are built from it: behind a proxy in a container
request.urlshows the internal host, and after a successful sign-in the person lands on 0.0.0.0:3000. Do not assemble redirects from the request address. - The redirect address is not bent to fit the code: the route is a catch-all and serves both
/api/auth/callbackand an already registered/api/auth/callback/zerno. - Who signed in:
await auth()on the server,useUser()on the client. Do not parse the cookie — the provider's tokens sit inside it, encrypted. - Sign-out is POST only.
<UserButton />renders it as a form; for your own button use<SignOutButton />. As a link, sign-out fires from the browser's prefetcher and from someone else's page with an<img src>. - Copy OIDC_SCOPE from the brief whole: its tail carries the site's organisation, and without it the sign-in form shows the default methods.
- ZERNO_SECRET is a long random string (
openssl rand -base64 32) that encrypts the session cookie. If the project already has AUTH_SECRET, that works. - Test sign-in through
next dev. A production build sends the cookie with the Secure flag, and over http://localhost the browser will not store it — sign-in looks broken for no reason. - Do not build or keep your own login-and-password form — sign-in lives entirely on the provider's side.
- Link your own user to the
subfrom the session. Email is not an identifier.
Django + Authlib (django)
Install: pip install authlib
Redirect URI shape: https://<domain>/auth/callback
settings.py
import os
AUTHLIB_OAUTH_CLIENTS = {
"zerno": {
"client_id": os.environ["OIDC_CLIENT_ID"],
"client_secret": os.environ["OIDC_CLIENT_SECRET"],
"server_metadata_url": os.environ["OIDC_ISSUER"]
+ "/.well-known/openid-configuration",
# The whole scope from the brief: it carries the site's organisation.
"client_kwargs": {"scope": os.environ["OIDC_SCOPE"]},
}
}
accounts/views.py
import os
from authlib.integrations.django_client import OAuth
from django.contrib.auth import get_user_model, login
from django.shortcuts import redirect
oauth = OAuth()
oauth.register("zerno")
def login_start(request):
return oauth.zerno.authorize_redirect(request, os.environ["OIDC_REDIRECT_URI"])
def login_callback(request):
token = oauth.zerno.authorize_access_token(request)
claims = token["userinfo"]
User = get_user_model()
# sub is the only stable identifier. An email address can change.
user, _ = User.objects.get_or_create(
username=claims["sub"],
defaults={"email": claims.get("email", "")},
)
login(request, user)
return redirect("/")
urls.py
from django.urls import path
from accounts import views
urlpatterns = [
path("auth/login", views.login_start),
path("auth/callback", views.login_callback),
]
- OIDC_REDIRECT_URI must match the address issued in the dashboard character for character.
- Passwords in the user model stay empty — nobody checks them any more.
Node.js / Express + openid-client (express)
Install: npm i openid-client express-session
Redirect URI shape: https://<domain>/auth/callback
auth.js
import { Issuer, generators } from "openid-client";
const issuer = await Issuer.discover(process.env.OIDC_ISSUER);
const client = new issuer.Client({
client_id: process.env.OIDC_CLIENT_ID,
client_secret: process.env.OIDC_CLIENT_SECRET,
redirect_uris: [process.env.OIDC_REDIRECT_URI],
response_types: ["code"],
});
export function loginStart(req, res) {
const verifier = generators.codeVerifier();
req.session.verifier = verifier;
res.redirect(
client.authorizationUrl({
scope: process.env.OIDC_SCOPE,
code_challenge: generators.codeChallenge(verifier),
code_challenge_method: "S256",
}),
);
}
export async function loginCallback(req, res) {
const params = client.callbackParams(req);
const tokens = await client.callback(process.env.OIDC_REDIRECT_URI, params, {
code_verifier: req.session.verifier,
});
req.session.user = tokens.claims();
res.redirect("/");
}
- The session cookie must be httpOnly + secure + sameSite=lax.
- code_verifier is kept in the session between the two requests, otherwise the code exchange fails.
PHP + league/oauth2-client (php)
Install: composer require league/oauth2-client
Redirect URI shape: https://<domain>/auth/callback.php
auth.php
<?php
require 'vendor/autoload.php';
session_start();
$issuer = getenv('OIDC_ISSUER');
$provider = new League\OAuth2\Client\Provider\GenericProvider([
'clientId' => getenv('OIDC_CLIENT_ID'),
'clientSecret' => getenv('OIDC_CLIENT_SECRET'),
'redirectUri' => getenv('OIDC_REDIRECT_URI'),
'urlAuthorize' => 'https://id.zerno.one/oauth/v2/authorize',
'urlAccessToken' => 'https://id.zerno.one/oauth/v2/token',
'urlResourceOwnerDetails' => 'https://id.zerno.one/oidc/v1/userinfo',
'scopes' => explode(' ', getenv('OIDC_SCOPE')),
]);
if (!isset($_GET['code'])) {
header('Location: ' . $provider->getAuthorizationUrl());
$_SESSION['oauth2state'] = $provider->getState();
exit;
}
if (empty($_GET['state']) || $_GET['state'] !== ($_SESSION['oauth2state'] ?? null)) {
unset($_SESSION['oauth2state']);
exit('state mismatch');
}
$token = $provider->getAccessToken('authorization_code', ['code' => $_GET['code']]);
$_SESSION['user'] = $provider->getResourceOwner($token)->toArray();
header('Location: /');
- Checking state is mandatory — without it sign-in is open to CSRF.
- The addresses come from the discovery document; if the provider changes them, read discovery rather than these lines.
WordPress (the «ZERNO ID sign-in» plugin) (wordpress)
Install: https://zerno.one/pkg/zerno-id-wp-1.0.1.zip
Redirect URI shape: https://<domain>/wp-login.php?action=zerno-callback
wp-config.php
<?php
// Optional. The plugin works without these lines.
// Kill switch: the plugin turns off entirely and the normal password login
// works again. Needed if sign-in is misconfigured and you are locked out.
// define('ZERNO_ID_DISABLE', true);
// Strict mode: the password stops being a door for administrators as well.
// Without it the "disable passwords" switch spares whoever edits the site —
// otherwise a wrong setting locks the owner out of their own site.
// define('ZERNO_ID_STRICT', true);
- Install the plugin from the site admin: «Plugins → Add New → Upload Plugin» and the zip at the address above. Neither a repository nor ssh access is needed — a site on shared hosting usually has neither.
- Nothing to write here: the sign-in client is a ready plugin. It does PKCE, the state check, the code exchange, userinfo and the logout at the provider. Do not add a second auth library next to it and do not rewrite wp-login.php.
- Keys go into «Settings → ZERNO sign-in», not into environment variables: shared hosting usually has nowhere to set them. The secret lives in the site database as a separate option and never reaches a page.
- The plugin's redirect address has a fixed shape —
…/wp-login.php?action=zerno-callback. Register that in the site card instead of bending it to an address already issued. Register the post-logout address there too: the site home page. - Your user is linked to a person by
sub; an existing one is picked up by a verified email once, on the first meeting. Nothing has to be written into the database by hand. - The old way in is switched off by the «Disable password sign-in» checkbox — it works on the server rather than hiding the fields. Registration and password recovery close together with it.
Any stack: plain OIDC authorization code (manual)
Install: —
Redirect URI shape: https://<domain>/auth/callback
flow.http
# 1. Metadata (every address comes from here)
GET https://id.zerno.one/.well-known/openid-configuration
# 2. Send the person to the sign-in form
GET https://id.zerno.one/oauth/v2/authorize
?response_type=code
&client_id={client_id}
&redirect_uri={redirect_uri}
&scope=<the scope value from the brief, URL-encoded whole>
&state=<random string kept in the session>
&code_challenge=<S256 of code_verifier>
&code_challenge_method=S256
# 3. They come back to redirect_uri with ?code= and ?state=
# compare state against the session, otherwise refuse.
# 4. Exchange the code for tokens (server to server, the secret never reaches the browser)
POST https://id.zerno.one/oauth/v2/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&code=<code>&redirect_uri={redirect_uri}
&client_id={client_id}&client_secret=<secret>&code_verifier=<verifier>
# 5. Who signed in
GET https://id.zerno.one/oidc/v1/userinfo
Authorization: Bearer <access_token>
- The sub from the id_token is the only stable user key. A person can change their email.
- Verify the id_token signature against jwks_uri from discovery, not by eye.
5. Verify
Without this section the task is not done.
- curl -sS https://id.zerno.one/.well-known/openid-configuration | head — must return JSON containing authorization_endpoint.
- Run the project locally and open the sign-in page: it must redirect to https://id.zerno.one, not return a 500. If the brief has redirect_uri_dev, that is what belongs in OIDC_REDIRECT_URI while you test.
- Sign in and come back: sub must appear in the session. Show the human where exactly it now lives.
- Look at the sign-in page as a stranger would: no intermediate one-button screen, no old sign-in methods sitting next to ours. One door — otherwise people take the familiar one and nothing changes.
- Check sign-out: the site session is cleared and signing in again asks for confirmation.
- Tell the human in one line: which files you created, which variables you added, what is left to do by hand — and restore the production address in OIDC_REDIRECT_URI if you tested on localhost.
6. What you learn about the person
What arrives about the person in the id_token and userinfo. The site declares which fields it wants in the dashboard; the person sees them on the consent screen and may decline any of them except the email.
| Field | What it is |
|---|---|
sub | the person's identifier at your site. Unique per site, forever |
email | the email address; email_verified marks whether it is confirmed |
name | full name in one string; given_name and family_name sit next to it |
phone_number | phone; phone_number_verified says whether it is confirmed |
birthdate | date of birth, YYYY-MM-DD |
gender | female or male |
picture | the address of the photo on our side — download and store it if you need it |
- A field is not guaranteed to be present: the person may not have supplied it or may have declined to share it. Write code where a missing field is ordinary, not a sign-in error.
- The requested fields are edited in the dashboard, in the sign-in section. Asking for extra does not pay off — every additional line on the consent screen is a reason to press "sign in another way".
phone_number_verified: falsemeans "the person typed a number", not "the number is theirs". Do not let such a number reach someone else's order.- The profile lives on our side and the person edits it. If you need it fresh, re-read it at each sign-in instead of treating a snapshot as permanent.
- Put a link to https://id.zerno.one on your site — that is the person's own account page: they edit their name and phone there, see which sites they gave what, and revoke access. Without a link from your site that page does not exist for them, and "where is my profile" is a question they will bring to you.
7. Moving an existing user base
How to move a site's existing user base onto the sign-in without signing people up again and without locking them out while the move runs.
- Passwords are not moved and not needed: the service has no password as a method. The person signs in with a code to the same address and lands in their own account.
- What moves is the link, not the person: send their previous id in your system along with the address. It comes back in the token as the
legacy_idclaim on the very first sign-in — find your old row by it and do not create a second user. - The site owner runs the move from the ZERNO cabinet, in the sign-in section of their site. In batches of a thousand, so it is visible where it stopped.
- The order is: move first, sign-in on the site second, and only then turn the old form off. The reverse order locks people out for exactly as long as the move takes.
- No
user.createdevents arrive for moved people: a thousand events into your handler is an incident, not news. The mapping comes back in the response to the move itself.
8. Common errors
- invalid_redirect_uri — The redirect address did not match the issued one. Compare the whole string; the port and the trailing slash count. Locally, only redirect_uri_dev from the brief works — the production address will not work with localhost, and vice versa.
- invalid_client — Wrong client_id or secret. The secret is shown once — if it is lost, the human issues a new one in the dashboard.
- state mismatch — The session was lost between the two requests. Usually the cause is a cookie without sameSite=lax, or a different domain.
- no sign-in methods — Methods are switched on with toggles in the site card. The agent cannot switch them on — a human does that.
- sign-in form without the provider buttons — A truncated scope went out in the request. Check that OIDC_SCOPE contains the whole string from the brief, including the urn:zitadel:iam:org:id:… tail — that tail tells the form whose site this is. Without it the buttons will not appear no matter how many you switch on in the dashboard.
9. Events (only if the human asked for them)
Sign-in events arrive at the site's own address: there is no need to poll our API. The subscription is created by a human in the dashboard, and the signing secret is issued there.
| Event | What it means |
|---|---|
user.created | a person appeared for the first time — create your user row |
session.created | a sign-in, by any method — email, passkey or provider |
user.identifier_verified | the email is confirmed — you can trust it |
user.merged | two accounts turned out to be one person; move your links to the new sub |
user.blocked | the site owner closed this person's access — drop your session, ours is already closed |
user.unblocked | access is open again |
Headers:
X-Zerno-Signature: t=<unix>,v1=<hmac-sha256 of t.body>
X-Zerno-Event
X-Zerno-Delivery — the retry key
The human takes the signing secret from the dashboard and puts it in ZERNO_WEBHOOK_SECRET. It is not in the brief: it is shown once, when the subscription is created.
// Signature check: X-Zerno-Signature: t=<unix>,v1=<hmac-sha256>
import crypto from "node:crypto";
export function verifyZernoWebhook(rawBody: string, header: string) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const expected = crypto
.createHmac("sha256", process.env.ZERNO_WEBHOOK_SECRET!)
.update(`${parts.t}.${rawBody}`)
.digest("hex");
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
// Body: { event, at, tenant_id, sub, application_id, provider }
- Compute the signature over the raw request body, before parsing JSON: a re-serialised json.dumps produces different bytes and the signature will not match.
- Check the timestamp from the signature (
t=) — otherwise an intercepted request can be replayed at any time. Five minutes of skew is enough. - The handler must be idempotent: delivery is retried up to five times a day, and the same event can arrive twice. The retry key is the X-Zerno-Delivery header.
- Answer 2xx immediately and queue the event on your side: we wait ten seconds for the response.
- Email and phone are deliberately absent from the event. If you need them, ask by sub through userinfo or your own database.
10. What not to do
- Do not create your own password form and a
users.passwordtable. - Do not keep the client_secret in client-side code and do not log it.
- Do not change the sign-in methods (passkey, email, Yandex ID, Google, GitHub, Telegram) — those are toggles in the human's dashboard.
- Do not invent endpoints: anything that is not in discovery does not exist.
- Do not build intermediate screens around sign-in: no "press the button" page, no method picker of your own. Methods are chosen on the provider's form.
- Do not bend the issued redirect address to fit the framework's habits. If the library insists on its own path and a different one was issued, exchange the code yourself or ask the human to re-issue the key. A provider rejecting a foreign redirect_uri is correct behaviour.
