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

## [Unreleased]

### Added

- **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.
- 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).

### Changed

- **2026-07-18** - Drop npub from invoices and VM-created notifications
- VM-created notifications (user + admin) no longer include the `NPUB:` line — it was noise and meaningless for OAuth accounts.
- The rendered invoice replaces the `Nostr Pubkey` line with the account `Email` (shown only when set), which is a universal identifier across Nostr and OAuth accounts.

### Fixed

- **2026-07-18** - Referral commission rate not visible to referrers (user API)
Expand Down
97 changes: 94 additions & 3 deletions API_DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ This document provides comprehensive API specifications for generating TypeScrip
## Base Configuration

- **Base URL**: `https://api.lnvps.com` (replace with actual production URL)
- **Authentication**: NIP-98 (Nostr) authentication required for all authenticated endpoints
- **Authentication**: either NIP-98 (Nostr) **or** an OAuth session token (see [Authentication](#authentication-types)) on all authenticated endpoints
- **Content Type**: `application/json`
- **Error Response Format**: `{ "error": "Error message" }`
- **Success Response Format**: `{ "data": <response_data> }`
Expand All @@ -29,14 +29,82 @@ This document provides comprehensive API specifications for generating TypeScrip

## Authentication Types

Every authenticated endpoint accepts **either** of two `Authorization` schemes.
You never mix them on a single request — pick whichever the logged-in user has.

```typescript
// All authenticated endpoints require NIP-98 authentication headers
interface AuthHeaders {
'Authorization': string; // Base64 encoded NIP-98 event
// Nostr accounts: "Nostr <base64 NIP-98 event>"
// OAuth accounts: "Bearer <session JWT>"
'Authorization': string;
'Content-Type': 'application/json';
}
```

- **Nostr (NIP-98)** — `Authorization: Nostr <base64-event>`. Unchanged; used by
users who log in with a Nostr key.
- **OAuth session token** — `Authorization: Bearer <jwt>`. Obtained from the
OAuth login flow below. The token is opaque to the frontend: store it and
echo it back on every request.

### OAuth login flow (Google / GitHub / Facebook / Apple)

External login is a full-page browser redirect through the provider, ending back
at your app with a session token. The frontend never sees the provider or the
authorization code — only the final token.

```
[React] click "Login with Google"
→ window.location = `${API}/api/v1/oauth/google/login`
→ provider consent screen
→ provider redirects to `${API}/api/v1/oauth/google/callback` (registered with the provider, NOT your app)
→ API exchanges the code, creates/updates the account, issues a JWT
→ 302 to your configured success-redirect: `https://app.example.com/oauth/complete#token=<jwt>`
```

The token is delivered in the URL **fragment** (`#token=…`) so it is never sent
to or logged by any server. Provider tags are `google`, `github`, `facebook`,
`apple` (whichever are enabled server-side).

**1. Start login** (plain navigation, not `fetch`):

```typescript
const API = import.meta.env.VITE_API_URL;
function startLogin(provider: 'google' | 'github' | 'facebook' | 'apple') {
window.location.href = `${API}/api/v1/oauth/${provider}/login`;
}
```

**2. Handle the landing route** (`/oauth/complete`): read the fragment, store the
token, scrub the URL:

```typescript
const params = new URLSearchParams(window.location.hash.slice(1)); // drop '#'
const token = params.get('token');
if (token) {
localStorage.setItem('session_token', token);
window.history.replaceState({}, '', window.location.pathname); // remove token from history
// navigate to your authenticated area
}
```

**3. Call authenticated endpoints** with the token:

```typescript
fetch(`${API}/api/v1/account`, {
headers: { Authorization: `Bearer ${localStorage.getItem('session_token')}` },
});
```

> If the server is configured **without** a `success-redirect`, the callback
> instead returns JSON `{ "data": { "token": string, "token_type": "Bearer",
> "expires_in": number } }` — suitable for a popup/`postMessage` flow. The
> redirect-fragment flow above is recommended for a standard SPA.

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.

## Core Data Types

### User Account
Expand All @@ -45,6 +113,7 @@ interface AuthHeaders {
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.
contact_nip17: boolean;
contact_email: boolean;
country_code?: string; // ISO 3166-1 alpha-3 country code
Expand Down Expand Up @@ -364,6 +433,28 @@ interface VmUpgradeQuote {

## API Endpoints

### OAuth Authentication

These endpoints are **unauthenticated** and drive full-page browser navigation
(not `fetch`/XHR). See [Authentication](#authentication-types) for the full flow
and React example. `{provider}` is one of the enabled tags (`google`, `github`,
`facebook`, `apple`).

#### Start Login
- **GET** `/api/v1/oauth/{provider}/login`
- **Auth**: None
- **Behavior**: 302 redirect to the provider's consent screen. Navigate the
browser here (e.g. `window.location.href = ...`).

#### Login Callback
- **GET/POST** `/api/v1/oauth/{provider}/callback`
- **Auth**: None
- **Behavior**: Handled by the provider redirect (POST for Apple `form_post`).
On success, either 302-redirects to the server's configured `success-redirect`
with the token in the URL fragment (`#token=<jwt>`), or \u2014 if no redirect is
configured \u2014 returns `{ "data": { "token": string, "token_type": "Bearer",
"expires_in": number } }`. The frontend does not call this directly.

### Account Management

#### Get Account Information
Expand Down
4 changes: 4 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions lnvps_api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ chrono = { version = "0.4", features = ["serde"] }
base64 = { version = "0.22", features = ["alloc"] }
urlencoding = "2.1"
rand = "0.9"
# ES256 signing for the "Sign in with Apple" client-secret JWT (.p8 key).
p256 = { version = "0.13", features = ["ecdsa", "pkcs8", "pem"] }
clap = { version = "4.5", features = ["derive"] }
ssh-key = "0.6"
lettre = { version = "0.11", features = ["tokio1-native-tls"] }
Expand Down
50 changes: 50 additions & 0 deletions lnvps_api/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,53 @@ captcha:
# message-template-lang: "en"
# verify-template: "lnvps_verification"
# verify-template-lang: "en"
# Generic OAuth / OIDC login (optional). When set, users can log in via
# external identity providers and receive a session JWT that is accepted on
# authenticated endpoints as `Authorization: Bearer <jwt>` (alongside NIP-98).
#oauth:
# # Strong, stable random secret used to sign session tokens. Changing it
# # invalidates all outstanding sessions.
# session-secret: "change-me-to-a-long-random-string"
# # Session lifetime in seconds (default 30 days).
# session-ttl: 2592000
# # Optional: after login, redirect the browser here with `#token=<jwt>`.
# # When omitted, the callback returns the token as JSON.
# success-redirect: "https://app.example.com/oauth/complete"
# # Providers are keyed by a stable tag (part of the account identity). Each
# # has a `type` selecting the flavor; built-in flavors supply default
# # endpoints/scopes so usually only credentials are needed.
# providers:
# google:
# type: google
# client-id: "xxxx.apps.googleusercontent.com"
# client-secret: "xxxx"
# github:
# type: github
# client-id: "Iv1.xxxx"
# client-secret: "xxxx"
# facebook:
# type: facebook
# client-id: "1234567890"
# client-secret: "xxxx"
# apple:
# type: apple
# client-id: "com.example.service" # Services ID
# team-id: "ABCDE12345"
# key-id: "XYZ1234567"
# # PEM contents of the downloaded .p8 key
# private-key: |
# -----BEGIN PRIVATE KEY-----
# MIGTAgEA...
# -----END PRIVATE KEY-----
# # Requesting name/email switches Apple to a POST (form_post) callback:
# #scopes: ["name", "email"]
# # Fully generic provider (explicit endpoints required):
# #keycloak:
# # type: oidc
# # client-id: "xxxx"
# # client-secret: "xxxx"
# # auth-url: "https://id.example.com/authorize"
# # token-url: "https://id.example.com/token"
# # userinfo-url: "https://id.example.com/userinfo"
# # scopes: ["openid", "email"]
# # subject-field: "sub"
6 changes: 4 additions & 2 deletions lnvps_api/invoice.html
Original file line number Diff line number Diff line change
Expand Up @@ -270,10 +270,12 @@ <h2>Invoice</h2>
{{#payment.is_upgrade}}Upgrade{{/payment.is_upgrade}}
{{^payment.is_upgrade}}Renewal{{/payment.is_upgrade}}
</div>
{{#has_email}}
<div>
<b>Nostr Pubkey:</b>
{{npub}}
<b>Email:</b>
{{email}}
</div>
{{/has_email}}
</div>
<div class="billing">
<div class="flex-col">
Expand Down
7 changes: 6 additions & 1 deletion lnvps_api/src/api/legal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,12 @@ async fn v1_generate_lir_agreement_from_subscription(
use nostr::{EventBuilder, Keys, Kind, Tag, TagKind, TagStandard, ToBech32};
use nostr_sdk::PublicKey as NostrSdkPublicKey;

let end_user_pubkey = auth.event.pubkey;
// This agreement embeds a Nostr-signed cryptographic proof that references
// the end user's Nostr public key, so it is only available to native Nostr
// accounts. OAuth accounts have no usable Nostr key.
let end_user_pubkey = auth
.nostr_pubkey()
.ok_or_else(|| ApiError::forbidden("This agreement requires a Nostr account"))?;
let pubkey_bytes = end_user_pubkey.to_bytes();
let uid = this.db.upsert_user(&pubkey_bytes).await?;
let user = this.db.get_user(uid).await?;
Expand Down
2 changes: 2 additions & 0 deletions lnvps_api/src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ mod legal;
mod model;
#[cfg(feature = "nostr-domain")]
mod nostr_domain;
mod oauth;
mod referral;
mod routes;
mod subscriptions;
Expand All @@ -22,6 +23,7 @@ use lnvps_api_common::{
use lnvps_db::LNVpsDb;
#[cfg(feature = "nostr-domain")]
pub use nostr_domain::router as nostr_domain_router;
pub use oauth::router as oauth_router;
pub use referral::router as referral_router;
pub use routes::routes as main_router;
use serde::Deserialize;
Expand Down
25 changes: 25 additions & 0 deletions lnvps_api/src/api/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,12 @@ pub struct AccountPatchRequest {
/// Whether the email address has been verified (read-only, ignored on PATCH)
#[serde(skip_deserializing, skip_serializing_if = "Option::is_none")]
pub email_verified: Option<bool>,
/// Account authentication type: `nostr` (native Nostr key) or `oauth`
/// (external login; `pubkey` is a synthetic id, not a usable Nostr key).
/// Read-only, ignored on PATCH. Use it to hide Nostr-only UI (npub display,
/// NIP-17 DM settings) for OAuth accounts.
#[serde(skip_deserializing)]
pub account_type: String,
pub contact_nip17: bool,
pub contact_email: bool,
#[serde(default)]
Expand Down Expand Up @@ -257,6 +263,7 @@ impl From<lnvps_db::User> for AccountPatchRequest {
None
},
email_verified: has_email.then_some(user.email_verified),
account_type: user.account_type.to_string(),
contact_nip17: user.contact_nip17,
contact_email: user.contact_email,
contact_telegram: user.contact_telegram,
Expand Down Expand Up @@ -1314,6 +1321,24 @@ pub enum ApiCreateSubscriptionLineItemRequest {
mod tests {
use super::*;

#[test]
fn test_account_response_exposes_account_type() {
let mut user = lnvps_db::User {
account_type: lnvps_db::AccountType::Nostr,
..Default::default()
};
let resp: AccountPatchRequest = user.clone().into();
assert_eq!(resp.account_type, "nostr");

user.account_type = lnvps_db::AccountType::OAuth;
let resp: AccountPatchRequest = user.into();
assert_eq!(resp.account_type, "oauth");

// Serialized field is present for the frontend.
let json = serde_json::to_value(&resp).unwrap();
assert_eq!(json["account_type"], "oauth");
}

#[test]
fn test_from_payment_data_fiat() {
// EUR: amounts are in cents, time in seconds (30 days)
Expand Down
10 changes: 5 additions & 5 deletions lnvps_api/src/api/nostr_domain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ async fn v1_nostr_domains(
auth: Nip98Auth,
State(this): State<RouterState>,
) -> ApiResult<ApiDomainsResponse> {
let pubkey = auth.event.pubkey.to_bytes();
let pubkey = auth.pubkey();
let uid = this.db.upsert_user(&pubkey).await?;

let domains = this.db.list_domains(uid).await?;
Expand All @@ -45,7 +45,7 @@ async fn v1_create_nostr_domain(
State(this): State<RouterState>,
Json(data): Json<NameRequest>,
) -> ApiResult<ApiNostrDomain> {
let pubkey = auth.event.pubkey.to_bytes();
let pubkey = auth.pubkey();
let uid = this.db.upsert_user(&pubkey).await?;

let mut dom = NostrDomain {
Expand All @@ -65,7 +65,7 @@ async fn v1_list_nostr_domain_handles(
State(this): State<RouterState>,
Path(dom): Path<u64>,
) -> ApiResult<Vec<ApiNostrDomainHandle>> {
let pubkey = auth.event.pubkey.to_bytes();
let pubkey = auth.pubkey();
let uid = this.db.upsert_user(&pubkey).await?;

let domain = this.db.get_domain(dom).await?;
Expand All @@ -83,7 +83,7 @@ async fn v1_create_nostr_domain_handle(
Path(dom): Path<u64>,
data: Json<HandleRequest>,
) -> ApiResult<ApiNostrDomainHandle> {
let pubkey = auth.event.pubkey.to_bytes();
let pubkey = auth.pubkey();
let uid = this.db.upsert_user(&pubkey).await?;

let domain = this.db.get_domain(dom).await?;
Expand Down Expand Up @@ -115,7 +115,7 @@ async fn v1_delete_nostr_domain_handle(
Path(dom): Path<u64>,
Path(handle): Path<u64>,
) -> ApiResult<()> {
let pubkey = auth.event.pubkey.to_bytes();
let pubkey = auth.pubkey();
let uid = this.db.upsert_user(&pubkey).await?;

let domain = this.db.get_domain(dom).await?;
Expand Down
Loading
Loading