kunji

FIREBASE

Sign in with kunji on Firebase

The exact stack kunji's own demo runs: Cloud Functions + Firestore + Hosting. Drop in the button widget, add three small functions, and your users sign in with keys they hold — no passwords, no Google. kunji shares no database and runs no backend in this flow; the wallet posts the signed assertion straight to your callback.

Fastest path — the drop-in button

1 · Add the button

Include the widget and drop in a button element pointing at your Cloud Function URLs. It renders the official button, opens the QR / code modal, and reports success — all client-side.

<script src="https://kunji.cc/rp.js"></script>

<div data-kunji-signin
     data-app-name="Your App"
     data-audience="yourapp.com"
     data-session-url="https://yourapp.com/kunji/session"
     data-callback-url="https://yourapp.com/kunji/callback"
     data-poll-url="https://yourapp.com/kunji/status"
     data-redirect="/dashboard"></div>

Pin a version in production: https://kunji.cc/rp.v1.js (optionally with an SRI hash). For SPAs, omit data-redirect and listen for the kunji:success event — e.detail.sub is the user's stable id. The widget talks only to your endpoints; it never calls a kunji server. See it running →

The backend you implement
STEP 2

Wire endpoints same-site (firebase.json)

Hosting rewrites make your functions resolve on your own domain — so callbackUrl is same-site as audience, which kunji requires.

{
  "hosting": {
    "rewrites": [
      { "source": "/kunji/session",  "function": { "functionId": "createSession",    "region": "us-central1" } },
      { "source": "/kunji/status",   "function": { "functionId": "getSessionStatus",  "region": "us-central1" } },
      { "source": "/kunji/callback", "function": { "functionId": "kunjiCallback",     "region": "us-central1" } },
      { "source": "**", "destination": "/index.html" }
    ]
  },
  "functions": { "source": "functions" }
}
STEP 3

createSession — mint a challenge

A random sessionId + challenge with a short TTL, stored in your Firestore. The ttl field lets a Firestore TTL policy auto-delete old sessions. Return a 6-digit code too if you want the code tab.

import { onRequest } from 'firebase-functions/v2/https';
import { initializeApp } from 'firebase-admin/app';
import { getFirestore, Timestamp } from 'firebase-admin/firestore';
import { randomBytes } from 'node:crypto';

initializeApp();
const db = getFirestore();
const hex = (n) => randomBytes(n).toString('hex');

export const createSession = onRequest({ cors: true }, async (req, res) => {
  const audience    = 'yourapp.com';                        // hardcode your domain
  const callbackUrl = 'https://yourapp.com/kunji/callback'; // same-site as audience
  const sessionId = hex(16);
  const challenge = hex(32);
  const expiresAt = Date.now() + 2 * 60_000;                // 2-minute TTL

  await db.collection('loginSessions').doc(sessionId).set({
    challenge, audience, callbackUrl, status: 'pending', sub: null, expiresAt,
    ttl: Timestamp.fromMillis(expiresAt + 5 * 60_000),      // for the TTL policy
  });

  res.json({ sessionId, challenge, expiresAt });
});
STEP 4

verify.js — the assertion checks

Canonical JSON + Ed25519, identical to kunji's signer. Enforces every rule in the checklist below.

import { createHash } from 'node:crypto';
import { ed25519 } from '@noble/curves/ed25519.js';

const canonicalJson = (o) =>
  (o === null || typeof o !== 'object' || Array.isArray(o))
    ? JSON.stringify(o)
    : JSON.stringify(Object.fromEntries(Object.keys(o).sort().map((k) => [k, o[k]])));

const b64   = (s) => Buffer.from(s, 'base64');
const subOf = (pk) => createHash('sha256').update(pk, 'utf8').digest('hex');

export function verifyAssertion({ assertion, session, audience, now = Date.now() }) {
  const { publicKey, signedPayload, signedToken } = assertion || {};
  if (!publicKey || !signedPayload || !signedToken)  return { ok: false, error: 'malformed' };
  if (!session || session.status !== 'pending')      return { ok: false, error: 'bad_session' };
  if (now > session.expiresAt)                       return { ok: false, error: 'expired' };
  if (signedPayload.challenge !== session.challenge) return { ok: false, error: 'challenge' };
  if (signedPayload.audience  !== audience)          return { ok: false, error: 'audience' };
  if (signedPayload.sub       !== subOf(publicKey))  return { ok: false, error: 'sub' };
  if (Math.abs(now - signedPayload.timestamp) > 120_000) return { ok: false, error: 'stale' };

  const ok = ed25519.verify(b64(signedToken),
    new TextEncoder().encode(canonicalJson(signedPayload)), b64(publicKey));
  return ok ? { ok: true, sub: signedPayload.sub } : { ok: false, error: 'signature' };
}
STEP 5

kunjiCallback — receive & verify

The wallet POSTs the signed assertion here. On success, mark the session approved and (for real auth) mint a custom token so request.auth.uid === sub and your existing rules keep working.

import { getAuth } from 'firebase-admin/auth';
import { verifyAssertion } from './verify.js';

export const kunjiCallback = onRequest({ cors: true }, async (req, res) => {
  const assertion = req.body || {};
  const ref = db.collection('loginSessions').doc(assertion?.signedPayload?.sessionId || '_');
  const session = (await ref.get()).data();

  const result = verifyAssertion({ assertion, session, audience: session?.audience });
  if (!result.ok) return res.status(400).json({ error: result.error });

  // first-seen sub = new account; returning sub = existing user
  const customToken = await getAuth().createCustomToken(result.sub, { kunjiPub: assertion.publicKey });
  // result.claims is the user's OPTIONAL, self-asserted profile (or null). It is NOT
  // verified — store it in your own profile doc, do NOT put it in the token's claims.
  await ref.update({ status: 'approved', sub: result.sub, customToken, claims: result.claims || null });

  res.json({ status: 'ok' });   // single-use: status is now 'approved'
});

Showing the user: you get a stable sub, not a verified name/photo — your app owns the profile. Render a default name + key-sigil avatar with kunji.handle(sub) (from rp.js); if the user shared claims, prefer it but treat it as untrusted (HTML-escape the name, render the picture client-side only). Keep unverified claims out of the custom token; store them in your own users/{sub} record.

STEP 6

getSessionStatus — the widget's poll

The widget polls this read-only endpoint until the session is approved, then fires success.

export const getSessionStatus = onRequest({ cors: true }, async (req, res) => {
  const snap = await db.collection('loginSessions').doc(String(req.query.sessionId || '_')).get();
  if (!snap.exists) return res.status(404).json({ error: 'unknown_session' });
  const s = snap.data();
  res.json({ status: s.status, sub: s.sub || null, customToken: s.customToken || null, claims: s.claims || null });
});
STEP 7

Firestore rules + TTL

Writes are server-only (Admin SDK bypasses rules). The frontend reads its own session by random id; the doc holds no secrets. Add a Firestore TTL policy on the ttl field so sessions self-delete.

match /loginSessions/{sessionId} {
  allow get: if true;     // read your own session by id; no client writes
}
STEP 8

Finish the sign-in (frontend)

With the widget, listen for success and exchange the custom token for a real Firebase session keyed by sub.

import { getAuth, signInWithCustomToken } from 'firebase/auth';

// the widget's success event carries the RP poll payload, incl. customToken
document.addEventListener('kunji:success', async ({ detail }) => {
  await signInWithCustomToken(getAuth(), detail.customToken);  // request.auth.uid === sub
});
Verification checklist (server-side)
  • Session known, not expired, status === 'pending'.
  • challenge matches the stored challenge.
  • audience equals your own domain.
  • Signature verifies over canonicalJson(signedPayload).
  • sub === hex(SHA-256(utf8(publicKeyBase64))), recomputed server-side.
  • timestamp within ±2 minutes.
  • Session marked consumed — reject re-submission.
Reference

A complete working copy lives in the kunji repo under examples/kunji-login-demo (these snippets are adapted from it). See the stack-agnostic protocol guide for message formats and the trust model.