# @rhinestone/1auth

Passkey-based authentication SDK for Web3 applications. Build seamless, secure authentication flows using passkeys (WebAuthn) for smart accounts.

## Installation

```bash
npm install @rhinestone/1auth
# or
pnpm add @rhinestone/1auth
# or
yarn add @rhinestone/1auth
```

### Peer Dependencies

```bash
npm install viem @wagmi/core
# React (optional, for PayButton component)
npm install react
```

## Entry Points

The SDK provides multiple entry points for different use cases:

| Entry Point | Import | Use Case |
|-------------|--------|----------|
| Default | `@rhinestone/1auth` | Core client, provider, batch components, registry, types |
| React | `@rhinestone/1auth/react` | `PayButton` component + `useSponsorship` hook |
| Server | `@rhinestone/1auth/server` | JWT sponsorship signers for app-sponsored intents |
| Wagmi | `@rhinestone/1auth/wagmi` | Wagmi connector integration |
| Headless | `@rhinestone/1auth/headless` | Headless client without dialog UI |

## Quick Start

### Basic Setup

```typescript
import { OneAuthClient, createOneAuthProvider } from '@rhinestone/1auth';

// Create the client
const client = new OneAuthClient({
  providerUrl: 'https://passkey.1auth.app',
});

// Create an EIP-1193 compatible provider (chainId is required)
const provider = createOneAuthProvider({ client, chainId: 8453 }); // Base
```

### React Integration

```tsx
import { PayButton } from '@rhinestone/1auth/react';
import { OneAuthClient } from '@rhinestone/1auth';

const client = new OneAuthClient({ providerUrl: 'https://passkey.1auth.app' });

function CheckoutPage() {
  return (
    <PayButton
      client={client}
      intent={{
        targetChain: 8453, // Base
        calls: [{ to: '0xRecipient', data: '0x', value: '1000000' }],
      }}
      onSuccess={(result) => console.log('Payment successful', result)}
      onError={(error) => console.error('Payment failed', error)}
    >
      Pay $5 USDC
    </PayButton>
  );
}
```

### Wagmi Connector

```typescript
import { oneAuth } from '@rhinestone/1auth/wagmi';
import { OneAuthClient } from '@rhinestone/1auth';
import { createConfig, http } from 'wagmi';
import { mainnet, sepolia } from 'wagmi/chains';

// The connector wraps a configured OneAuthClient.
const client = new OneAuthClient({ providerUrl: 'https://passkey.1auth.app' });

const config = createConfig({
  chains: [mainnet, sepolia],
  connectors: [
    oneAuth({ client }),
  ],
  transports: {
    [mainnet.id]: http(),
    [sepolia.id]: http(),
  },
});
```

### Server-Side JWT Sponsorship

```typescript
import { createSponsorshipSigner } from '@rhinestone/1auth/server';

// Reads RHINESTONE_JWT_PRIVATE_KEY / _INTEGRATOR_ID / _PROJECT_ID / _APP_ID / _KEY_ID
// from process.env. Pass `{ credentials }` to supply them explicitly instead.
const signer = createSponsorshipSigner();

// Use in your API routes
const accessToken = await signer.accessToken();
const extensionToken = await signer.extensionToken(intentOp); // accepts the JSON-stringified intentOp
```

## Core Exports

### `@rhinestone/1auth`

- `OneAuthClient` - Main client for passkey operations
- `createOneAuthProvider()` - EIP-1193 compatible provider factory
- `createPasskeyAccount()`, `createPasskeyWalletClient()` - viem account / WalletClient with passkey support
- `definePermissions()`, `createCrossChainPermission()` - Session-key permission helpers
- `BatchQueueProvider`, `useBatchQueue()`, `BatchQueueWidget` - Batch queue context, hook, and widget
- `getSupportedChains()`, `getSupportedTokens()`, `getAllSupportedChainsAndTokens()` - Chain/token registry
- `hashMessage()`, `verifyMessageHash()` - Message verification
- Type exports for intents, accounts, sponsorship, and more

### `@rhinestone/1auth/react`

- `PayButton` - Pre-built payment/intent button component (requires a `client` prop)
- `useSponsorship()` - Hook that builds a sponsorship config from token endpoint URLs with an expiry-aware access-token cache

> Batch components (`BatchQueueProvider`, `BatchQueueWidget`, `useBatchQueue`) are exported from the **main** `@rhinestone/1auth` entry, not from `/react`.

### `@rhinestone/1auth/server`

- `createSponsorshipSigner()` - **Recommended.** Framework-agnostic signer that reads credentials from env and exposes `accessToken()` / `extensionToken(intentOp)`
- `createJwtSigner()` - Lower-level escape hatch for custom claims; takes `{ jwt, shouldSponsor? }`
- `verifyOneAuthAccount()` - Server-side check that an address belongs to a known 1auth account
- `hashMessage()`, `verifyMessageHash()`, `encodeWebAuthnSignature()` - Verification / signing utilities
- `JwtSignerConfig`, `JwtCredentials`, `SponsorshipSigner` - Configuration types

### `@rhinestone/1auth/wagmi`

- `oneAuth()` - Wagmi connector factory

## App-Sponsored Intents

### What the JWT is for

Every intent needs someone to pay its fees. **By default, 1auth pays** using its own orchestrator API key — you don't have to do anything. App sponsorship lets *your app* take over that bill instead: you mint a short-lived JWT, signed with a private key Rhinestone issued to your project, and the orchestrator charges your account for any intent that JWT authorizes.

The JWT is purely a **billing authorization** — it says "this app vouches for paying this intent." It does **not** authenticate the user or authorize the transaction itself; that's the passkey/WebAuthn signature, which is independent. Sponsorship and signing happen in parallel.

The private key must **never** reach the browser, so the tokens are always minted server-side. You have two ways to wire that up:

1. **Self-host the signer (full control).** Run `createSponsorshipSigner()` (or the lower-level `createJwtSigner()`) from `@rhinestone/1auth/server` behind two endpoints on your own backend, gated by your existing session auth. This is where you enforce per-user policy — spending limits, allowlists, the `shouldSponsor` filter. You decide exactly when and for whom a token is minted.

2. **Let the SDK drive it.** Point `OneAuthClient` at those two endpoint URLs (the `SponsorshipUrlConfig` form) and the SDK handles fetching, caching, expiry, and retry for you — including the `useSponsorship` React hook. You still host the endpoints (the signing key lives there), but you don't write any client-side token plumbing.

In short: the **signing** always lives on your backend; the **fetching** can either be your code (callback form) or the SDK's (URL form). Pick based on how much of the token lifecycle you want to own.

### How It Works

```mermaid
sequenceDiagram
    participant App as Your App
    participant BE as Your Backend
    participant SDK as @rhinestone/1auth
    participant PS as 1auth Passkey Service

    Note over App,SDK: 1. Configure the client with sponsorship URLs
    App->>SDK: new OneAuthClient({<br/>  sponsorship: {<br/>    accessTokenUrl,<br/>    extensionTokenUrl<br/>  }<br/>})

    Note over SDK,BE: 2. Prepare the intent
    SDK->>PS: POST /api/intent/prepare
    PS-->>SDK: { intentOp, digestResult, ... }

    Note over SDK,BE: 3. In parallel: fetch tokens (same-origin, with session cookie) + WebAuthn ceremony
    par
      SDK->>BE: GET /sponsorship/access-token
      BE-->>SDK: { token: "eyJhbG..." }
    and
      SDK->>BE: POST /sponsorship/extension-token { intentOp }
      BE-->>SDK: { token: "eyJhbG..." }
    and
      SDK->>SDK: WebAuthn signing ceremony
    end

    Note over SDK,PS: 4. Execute with pre-fetched tokens in request body
    SDK->>PS: POST /api/intent/execute<br/>{ signature, sponsorship: { accessToken, extensionToken } }
    PS-->>SDK: { intentId }
```

### The two tokens

App sponsorship is authorized by two short-lived JWTs your backend mints with `createSponsorshipSigner()` (or `createJwtSigner()`). Both are signed with your Rhinestone JWK — `ES256` for an EC key, `RS256` for RSA — and carry your `integratorId` (issuer), `projectId` (subject), and `appId`.

| Token | `typ` | TTL | Purpose |
|-------|-------|-----|---------|
| **Access token** | `access` | 1 hour | Identifies your app to the orchestrator. Not bound to any intent, so it's safe to cache and reuse across many intents (the SDK's `useSponsorship` hook does exactly this). |
| **Extension token** | `intent_extension` | 5 minutes | Authorizes sponsorship of **one specific intent**. Minted fresh per intent. |

The extension token is what makes sponsorship safe to expose to the browser: its `policy.sponsorship.intent_input.digest` claim is a **JCS-canonicalized ([RFC 8785](https://www.rfc-editor.org/rfc/rfc8785)) SHA-256 hash of the exact `intentOp`** being signed. The orchestrator recomputes that digest from the intent it receives and rejects the token if they differ — so a token minted for "send $5 to Alice" cannot be replayed to authorize "send $5000 to Mallory". Each extension token also carries a unique `jti`.

Because the digest pins the token to one intent, your backend's `extension-token` endpoint is the natural place to enforce per-user policy (spending limits, allowlists) — see the `shouldSponsor` filter on `createJwtSigner` / `createSponsorshipSigner`, which can veto by chain, account, or calls before a token is ever minted.

#### What the tokens actually contain

If you decode the JWTs your backend mints (e.g. paste into [jwt.io](https://jwt.io)), this is the shape. Both share the same header and `iss`/`sub`/`aud` claims — only `typ`, lifetime, and the intent-binding differ.

```jsonc
// Header (both tokens) — alg is ES256 for an EC key, RS256 for RSA
{ "alg": "ES256", "kid": "<keyId>" }

// Access token payload — app identity, reusable for 1h
{
  "typ": "access",
  "app_id": "<appId>",
  "iss": "<integratorId>",     // setIssuer
  "sub": "<projectId>",        // setSubject
  "aud": "rhinestone-api",     // override via JwtCredentials.audience
  "iat": 1719300000,
  "exp": 1719303600            // iat + 1h
}

// Extension token payload — authorizes ONE intent, expires in 5m
{
  "typ": "intent_extension",
  "app_id": "<appId>",
  "jti": "f47ac10b-58cc-4372-a567-0e02b2c3d479",  // unique per token
  "policy": {
    "sponsorship": {
      "scope": "intent",
      "intent_input": {
        // JCS-canonicalized SHA-256 of the exact intentOp; the orchestrator
        // recomputes this and rejects the token if it doesn't match.
        "digest": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
      }
    }
  },
  "iss": "<integratorId>",
  "sub": "<projectId>",
  "aud": "rhinestone-api",
  "iat": 1719300000,
  "exp": 1719300300            // iat + 5m
}
```

#### Token lifecycle & caching

The access token is reusable, so re-minting it on every intent is wasteful — but serving a *stale* one is worse. `useSponsorship` / `createCachedSponsorship` hold the access token in a closure and only reuse it while its `exp` is comfortably in the future:

- A **60-second safety margin** (`ACCESS_TOKEN_EXPIRY_MARGIN_MS`) is subtracted from `exp` before deciding a cached token is still usable. This absorbs clock skew between your app server and the orchestrator's verifier plus the in-flight time of the `prepare` call — a token that looks valid locally can still be rejected downstream if it expires mid-request.
- A token that can't be decoded, or carries no numeric `exp`, is treated as unusable so a malformed cache entry never gets served indefinitely.
- If the **extension-token** endpoint returns `401`, the cached access token is dropped immediately, so the next call re-mints rather than retrying with a token the orchestrator just rejected.
- The **extension token is never cached** — it binds to a specific `intentOp`, so a fresh one is minted per intent.

Both fetches use `credentials: "include"` so your app's own session cookie authenticates the user; no separate auth channel is needed.

#### Troubleshooting

| Symptom | Likely cause | Fix |
|---------|--------------|-----|
| `"Failed to get quote from orchestrator"` (a 401 surfacing as a quote failure) | The access token's `exp` lapsed before the orchestrator verified it (`"exp" claim timestamp check failed`) — usually a long-lived tab serving a stale cached token, or large clock skew | Use `useSponsorship` / `createCachedSponsorship` (they re-mint with the 60s margin) rather than hand-rolling a cache; check server clock sync |
| `401` from your own `access-token` / `extension-token` endpoint | Your session guard rejected the request (user not logged in, cookie not sent cross-origin) | Ensure the endpoints are **same-origin** with your app and that the client sends credentials; gate with your existing session auth |
| Sponsorship silently denied / `SponsorshipDeniedError` | A `shouldSponsor` filter vetoed the intent (chain/account/calls not allowed) | Confirm the intent matches your policy, or relax the filter |
| Orchestrator rejects the extension token despite a valid signature | `intent_input.digest` doesn't match — the `intentOp` was mutated between `prepare` and `execute`, or you minted the token from a different object than the SDK sent | Mint the extension token from the exact `intentOp` string the SDK posts to your endpoint; don't re-serialize or reorder it |
| `Unsupported JWK kty` / `Unsupported EC curve` at startup | The private key JWK isn't an EC (`P-256`/`P-384`/`P-521`) or RSA key | Use the JWK Rhinestone issued; `ES256` (EC P-256) is the common case |

### Why this shape

The SDK calls your token endpoints **from the browser, same-origin**. Your app's session cookie naturally authenticates the request — no need to pass bearer tokens through the SDK. The passkey service never fetches from your backend on your behalf, so there's no SSRF surface and no CORS config to worry about.

### Setup

**1. Get a signing key from the Rhinestone dashboard**

Create a Rhinestone app in the [Rhinestone dashboard](https://dashboard.rhinestone.dev) to generate a JWT signing key, then copy these values into your **server** environment (never the browser):

| Variable | Description |
|----------|-------------|
| `RHINESTONE_JWT_PRIVATE_KEY` | JSON-encoded JWK (EC P-256 / `ES256` recommended, RSA accepted) |
| `RHINESTONE_INTEGRATOR_ID` | Your integrator handle — becomes the JWT `iss` |
| `RHINESTONE_PROJECT_ID` | Project the app belongs to — becomes the JWT `sub` |
| `RHINESTONE_APP_ID` | App ID — becomes the JWT `app_id` claim |
| `RHINESTONE_KEY_ID` | Key ID registered with the orchestrator — the `kid` header |

`createSponsorshipSigner()` reads all five automatically; pass `{ credentials: { ... } }` to supply them explicitly instead.

**2. Create two endpoints on your backend, on the same origin as your app**

Gate these with your existing session auth — the user's cookie rides along automatically.

```typescript
// GET /api/sponsorship/access-token
import { createJwtSigner } from '@rhinestone/1auth/server';

const signer = createJwtSigner({
  jwt: {
    privateKey: JSON.parse(process.env.RHINESTONE_JWT_PRIVATE_KEY),
    integratorId: process.env.RHINESTONE_INTEGRATOR_ID,
    projectId: process.env.RHINESTONE_PROJECT_ID,
    appId: process.env.RHINESTONE_APP_ID,
    keyId: process.env.RHINESTONE_KEY_ID,
  },
});

export async function handleAccessToken(req, res) {
  const user = await verifySession(req); // your app's auth
  if (!user) return res.status(401).json({ error: 'Unauthorized' });
  const token = await signer.accessToken();
  res.json({ token });
}

// POST /api/sponsorship/extension-token
// Body: { intentOp: string }  — JSON-stringified intent operation
export async function handleExtensionToken(req, res) {
  const user = await verifySession(req);
  if (!user) return res.status(401).json({ error: 'Unauthorized' });

  const { intentOp } = req.body;
  const intentInput = JSON.parse(intentOp);

  // Enforce policies
  if (await exceedsSpendingLimit(user.id)) {
    return res.status(403).json({ error: 'Spending limit exceeded' });
  }

  const token = await signer.getIntentExtensionToken(intentInput);
  res.json({ token });
}
```

**3. Configure `OneAuthClient` with the URL form**

```typescript
import { OneAuthClient, createOneAuthProvider } from '@rhinestone/1auth';

const client = new OneAuthClient({
  providerUrl: 'https://passkey.1auth.app',
  sponsorship: {
    accessTokenUrl: '/api/sponsorship/access-token',
    extensionTokenUrl: '/api/sponsorship/extension-token',
  },
});

const provider = createOneAuthProvider({ client, chainId: 8453 });

const txHash = await provider.request({
  method: 'eth_sendTransaction',
  params: [{ to: '0x...', data: '0x...' }],
});
```

**React apps** can use the convenience hook:

```tsx
import { useSponsorship } from '@rhinestone/1auth/react';
import { OneAuthClient } from '@rhinestone/1auth';

function MyApp() {
  const sponsorship = useSponsorship({
    accessTokenUrl: '/api/sponsorship/access-token',
    extensionTokenUrl: '/api/sponsorship/extension-token',
  });

  const client = useMemo(
    () => new OneAuthClient({ providerUrl, sponsorship }),
    [sponsorship],
  );
  // ...
}
```

The hook caches the access token in a ref and refetches on 401 from the extension-token endpoint.

### SponsorshipConfig

`SponsorshipConfig` is a discriminated union — pick whichever form fits:

```typescript
// Convenience form — SDK does the fetching.
type SponsorshipUrlConfig = {
  accessTokenUrl: string;     // GET  → { token }
  extensionTokenUrl: string;  // POST { intentOp: string } → { token }
};

// Low-level form — you control token sourcing / refresh / caching.
type SponsorshipCallbackConfig = {
  accessToken: () => Promise<string>;
  getExtensionToken: (intentOp: string) => Promise<string>;
};

type SponsorshipConfig = SponsorshipUrlConfig | SponsorshipCallbackConfig;
```

The SDK pre-fetches both tokens in parallel with the WebAuthn signing ceremony and passes them to the passkey service via the execute request body. The passkey service never makes outbound requests to your backend.

### Without Sponsorship

If you don't pass `sponsorship`, 1auth sponsors intents on your behalf using its own API key. No changes needed — this is the default behavior.

## License

MIT
