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
8 changes: 7 additions & 1 deletion API_CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

### Added

- **2026-07-18** - Passwordless WebAuthn / passkey login
- New `fetch`-based endpoints `POST /api/v1/webauthn/register/start` + `/register/finish` (creates a passwordless account) and `POST /api/v1/webauthn/login/start` + `/login/finish` (usernameless / discoverable login). Each `start` returns a `challenge` for the browser `navigator.credentials` API plus an opaque signed `state`; the matching `finish` posts back that `state` with the credential and returns a `{ token, token_type, expires_in }` session response.
- Uses the same stateless session **JWT** as OAuth (`Authorization: Bearer <jwt>`), so passkey users reach every existing authenticated endpoint. Configured under a new `webauthn` config section (`rp-id`, `rp-origin`, `rp-name`); the signing secret lives in the shared `session` block (below).
- Passkey accounts are stored with a new `account_type` of `webauthn` and a synthetic identity (`sha256("webauthn\\0{user_handle}")`, in a namespace provably disjoint from OAuth). Like OAuth accounts they have no usable Nostr key: NIP-17 DMs, npub display and LIR signing are gated off, and `GET /api/v1/account` reports `account_type: "webauthn"`. Credentials are stored in the new `user_webauthn_credentials` table (one account may register several devices).
- **Add passkeys to existing accounts:** authenticated endpoints `GET /api/v1/webauthn/credentials` (list), `POST /api/v1/webauthn/credentials/start` + `/finish` (register another passkey to the current account — any account type, Nostr/OAuth/passkey), and `DELETE /api/v1/webauthn/credentials/{id}` (remove one). A later discoverable login with such a passkey resolves back to the same account (by credential id), so its session token carries the account's real identity. A pure passkey account cannot delete its only credential.

- **2026-07-18** - Generic OAuth / OIDC login (Google, GitHub, Facebook, Apple)
- New endpoints `GET /api/v1/oauth/{provider}/login` (redirects to the provider) and `GET`/`POST /api/v1/oauth/{provider}/callback` (exchanges the authorization code, resolves/creates the user and issues a session token). Providers are configured under the new `oauth` config section, each with a `type` of `google`, `github`, `facebook`, `apple`, or generic `oidc`.
- Built-in flavors handle each provider's quirks: GitHub's `User-Agent` requirement and numeric `id` subject, Facebook's Graph `me` endpoint, and Sign in with Apple's `id_token`-based subject, dynamically-signed **ES256** client secret, and `form_post` (POST) callback.
- After a successful login the API issues a stateless session **JWT**. It is accepted on every existing authenticated endpoint via `Authorization: Bearer <jwt>`, alongside the existing `Authorization: Nostr <event>` (NIP-98) scheme.
- After a successful login the API issues a stateless session **JWT**. It is accepted on every existing authenticated endpoint via `Authorization: Bearer <jwt>`, alongside the existing `Authorization: Nostr <event>` (NIP-98) scheme. The JWT signing secret and lifetime live in a shared top-level `session` config block (`session.secret`, `session.ttl`) used by both OAuth and WebAuthn — required whenever either login method is enabled.
- On first login the provider's email is synced into the account (marked verified when the provider asserts it) and email notifications are enabled by default, since OAuth accounts have no NIP-17 channel. GitHub's primary verified address is fetched from `/user/emails`; Apple's email comes from the `id_token`. The sync is non-destructive — a user's later email edits are not overwritten — and best-effort (a sync failure never blocks login).
- OAuth accounts are stored with a new `account_type` of `oauth` and a synthetic identity (`sha256("{provider}:{subject}")`) in place of a Nostr pubkey. Nostr-only features (NIP-17 DMs, npub display, LIR agreement signing) are gated to native Nostr accounts.
- `GET /api/v1/account` now returns `account_type` (`nostr` | `oauth`, read-only) so the frontend can hide Nostr-only UI for OAuth users. `PATCH /api/v1/account` rejects enabling `contact_nip17` for OAuth accounts (their pubkey is not a usable Nostr key).
Expand Down
108 changes: 107 additions & 1 deletion API_DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,60 @@ On first OAuth login the provider's email is synced into the account (and marked
verified when the provider asserts it), so `AccountInfo.email` /
`email_verified` may already be populated for these users.

### Passkey (WebAuthn) login

Passwordless login with a platform passkey (Face ID / Touch ID / Windows Hello)
or a security key. Like OAuth, a passkey **is** the account (`account_type` is
`oauth`-style synthetic — see the note below) and login yields the same
`Bearer` session token. Unlike OAuth this is a **`fetch`/XHR flow** driven by the
browser's `navigator.credentials` API, not a redirect.

Each ceremony is two calls — `start` returns a challenge plus an opaque signed
`state`; you run the WebAuthn browser API, then post the result back with that
same `state` to `finish`, which returns the session token.

**Register (new account):**

```typescript
import { startRegistration } from '@simplewebauthn/browser';

// 1. begin
const start = await fetch(`${API}/api/v1/webauthn/register/start`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'my device' }), // optional label
}).then(r => r.json());
// start.data = { challenge, state }

// 2. run the authenticator (challenge.publicKey per the WebAuthn spec)
const credential = await startRegistration(start.data.challenge.publicKey);

// 3. finish -> session token
const done = await fetch(`${API}/api/v1/webauthn/register/finish`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ state: start.data.state, credential }),
}).then(r => r.json());
localStorage.setItem('session_token', done.data.token); // { token, token_type, expires_in }
```

**Login (existing passkey, usernameless):**

```typescript
import { startAuthentication } from '@simplewebauthn/browser';

const start = await fetch(`${API}/api/v1/webauthn/login/start`, { method: 'POST' })
.then(r => r.json()); // { challenge, state }
const credential = await startAuthentication(start.data.challenge.publicKey);
const done = await fetch(`${API}/api/v1/webauthn/login/finish`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ state: start.data.state, credential }),
}).then(r => r.json());
localStorage.setItem('session_token', done.data.token);
```

The signed `state` must be round-tripped unchanged and expires after ~5 minutes.
Passkey accounts are Nostr-less exactly like OAuth accounts — hide npub / NIP-17
UI for them (they are reported with `account_type` distinct from `nostr`).

## Core Data Types

### User Account
Expand All @@ -113,7 +167,7 @@ verified when the provider asserts it), so `AccountInfo.email` /
interface AccountInfo {
email?: string;
email_verified?: boolean; // Present when email is set; true if email has been verified
account_type: 'nostr' | 'oauth'; // Read-only. 'oauth' accounts have no usable Nostr key — hide npub / NIP-17 UI. Enabling contact_nip17 for an 'oauth' account is rejected.
account_type: 'nostr' | 'oauth' | 'webauthn'; // Read-only. Only 'nostr' has a usable Nostr key — hide npub / NIP-17 UI for 'oauth' and 'webauthn'. Enabling contact_nip17 for a non-'nostr' account is rejected.
contact_nip17: boolean;
contact_email: boolean;
country_code?: string; // ISO 3166-1 alpha-3 country code
Expand Down Expand Up @@ -433,6 +487,58 @@ interface VmUpgradeQuote {

## API Endpoints

### Passkey (WebAuthn) Authentication

Unauthenticated `fetch` endpoints (JSON in/out) for passwordless passkey login.
See [Authentication](#authentication-types) for the full flow and browser
examples. Each ceremony is a `start` then `finish` pair; the opaque signed
`state` from `start` must be posted back to `finish` unchanged.

#### Register Start
- **POST** `/api/v1/webauthn/register/start`
- **Auth**: None
- **Body**: `{ name?: string }` (optional device label)
- **Response**: `{ challenge: PublicKeyCredentialCreationOptions, state: string }`

#### Register Finish
- **POST** `/api/v1/webauthn/register/finish`
- **Auth**: None
- **Body**: `{ state: string, credential: RegisterPublicKeyCredential, name?: string }`
- **Response**: `{ token: string, token_type: "Bearer", expires_in: number }` (creates the account)

#### Login Start
- **POST** `/api/v1/webauthn/login/start`
- **Auth**: None
- **Body**: none (usernameless / discoverable)
- **Response**: `{ challenge: PublicKeyCredentialRequestOptions, state: string }`

#### Login Finish
- **POST** `/api/v1/webauthn/login/finish`
- **Auth**: None
- **Body**: `{ state: string, credential: PublicKeyCredential }`
- **Response**: `{ token: string, token_type: "Bearer", expires_in: number }`

#### Manage passkeys on the current account

These endpoints are **authenticated** (any scheme — a Nostr, OAuth or passkey
user can add passkeys to their own account). A passkey added here is stored
against the current account, so a later discoverable login with it resolves back
to that same account (its session token then carries the account's real
identity — e.g. a Nostr user's npub still works).

- **GET** `/api/v1/webauthn/credentials` — list this account's passkeys.
Response: `Array<{ id: number, name?: string, created: string, last_used?: string }>`
- **POST** `/api/v1/webauthn/credentials/start` — begin adding a passkey.
Body `{ name?: string }`; Response `{ challenge, state }` (already excludes
credentials registered to this account). Run `startRegistration(...)` then:
- **POST** `/api/v1/webauthn/credentials/finish` — Body
`{ state, credential, name? }`; Response is the created
`{ id, name?, created, last_used? }` (no session token — you are already
logged in).
- **DELETE** `/api/v1/webauthn/credentials/{id}` — remove a passkey. A pure
passkey account (`account_type: "webauthn"`) cannot delete its **only**
credential (that would lock the account out).

### OAuth Authentication

These endpoints are **unauthenticated** and drive full-page browser navigation
Expand Down
Loading
Loading