Sign in with Zeq
"Sign in with Zeq" is a cross-domain OAuth 2.0 + PKCE authentication system where users identify themselves with a mathematical equation instead of a password or email address. It works like Sign in with Google — a third-party website registers an app, drops in a script tag or SDK call, and users authenticate through a popup on the Zeq OS domain. The equation is typed only inside that popup; it never leaves the zeq.os origin. Your server receives a scoped JWT whose claims include the user's stable zid identifier and whatever scopes the user consented to. No passwords are stored, no email addresses are required, and sessions are isolated per (zid, client_id) pair.
Quick Start
- Sign in to Zeq OS, then
POST /auth/apps/registerwith your app name and redirect URIs. - Copy the embed snippet from
GET /auth/apps/<client_id>/embed. - Paste the
<script>tag into your HTML — done.
Popup Flow
Third-party page zeq.os popup (/auth/oauth/authorize)
───────────────── ────────────────────────────────────
ZeqAuth.connect()
│
├─ generate PKCE verifier + SHA-256 challenge
├─ window.open(…?code_challenge=…&client_id=…&scope=…)
│
│ User types phrase
│ → equation generated (server-side)
│ → consent screen shown
│ → auth code issued
│ → /auth/popup-callback?code=…
│ fires postMessage({type:'zeq-auth-callback', code})
│ window.close()
│
├─ onmessage receives code
├─ POST /auth/oauth/token (code + verifier)
│ ← { access_token, id_token, scope, expires_in }
├─ GET /auth/oauth/userinfo
│ ← { sub, name, preferred_username, email }
└─ saveSession(token, user) → localStorage
The PKCE code_challenge_method is always S256. The token exchange happens from the caller's origin, so the code_verifier never travels through a redirect URL.
Scopes
| Scope | What it grants |
|---|---|
identity | User's zid, display name, and avatar. Always included. |
zeq:operators | Access to the authenticated operator API on the API Gateway. |
vault:read | Read this app's per-user vault slot (GET /auth/vault/:client_id). |
vault:write | Write this app's per-user vault slot (POST /auth/vault/:client_id). |
Scopes are declared at app registration time. Users see a consent screen listing what each scope accesses before approving.
REST API Reference
All endpoints are relative to the auth server base (default: /auth on localhost, https://auth.zeq.os in production).
POST /auth/apps/register
Register a new OAuth client. Requires a valid Zeq session token (Authorization: Bearer <token>).
Request body
{
"appName": "My App",
"redirectUris": ["https://example.com/callback"],
"scopes": ["identity", "vault:read"]
}
Response 201
{
"client_id": "my-app-a1b2c3",
"client_secret": "...",
"app_name": "My App",
"redirect_uris": ["https://example.com/callback"],
"allowed_scopes": ["identity", "vault:read"],
"owner_zid": "zeq-abc123",
"warning": "Store client_secret securely — it is shown only once."
}
Limits: max 80-char app name, max 10 redirect URIs, max 50 apps per account, rate-limited to 20 registrations per hour.
GET /auth/apps
List all apps registered by the authenticated user. Never returns client_secret.
Response
[
{
"client_id": "my-app-a1b2c3",
"app_name": "My App",
"redirect_uris": ["https://example.com/callback"],
"allowed_scopes": ["identity", "vault:read"],
"created_at": 1741200000000
}
]
DELETE /auth/apps/:client_id
Delete a registered app. Must be the owner. System apps (e.g. zeq-gitea) cannot be deleted.
Response 200
{ "deleted": "my-app-a1b2c3" }
GET /auth/apps/:client_id/embed
Returns the ready-to-paste HTML script tag for this app. Public endpoint — no auth required.
Response (text/plain)
<!-- Sign in with Zeq — My App -->
<script src="https://auth.zeq.os/static/zeq-auth-client.js"
data-client-id="my-app-a1b2c3"
data-auth-base="https://auth.zeq.os"
data-scope="identity vault:read"
data-protect
async></script>
GET /auth/vault/:client_id and POST /auth/vault/:client_id
Per-user, per-app key-value store. The token's aud claim must match the :client_id in the path — a token issued to one app cannot read another app's vault.
- GET requires scope
vault:read. Returns{ client_id, zid, data: {} }. - POST requires scope
vault:write. Body:{ "data": { ...any JSON } }. Max 64 KB per slot.
JavaScript SDK
npm install @zeq/auth
Vanilla ES module
import { ZeqAuthClient } from '@zeq/auth';
const auth = new ZeqAuthClient({
clientId: 'my-app-a1b2c3',
authBase: 'https://auth.zeq.os',
scope: 'identity vault:read',
});
// Restore session on page load
auth.loadSession();
// Open PKCE popup — resolves when user completes auth
const { token, user } = await auth.connect();
// user = { id, displayName, equationHash, authMethod, scope }
// Attach Bearer token to any fetch
const res = await auth.fetchWithAuth('/api/protected');
// Read/write vault
const vaultData = await auth.readVault('https://myapp.example.com');
await auth.writeVault('https://myapp.example.com', { theme: 'dark', level: 5 });
// Log out
auth.logout();
Same-origin login (no clientId required, equation sent directly):
import { auth } from '@zeq/auth'; // singleton, authBase: '/auth'
await auth.login('E = mc²'); // existing account
await auth.register('E = mc²', 'Alice'); // new account
React SDK
npm install @zeq/auth
import { ZeqAuthProvider, useZeqAuth, ZeqSignInButton } from '@zeq/auth/react';
// index.jsx — wrap your app
<ZeqAuthProvider
clientId="my-app-a1b2c3"
authBase="https://auth.zeq.os"
scope="identity vault:read"
protect>
<App />
</ZeqAuthProvider>
// Profile.jsx — use anywhere inside the provider
function Profile() {
const { user, loading, error, connect, logout, fetchWithAuth, readVault, writeVault } = useZeqAuth();
if (loading) return <p>Loading…</p>;
if (!user) return <ZeqSignInButton onSuccess={r => console.log(r)} />;
return (
<div>
<p>Signed in as {user.displayName}</p>
<p>Scope: {user.scope}</p>
<ZeqSignInButton /> {/* shows "Signed in as … — Log out" when authenticated */}
</div>
);
}
ZeqAuthProvider props
| Prop | Type | Default | Description |
|---|---|---|---|
clientId | string | null | OAuth client ID. Required for cross-domain PKCE. |
authBase | string | 'http://localhost/auth' | Auth server base URL. |
scope | string | 'identity' | Space-separated list of requested scopes. |
protect | bool | false | If true, expired sessions trigger connect() automatically. |
autoConnect | bool | false | If true and no session exists, open popup immediately on mount. |
useZeqAuth() returns
| Key | Type | Description |
|---|---|---|
user | object | null | { id, displayName, equationHash, authMethod, scope } |
token | string | null | Bearer token. |
loading | bool | True while restoring session on first render. |
error | string | null | Last auth error message. |
isAuthenticated | bool | !!token |
connect | async () => { token, user } | Open PKCE popup. |
logout | () => void | Clear session. |
fetchWithAuth | (url, opts) => Response | Bearer-authenticated fetch. |
readVault | (apiBase) => Promise | Read vault for this clientId. |
writeVault | (apiBase, data) => Promise | Write vault for this clientId. |
Vue 3 SDK
npm install @zeq/auth
// main.js
import { createApp } from 'vue';
import ZeqAuth from '@zeq/auth/vue';
import App from './App.vue';
const app = createApp(App);
app.use(ZeqAuth, {
clientId: 'my-app-a1b2c3',
authBase: 'https://auth.zeq.os',
scope: 'identity vault:read',
});
app.mount('#app');
<!-- MyComponent.vue -->
<script setup>
import { useZeqAuth, ZeqSignInButton } from '@zeq/auth/vue';
const { user, loading, connect, logout, fetchWithAuth, readVault, writeVault } = useZeqAuth();
</script>
<template>
<div v-if="loading">Loading…</div>
<div v-else-if="user">
Signed in as {{ user.displayName }}
<button @click="logout">Log out</button>
</div>
<ZeqSignInButton v-else @success="console.log($event)" />
</template>
The composable returns the same keys as the React hook. token, user, loading, and error are Vue readonly refs — unwrap them in templates with .value in <script setup> or directly in {{ }} bindings.
ZeqSignInButton also accepts a logoutLabel prop to customise the signed-in state text.
Drop-in Script Tag
No build tool required. Copy this from GET /auth/apps/:client_id/embed and paste it anywhere in your HTML:
<!DOCTYPE html>
<html>
<head><title>My App</title></head>
<body>
<div id="app">
<!-- ZeqAuth injects a sign-in button here when data-protect is set -->
</div>
<!-- Sign in with Zeq -->
<script src="https://auth.zeq.os/static/zeq-auth-client.js"
data-client-id="my-app-a1b2c3"
data-auth-base="https://auth.zeq.os"
data-scope="identity vault:read"
data-protect
async></script>
<script>
// ZeqAuth is available globally after the script loads
document.addEventListener('DOMContentLoaded', function () {
ZeqAuth.onAuth(function (user, token) {
if (user) {
document.getElementById('app').textContent = 'Hello ' + user.displayName;
}
});
});
</script>
</body>
</html>
data-* attributes
| Attribute | Description |
|---|---|
data-client-id | Your registered client_id. Enables cross-domain PKCE popup flow. |
data-auth-base | Auth server origin. Defaults to /auth (same-origin). |
data-scope | Space-separated scope list. Defaults to identity. |
data-protect | Redirect/block page if not authenticated. Shows sign-in UI automatically. |
data-auto-show | Open the login UI on load without waiting for user interaction. |
Global ZeqAuth API (available after script loads)
ZeqAuth.connect() // open PKCE popup → Promise<{ token, user }>
ZeqAuth.isAuthenticated() // boolean
ZeqAuth.getToken() // scoped JWT or null
ZeqAuth.getUser() // { id, displayName, equationHash } or null
ZeqAuth.onAuth(fn) // subscribe to auth state changes
ZeqAuth.fetchWithAuth(url, opts) // Bearer-authenticated fetch()
ZeqAuth.logout() // clear session + reload
Security Notes
-
Equation never leaves zeq.os. The user types their phrase inside the popup served from
zeq.os. The phrase is sent over HTTPS to the auth server, converted to an equation server-side, and stored only as a bcrypt hash. The third-party origin sees only the resulting JWT. -
PKCE S256. A fresh 64-byte
code_verifieris generated client-side per authentication attempt. The auth server verifiesSHA-256(verifier) == code_challengebefore issuing a token. Authorization codes cannot be replayed. -
Scoped JWTs. Every token carries a
scopeclaim and anaudclaim set toclient_id. The API server checks both before executing privileged operations. A token for one app cannot be used to call another app's vault or operator endpoints. -
Vault isolation. Vault rows are keyed by
(zid, client_id). The server additionally checkstoken.aud === clientIdon every vault request, so a token obtained from one app cannot access a different app's vault even if the user has accounts with both. -
client_secrethandling. Theclient_secretreturned at registration is shown once and never returned again. It is used only for server-to-server token exchanges where the client can be confidential. The PKCE popup flow does not require the secret on the client side. -
Consent persistence. Once a user approves scopes for an app, the consent is stored in
oauth_consents(zid, client_id). Subsequent logins skip the consent screen unless the app requests additional scopes.