Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
name: Check

on:
pull_request:
push:
branches: [main]

permissions:
contents: read

jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- uses: oven-sh/setup-bun@v2
- run: bun install --frozen-lockfile
- run: bun run typecheck
- run: bun run test
- run: bun run build
env:
NEXT_TELEMETRY_DISABLED: 1
27 changes: 25 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

A runnable Next.js example for collecting credentials with [`@onkernel/vault-react`](https://www.npmjs.com/package/@onkernel/vault-react). The browser renders Kernel's unstyled credential form while a same-origin backend keeps the Kernel API key private and maps one-time bearer capabilities to vault items.

It includes two flows that share the same backend pieces:

- **`/credentials/collect`**: the page sends entered values to this backend, which forwards them to Kernel as `value`.
- **`/credentials/collect-encrypted`**: the page encrypts values in the browser to the vault's public key, this backend forwards the ciphertext as `encrypted_value`, and only Kernel can decrypt it. Use this when plaintext must never reach your backend, proxies, or request logs. See [Encrypted values](#encrypted-values).

## Run the mock flow

Requires Bun and Node.js 20.9 or newer.
Expand All @@ -18,7 +23,7 @@ In a second terminal, create a 15-minute collection link:
bun run create-request
```

Open the printed URL. Mock mode exercises the complete browser/backend flow without calling Kernel. Do not enter real credentials in mock mode.
Open the printed URL. Add `--encrypted` (`bun run create-request -- --encrypted`) for a link to the encrypted flow. Mock mode exercises the complete browser/backend flow, including encryption, without calling Kernel. Do not enter real credentials in mock mode.

## Connect a Kernel vault item

Expand All @@ -28,6 +33,7 @@ Open the printed URL. Mock mode exercises the complete browser/backend flow with

```sh
bun run create-request -- <vault-id-or-name> <item-key>
bun run create-request -- --encrypted <vault-id-or-name> <item-key>
```

The command retrieves the item's immutable ID, generates 32 random bytes, stores only the SHA-256 token digest, and prints the raw token once in this form:
Expand All @@ -43,12 +49,29 @@ The fragment is not sent in page requests or referrers. The page sends it only i
- `app/credentials/collect` reads and clears the fragment capability and renders `CredentialForm`.
- `app/api/credential-requests/current` serves safe item data and accepts versioned field edits.
- `lib/collection-handler.ts` validates the bearer, origin, target, item ID, version, field names, value sizes, and body size.
- `lib/vault-client.ts` contains real and mock vault adapters. Only the real adapter receives `KERNEL_API_KEY`.
- `lib/vault-client.ts` contains real and mock vault adapters. Only the real adapter receives `KERNEL_API_KEY`. The mock adapter generates its own key pair and decrypts `encrypted_value` the way Kernel does.
- `app/credentials/collect-encrypted` and `app/api/credential-requests/current-encrypted` are the encrypted flow. `lib/encrypted-submission.ts` turns a `CredentialForm` submission into `encrypted_value` fields with [`jose`](https://github.com/panva/jose).
- `lib/request-store.ts` defines the durable `CollectionRequestStore` boundary.
- `scripts/create-request.ts` creates a short-lived link without persisting the raw token.

Successful submissions mark the mapping consumed. Kernel writes include both the rendered version and immutable item ID, so a stale form or a deleted-and-recreated key cannot redirect a write.

## Encrypted values

The encrypted flow differs from the default flow in three places:

1. `GET /api/credential-requests/current-encrypted` returns `{ item, encryption_key }`. The backend gets the key from `kernel.vaults.retrieveEncryptionKey(vaultId)` (`GET /vaults/{id_or_name}/encryption_key`). Each vault has its own P-256 key; it is public and stable for the vault's lifetime.
2. On submit, the page encrypts every entered value as a compact JWE (`alg: ECDH-ES`, `enc: A256GCM`, the vault's `kid` in the header). Clearing an optional field stays `{ "value": null }`.
3. The backend passes each field through unchanged, and `kernel.vaults.items.update` sends it to Kernel, which decrypts and validates it exactly like `value`:

```json
{ "version": 3, "fields": { "password": { "encrypted_value": "eyJhbGciOiJFQ0RILUVTIi..." } } }
```

The encrypted route rejects plaintext `value` strings, so a misconfigured page cannot silently fall back to plaintext. Kernel rejects ciphertext encrypted for a different vault with `400 encryption_key_mismatch`, which this example reports as `invalid`.

Requires `@onkernel/sdk` 0.122.0 or newer and HTTPS (WebCrypto needs a secure context; `localhost` also works). The page's JavaScript still sees raw values before encrypting them, so keep the collection route free of third-party scripts. If your backend already holds plaintext, use the default flow.

## Use a production store

`FileCollectionRequestStore` is intentionally local-only. Its atomic file replacement prevents torn files, but it does not coordinate multiple processes and most serverless filesystems are ephemeral.
Expand Down
12 changes: 12 additions & 0 deletions app/api/credential-requests/current-encrypted/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { getCollectionHandler } from '@/lib/runtime';

export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';

export function GET(request: Request) {
return getCollectionHandler(true)(request);
}

export function PATCH(request: Request) {
return getCollectionHandler(true)(request);
}
121 changes: 121 additions & 0 deletions app/credentials/collect-encrypted/collection-page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
'use client';

import { useEffect, useState } from 'react';
import {
CredentialForm,
CredentialFormError,
safeCredentialItem,
type CollectionErrorCode,
type CredentialItem,
} from '@onkernel/vault-react';
import { readCollectionToken } from '@/lib/collection-token';
import { encryptSubmission, type VaultEncryptionKey } from '@/lib/encrypted-submission';

const endpoint = '/api/credential-requests/current-encrypted';
const errorCodes = new Set<CollectionErrorCode>([
'stale',
'expired',
'consumed',
'unavailable',
'invalid',
]);

async function responseError(response: Response, fallback: CollectionErrorCode) {
const body: unknown = await response.json().catch(() => null);
const code = body && typeof body === 'object' ? (body as { code?: unknown }).code : undefined;
return typeof code === 'string' && errorCodes.has(code as CollectionErrorCode)
? (code as CollectionErrorCode)
: fallback;
}

export function CollectionPage() {
const [token, setToken] = useState<string | null>();

useEffect(() => {
setToken(readCollectionToken(window.location.hash));
const reload = () => window.location.reload();
window.addEventListener('hashchange', reload);
return () => window.removeEventListener('hashchange', reload);
}, []);

if (token === undefined) return null;
if (!token) return <Message>collection link unavailable.</Message>;
return <RequestForm token={token} />;
}

function RequestForm({ token }: { token: string }) {
const [collection, setCollection] = useState<{ item: CredentialItem; key: VaultEncryptionKey }>();
const [failure, setFailure] = useState<CollectionErrorCode>();
const authorization = `Bearer ${token}`;

useEffect(() => {
const controller = new AbortController();
void fetch(endpoint, {
credentials: 'omit',
cache: 'no-store',
headers: { Authorization: authorization },
signal: controller.signal,
})
.then(async (response) => {
if (!response.ok) throw new CredentialFormError(await responseError(response, 'unavailable'));
const body = await response.json();
const item = safeCredentialItem(body.item);
const key = vaultEncryptionKey(body.encryption_key);
if (!controller.signal.aborted) setCollection({ item, key });
})
.catch((error: unknown) => {
if (!controller.signal.aborted) {
setFailure(error instanceof CredentialFormError ? error.code : 'unavailable');
}
});
return () => controller.abort();
}, [authorization]);

if (failure) {
const messages: Partial<Record<CollectionErrorCode, string>> = {
expired: 'this collection link expired. request a new link.',
consumed: 'this collection was already completed.',
};
return <Message>{messages[failure] ?? 'collection unavailable. request a new link.'}</Message>;
}
if (!collection) return <p role="status">loading credential fields…</p>;

return (
<CredentialForm
className="customer-credential-form"
item={collection.item}
submitLabel="Save credentials"
onSubmit={async (submission) => {
const response = await fetch(endpoint, {
method: 'PATCH',
credentials: 'omit',
cache: 'no-store',
headers: { Authorization: authorization, 'Content-Type': 'application/json' },
body: JSON.stringify(await encryptSubmission(submission, collection.key)),
});
if (!response.ok) {
throw new CredentialFormError(await responseError(response, 'unavailable'));
}
window.history.replaceState(null, '', window.location.pathname);
}}
/>
);
}

function vaultEncryptionKey(value: unknown): VaultEncryptionKey {
const key = value as VaultEncryptionKey | undefined;
if (
typeof key?.kid !== 'string' ||
key.jwk?.kty !== 'EC' ||
key.jwk.crv !== 'P-256' ||
typeof key.jwk.x !== 'string' ||
typeof key.jwk.y !== 'string'
) {
throw new CredentialFormError('unavailable');
}
return { kid: key.kid, jwk: { kty: 'EC', crv: 'P-256', x: key.jwk.x, y: key.jwk.y } };
}

function Message({ children }: { children: React.ReactNode }) {
return <p role="alert">{children}</p>;
}
14 changes: 14 additions & 0 deletions app/credentials/collect-encrypted/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { CollectionPage } from './collection-page';

export default function Page() {
return (
<main className="collection-shell">
<section className="collection-card">
<p className="eyebrow">Secure credential request</p>
<h1>Connect your account</h1>
<p className="intro">Your credentials are encrypted in this page. Only Kernel can decrypt them.</p>
<CollectionPage />
</section>
</main>
);
}
2 changes: 1 addition & 1 deletion app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export default function Home() {
<h1>Customer-hosted credential collection</h1>
<p className="intro">
Generate a one-time collection link with <code>bun run create-request</code>, then open the
printed URL.
printed URL. Add <code>--encrypted</code> for the flow that encrypts values in the browser.
</p>
</section>
</main>
Expand Down
Loading
Loading