> ## Documentation Index
> Fetch the complete documentation index at: https://docs.pinaivu.com/llms.txt
> Use this file to discover all available pages before exploring further.

# zkLogin Authentication

> Seedless sign-in for the Pinaivu developer console — a Sui address derived from a Google login via zero-knowledge proof, no seed phrase, no wallet extension

The Pinaivu **developer console** (`pinaivu-api/dashboard`) authenticates
users with **Sui zkLogin**. Instead of a seed phrase or a browser wallet, a
developer signs in with their existing Google account and receives a real,
self-custodied Sui address — derived from the OAuth identity through a
zero-knowledge proof. The OAuth provider never learns the user's Sui
address, and Pinaivu never sees a private key.

This page documents how that flow is wired end-to-end, the exact files and
endpoints involved, and the security caveats worth knowing before you ship
it.

## Why zkLogin

A developer console needs an on-chain identity (so usage, keys, and
payments can bind to a Sui address) but the audience is developers who do
not want to manage a wallet just to create an API key. zkLogin closes that
gap:

* **Seedless** — the address is a function of the OAuth `sub` + a salt,
  proven in zero knowledge. There is no mnemonic to store or lose.
* **Self-custodied** — every transaction is still authorised by an
  **ephemeral key** the browser generates and holds; the proof binds that
  ephemeral key to the user's address for a bounded number of epochs.
* **Private** — the link between the Google account and the Sui address is
  hidden behind the ZK proof. The chain sees a normal Sui address.

See [Why Sui](/architecture/why-sui) for why this primitive only exists on
Sui.

## Where it lives

```
pinaivu-api/dashboard/
├─ lib/zklogin/
│  ├─ zklogin.ts      ← ZkLoginService: nonce, OAuth URL, ZK proof, address, tx signature
│  └─ session.ts      ← SessionManager: localStorage session + cached proof (24h TTL)
├─ app/login/page.tsx          ← "Continue with Google" entry point
├─ app/auth/callback/page.tsx  ← OAuth redirect handler → proof → account
└─ app/DashboardShell.tsx      ← route guard; redirects to /login without a proof
```

The proving and salt-backed nonce are delegated to **Enoki** (Mysten Labs'
hosted zkLogin service); the OAuth provider is **Google**
(`response_type=id_token`).

## The flow

```
/login                       Enoki              Google OAuth          /auth/callback
  │                            │                     │                      │
  │ 1. new ephemeral Ed25519   │                     │                      │
  │──── POST /nonce ──────────▶│                     │                      │
  │   (ephemeralPublicKey)     │                     │                      │
  │◀── nonce, randomness ──────│                     │                      │
  │        maxEpoch            │                     │                      │
  │                                                  │                      │
  │ 2. redirect with nonce ─────────────────────────▶│                      │
  │                                                  │ user consents        │
  │ 3. redirect #id_token=<JWT> ────────────────────────────────────────────▶│
  │                            │                     │                      │
  │                            │ 4. POST /zkp (zklogin-jwt: JWT)            │
  │                            │◀────────────────────────────────────────────│
  │                            │─── zkProof ─────────────────────────────────▶│
  │                                                                         │
  │                            5. derive zkLogin address, cache proof,       │
  │                               POST /api/accounts { email, wallet_addr }  │
  │                            6. redirect to dashboard ────────────────────▶ /
```

<Steps>
  <Step title="Initialize a session (/login)">
    `ZkLoginService.initializeSession()` mints a fresh **ephemeral
    Ed25519 keypair**, sends its public key to the Enoki nonce endpoint
    (`NEXT_PUBLIC_ENOKI_NONCE_URL`) with `additionalEpochs: 2`, and stores
    the returned `{ nonce, randomness, maxEpoch }` plus the ephemeral
    private key in `localStorage` under `pinaivu-zklogin-session`.

    ```ts theme={null}
    const ephemeralKeyPair = new Ed25519Keypair();
    const ephemeralPublicKey = ephemeralKeyPair.getPublicKey().toSuiPublicKey();

    const nonceRes = await fetch(process.env.NEXT_PUBLIC_ENOKI_NONCE_URL!, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.NEXT_PUBLIC_ENOKI_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        network: process.env.NEXT_PUBLIC_SUI_NETWORK || "testnet",
        ephemeralPublicKey,
        additionalEpochs: 2,
      }),
    });
    const { data } = await nonceRes.json(); // { nonce, randomness, maxEpoch }
    ```

    `maxEpoch` is the Sui epoch after which both the nonce and any proof
    bound to this ephemeral key stop being valid — the practical session
    lifetime.
  </Step>

  <Step title="Redirect to Google">
    The browser is sent to Google's OIDC endpoint requesting an
    `id_token` (a JWT) whose `nonce` claim is the value Enoki returned.
    The nonce cryptographically commits the JWT to *this* ephemeral key, so
    a stolen JWT can't be replayed against a different key.

    ```ts theme={null}
    const params = new URLSearchParams({
      client_id: GOOGLE_CLIENT_ID,
      redirect_uri: `${origin}/auth/callback`,
      response_type: "id_token",
      scope: "openid email profile",
      nonce,
      state: "pinaivu_" + Date.now(),
    });
    window.location.href = `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
    ```
  </Step>

  <Step title="Fetch the ZK proof (/auth/callback)">
    Google redirects back with `#id_token=<JWT>` in the URL fragment. The
    callback recreates the ephemeral keypair from the saved session and asks
    Enoki to produce the zkLogin proof, passing the JWT in the
    `zklogin-jwt` header.

    ```ts theme={null}
    const zkProof = await ZkLoginService.fetchZkProof({
      jwtToken,
      ephemeralKeyPair,            // recreated from session.ephemeralPrivateKey
      randomness: session.randomness,
      maxEpoch: parseInt(session.maxEpoch),
      userSalt: salt,
    });
    ```
  </Step>

  <Step title="Derive the address and persist the proof">
    The zkLogin Sui address is derived from the JWT + salt (or from the
    proof's `addressSeed`). The full proof bundle is cached in
    `localStorage` under `pinaivu-zklogin-proof` with a 24-hour TTL.

    ```ts theme={null}
    const address = ZkLoginService.getZkLoginAddress(jwtToken, salt, zkProof.addressSeed);
    SessionManager.saveProof({ zkProof, jwtToken, address, userSalt: salt, ... });
    ```
  </Step>

  <Step title="Link the account">
    The callback creates (or links) the Pinaivu account, binding the email
    and Sui address, then redirects into the console.

    ```ts theme={null}
    await fetch("/api/accounts", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ email: decodedJWT.email, wallet_addr: address }),
    });
    ```
  </Step>
</Steps>

## Signing Sui transactions

When the user later authorises an on-chain action, the ephemeral key signs
the transaction bytes and `ZkLoginService.createTransactionSignature`
wraps that signature together with the ZK proof and the `addressSeed` into a
single **zkLogin signature** that Sui validators accept:

```ts theme={null}
return getZkLoginSignature({
  inputs: { ...partialZkProof, addressSeed },
  maxEpoch,
  userSignature: ephemeralSignature, // ephemeral key's signature over the tx
});
```

`addressSeed` comes straight from the proof when present, otherwise it is
recomputed with `genAddressSeed(BigInt(userSalt), "sub", sub, aud)`.

## Session & route guarding

`SessionManager` (`lib/zklogin/session.ts`) keeps two `localStorage` keys:

| Key                       | Contents                                                 | Lifetime                                    |
| ------------------------- | -------------------------------------------------------- | ------------------------------------------- |
| `pinaivu-zklogin-session` | ephemeral private key, `randomness`, `maxEpoch`, `nonce` | until the callback completes (then cleared) |
| `pinaivu-zklogin-proof`   | `zkProof`, JWT, address, salt, email                     | 24 h TTL, auto-expired on read              |

`DashboardShell` treats `/login` and `/auth/callback` as public and
redirects every other route to `/login` when no valid cached proof exists.
"Sign out" calls `SessionManager.clearAll()`.

## Environment variables

These are required by the dashboard for zkLogin to work. The
`NEXT_PUBLIC_` prefix means they are exposed to the browser (Enoki API keys
used here are publishable client keys, not server secrets).

| Variable                       | Purpose                                      |
| ------------------------------ | -------------------------------------------- |
| `NEXT_PUBLIC_GOOGLE_CLIENT_ID` | Google OAuth client id (OIDC provider)       |
| `NEXT_PUBLIC_ENOKI_API_KEY`    | Enoki publishable key (Bearer auth to Enoki) |
| `NEXT_PUBLIC_ENOKI_NONCE_URL`  | Enoki nonce endpoint (step 1)                |
| `NEXT_PUBLIC_ENOKI_ZKP_URL`    | Enoki ZK proving endpoint (step 3)           |
| `NEXT_PUBLIC_SUI_NETWORK`      | `testnet` (default) / `mainnet`              |

Dependencies: `@mysten/sui` (`/zklogin` + `/keypairs/ed25519`) and
`jwt-decode`.

<Warning>
  **Salt derivation is a development placeholder.** The callback currently
  derives `userSalt` deterministically from the JWT `sub` with a simple
  non-cryptographic hash:

  ```ts theme={null}
  const hashVal = Array.from(encoder.encode(decodedJWT.sub))
    .reduce((acc, val) => (acc << 5) - acc + val, 0);
  const salt = Math.abs(hashVal).toString();
  ```

  This is deterministic (the same Google account always maps to the same
  address) which is what you want, but it is **not** a production salt
  scheme: it has low entropy and the salt is recomputed client-side rather
  than held by a salt service. For production, back the salt with a
  persistent, per-user **salt service** (Enoki can manage this for you) so
  the OAuth↔address mapping cannot be brute-forced and stays stable across
  providers.
</Warning>

<Warning>
  The `id_token` arrives in the URL **fragment** (`#id_token=...`), which is
  never sent to a server. The callback parses it client-side, derives the
  proof, then calls `window.history.replaceState` to strip it from the
  address bar. Keep the proving on the client (or a trusted edge) — never
  log the raw JWT.
</Warning>

## Troubleshooting

* **"Google Client ID not configured."** — `NEXT_PUBLIC_GOOGLE_CLIENT_ID`
  is unset at build time. `NEXT_PUBLIC_` vars are inlined during the build,
  so rebuild after setting it.
* **`Enoki nonce error` / `Enoki ZKP error`** — check the
  `NEXT_PUBLIC_ENOKI_API_KEY` and that the `network` matches the key's
  configured network. The thrown error includes Enoki's response body.
* **Redirect loops back to `/login`** — the cached proof expired (24 h) or
  was cleared; sign in again. `DashboardShell` redirects whenever
  `SessionManager.getProof()` returns `null`.
* **`No active session.` in the callback** — the user reached
  `/auth/callback` without first going through `/login` (the ephemeral
  session in `localStorage` is missing).

<CardGroup cols={2}>
  <Card title="Why Sui" icon="circle-question" href="/architecture/why-sui">
    Why zkLogin and on-chain settlement are built on Sui specifically
  </Card>

  <Card title="Glossary" icon="book" href="/reference/glossary">
    zkLogin, ephemeral key, salt, maxEpoch, and other terms
  </Card>
</CardGroup>
