Skip to main content

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

  1. Sign in to Zeq OS, then POST /auth/apps/register with your app name and redirect URIs.
  2. Copy the embed snippet from GET /auth/apps/<client_id>/embed.
  3. Paste the <script> tag into your HTML — done.

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

ScopeWhat it grants
identityUser's zid, display name, and avatar. Always included.
zeq:operatorsAccess to the authenticated operator API on the API Gateway.
vault:readRead this app's per-user vault slot (GET /auth/vault/:client_id).
vault:writeWrite 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

PropTypeDefaultDescription
clientIdstringnullOAuth client ID. Required for cross-domain PKCE.
authBasestring'http://localhost/auth'Auth server base URL.
scopestring'identity'Space-separated list of requested scopes.
protectboolfalseIf true, expired sessions trigger connect() automatically.
autoConnectboolfalseIf true and no session exists, open popup immediately on mount.

useZeqAuth() returns

KeyTypeDescription
userobject | null{ id, displayName, equationHash, authMethod, scope }
tokenstring | nullBearer token.
loadingboolTrue while restoring session on first render.
errorstring | nullLast auth error message.
isAuthenticatedbool!!token
connectasync () => { token, user }Open PKCE popup.
logout() => voidClear session.
fetchWithAuth(url, opts) => ResponseBearer-authenticated fetch.
readVault(apiBase) => PromiseRead vault for this clientId.
writeVault(apiBase, data) => PromiseWrite 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

AttributeDescription
data-client-idYour registered client_id. Enables cross-domain PKCE popup flow.
data-auth-baseAuth server origin. Defaults to /auth (same-origin).
data-scopeSpace-separated scope list. Defaults to identity.
data-protectRedirect/block page if not authenticated. Shows sign-in UI automatically.
data-auto-showOpen 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_verifier is generated client-side per authentication attempt. The auth server verifies SHA-256(verifier) == code_challenge before issuing a token. Authorization codes cannot be replayed.

  • Scoped JWTs. Every token carries a scope claim and an aud claim set to client_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 checks token.aud === clientId on 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_secret handling. The client_secret returned 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.