diff --git a/API_CHANGELOG.md b/API_CHANGELOG.md index cd75e14b..ce1c343f 100644 --- a/API_CHANGELOG.md +++ b/API_CHANGELOG.md @@ -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 `, alongside the existing `Authorization: Nostr ` (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) diff --git a/API_DOCUMENTATION.md b/API_DOCUMENTATION.md index 06e76aea..89702018 100644 --- a/API_DOCUMENTATION.md +++ b/API_DOCUMENTATION.md @@ -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": }` @@ -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 " + // OAuth accounts: "Bearer " + 'Authorization': string; 'Content-Type': 'application/json'; } ``` +- **Nostr (NIP-98)** — `Authorization: Nostr `. Unchanged; used by + users who log in with a Nostr key. +- **OAuth session token** — `Authorization: Bearer `. 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=` +``` + +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 @@ -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 @@ -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=`), 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 diff --git a/Cargo.lock b/Cargo.lock index f5fa1979..0f9115ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1243,6 +1243,7 @@ dependencies = [ "ff", "generic-array", "group", + "pem-rfc7468", "pkcs8", "rand_core 0.6.4", "sec1", @@ -2803,6 +2804,7 @@ dependencies = [ "nostr-sdk", "nwc", "openapi-build-gen", + "p256", "payments-rs", "quick-xml", "rand 0.9.4", @@ -2860,6 +2862,7 @@ dependencies = [ "env_logger", "futures", "hex", + "hmac", "ipnetwork", "isocountry", "lnvps_db", @@ -2873,6 +2876,7 @@ dependencies = [ "serde", "serde_json", "sha1", + "sha2", "sqlx", "tokio", "try-procedure", diff --git a/lnvps_api/Cargo.toml b/lnvps_api/Cargo.toml index a69de6f9..578d104a 100644 --- a/lnvps_api/Cargo.toml +++ b/lnvps_api/Cargo.toml @@ -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"] } diff --git a/lnvps_api/config.yaml b/lnvps_api/config.yaml index 94901c11..59f1eab1 100644 --- a/lnvps_api/config.yaml +++ b/lnvps_api/config.yaml @@ -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 ` (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=`. +# # 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" diff --git a/lnvps_api/invoice.html b/lnvps_api/invoice.html index eff9ba54..7ebff6ac 100644 --- a/lnvps_api/invoice.html +++ b/lnvps_api/invoice.html @@ -270,10 +270,12 @@

Invoice

{{#payment.is_upgrade}}Upgrade{{/payment.is_upgrade}} {{^payment.is_upgrade}}Renewal{{/payment.is_upgrade}} + {{#has_email}}
- Nostr Pubkey: - {{npub}} + Email: + {{email}}
+ {{/has_email}}
diff --git a/lnvps_api/src/api/legal.rs b/lnvps_api/src/api/legal.rs index e7f3ca86..f7981395 100644 --- a/lnvps_api/src/api/legal.rs +++ b/lnvps_api/src/api/legal.rs @@ -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?; diff --git a/lnvps_api/src/api/mod.rs b/lnvps_api/src/api/mod.rs index 311206c5..1c29e18d 100644 --- a/lnvps_api/src/api/mod.rs +++ b/lnvps_api/src/api/mod.rs @@ -5,6 +5,7 @@ mod legal; mod model; #[cfg(feature = "nostr-domain")] mod nostr_domain; +mod oauth; mod referral; mod routes; mod subscriptions; @@ -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; diff --git a/lnvps_api/src/api/model.rs b/lnvps_api/src/api/model.rs index 06787200..c707a528 100644 --- a/lnvps_api/src/api/model.rs +++ b/lnvps_api/src/api/model.rs @@ -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, + /// 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)] @@ -257,6 +263,7 @@ impl From 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, @@ -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) diff --git a/lnvps_api/src/api/nostr_domain.rs b/lnvps_api/src/api/nostr_domain.rs index 18f6d90b..16e42578 100644 --- a/lnvps_api/src/api/nostr_domain.rs +++ b/lnvps_api/src/api/nostr_domain.rs @@ -30,7 +30,7 @@ async fn v1_nostr_domains( auth: Nip98Auth, State(this): State, ) -> ApiResult { - 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?; @@ -45,7 +45,7 @@ async fn v1_create_nostr_domain( State(this): State, Json(data): Json, ) -> ApiResult { - let pubkey = auth.event.pubkey.to_bytes(); + let pubkey = auth.pubkey(); let uid = this.db.upsert_user(&pubkey).await?; let mut dom = NostrDomain { @@ -65,7 +65,7 @@ async fn v1_list_nostr_domain_handles( State(this): State, Path(dom): Path, ) -> 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?; @@ -83,7 +83,7 @@ async fn v1_create_nostr_domain_handle( Path(dom): Path, data: Json, ) -> 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?; @@ -115,7 +115,7 @@ async fn v1_delete_nostr_domain_handle( Path(dom): Path, Path(handle): Path, ) -> 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?; diff --git a/lnvps_api/src/api/oauth.rs b/lnvps_api/src/api/oauth.rs new file mode 100644 index 00000000..7bfe3332 --- /dev/null +++ b/lnvps_api/src/api/oauth.rs @@ -0,0 +1,627 @@ +//! Generic OAuth2 / OIDC login with built-in support for Google, GitHub, +//! Facebook and Sign in with Apple (plus a fully generic `oidc` flavor). +//! +//! On success the user is looked up/created via their synthetic identity +//! (`sha256("{provider}:{subject}")`, see [`lnvps_db::oauth_pubkey`]) and issued +//! a stateless session JWT. That JWT is accepted by the same `Nip98Auth` +//! extractor (as `Authorization: Bearer `) that guards the rest of the API, +//! so OAuth users reach the exact same endpoints as Nostr users. +//! +//! Provider-specific quirks handled here: +//! - **GitHub** requires a `User-Agent` header and its subject is the numeric +//! `id` (not `sub`); its token endpoint returns JSON when asked via `Accept`. +//! - **Facebook** identifies users via the Graph `me` endpoint (`id`). +//! - **Apple** has no userinfo endpoint (the subject comes from the `id_token`), +//! requires a dynamically-signed **ES256** JWT as the `client_secret`, and +//! uses `response_mode=form_post` (a POST callback) when name/email is asked. + +use std::time::{SystemTime, UNIX_EPOCH}; + +use axum::Router; +use axum::extract::{Form, Path, Query, State}; +use axum::response::{IntoResponse, Redirect}; +use axum::routing::get; +use base64::Engine; +use base64::prelude::BASE64_URL_SAFE_NO_PAD; +use log::warn; +use serde::{Deserialize, Serialize}; + +use lnvps_api_common::{ + ApiData, ApiError, DEFAULT_STATE_TTL_SECS, issue_session_token, issue_state_token, + verify_state_token, +}; +use lnvps_db::{EncryptedString, LNVpsDb, oauth_pubkey}; + +use crate::api::RouterState; +use crate::settings::{OAuthProviderConfig, SubjectSource}; + +pub fn router() -> Router { + Router::new() + .route("/api/v1/oauth/{provider}/login", get(v1_oauth_login)) + .route( + "/api/v1/oauth/{provider}/callback", + // GET for standard redirects; POST for Apple `response_mode=form_post`. + get(v1_oauth_callback_get).post(v1_oauth_callback_post), + ) +} + +#[derive(Deserialize)] +struct CallbackParams { + code: Option, + state: Option, + error: Option, +} + +#[derive(Serialize)] +pub struct OAuthTokenResponse { + /// Session JWT to be sent as `Authorization: Bearer `. + pub token: String, + /// Token type, always `Bearer`. + pub token_type: String, + /// Lifetime in seconds. + pub expires_in: u64, +} + +/// Build the redirect URI this service exposes for a provider callback. +fn callback_uri(public_url: &str, provider: &str) -> String { + format!( + "{}/api/v1/oauth/{}/callback", + public_url.trim_end_matches('/'), + provider + ) +} + +/// Start a login: redirect the browser to the provider's authorization endpoint. +async fn v1_oauth_login( + Path(provider): Path, + State(this): State, +) -> Result { + let (_, provider_cfg) = resolve_provider(&this, &provider)?; + + let nonce = hex::encode(rand::random::<[u8; 16]>()); + let state = issue_state_token(&provider, &nonce, DEFAULT_STATE_TTL_SECS) + .map_err(|e| ApiError::internal(format!("Failed to create state: {}", e)))?; + + let redirect_uri = callback_uri(&this.settings.public_url, &provider); + let scopes = provider_cfg.scopes().join(" "); + let mut auth_url = format!( + "{}?response_type=code&client_id={}&redirect_uri={}&scope={}&state={}", + provider_cfg.auth_url(), + urlencoding::encode(provider_cfg.client_id()), + urlencoding::encode(&redirect_uri), + urlencoding::encode(&scopes), + urlencoding::encode(&state), + ); + if let Some(mode) = provider_cfg.response_mode() { + auth_url.push_str(&format!("&response_mode={}", mode)); + } + Ok(Redirect::to(&auth_url)) +} + +/// Standard GET redirect callback. +async fn v1_oauth_callback_get( + Path(provider): Path, + Query(q): Query, + State(this): State, +) -> Result { + handle_callback(&this, &provider, q).await +} + +/// `form_post` callback (Apple) — the parameters arrive as a POST form body. +async fn v1_oauth_callback_post( + Path(provider): Path, + State(this): State, + Form(q): Form, +) -> Result { + handle_callback(&this, &provider, q).await +} + +/// Shared callback logic: verify state, exchange the code, resolve the user and +/// issue a session token. +async fn handle_callback( + this: &RouterState, + provider: &str, + q: CallbackParams, +) -> Result { + if let Some(err) = q.error { + return Err(ApiError::from(anyhow::anyhow!( + "OAuth provider returned error: {}", + err + ))); + } + let code = q + .code + .ok_or_else(|| ApiError::from(anyhow::anyhow!("Missing authorization code")))?; + let state = q + .state + .ok_or_else(|| ApiError::from(anyhow::anyhow!("Missing state")))?; + + // Verify CSRF state and that it was issued for this provider. + let state_provider = verify_state_token(&state) + .map_err(|e| ApiError::from(anyhow::anyhow!("Invalid state: {}", e)))?; + if state_provider != provider { + return Err(ApiError::from(anyhow::anyhow!("State provider mismatch"))); + } + + let (cfg, provider_cfg) = resolve_provider(this, provider)?; + let redirect_uri = callback_uri(&this.settings.public_url, provider); + let profile = exchange_and_identify(&provider_cfg, &code, &redirect_uri) + .await + .map_err(|e| ApiError::internal(format!("OAuth exchange failed: {}", e)))?; + + // Resolve/create the user by their synthetic identity. + let pubkey = oauth_pubkey(provider, &profile.subject); + let uid = this.db.upsert_oauth_user(&pubkey).await?; + + // Best-effort: back-fill the account's email from the provider on first + // login. A sync failure (e.g. the email is already taken by another + // account) must not block login, so errors are logged and swallowed. + if let Err(e) = sync_user_email(&this.db, uid, &profile).await { + warn!("Failed to sync OAuth email for user {}: {}", uid, e); + } + + let token = issue_session_token(&pubkey, uid, cfg_session_ttl(&cfg)) + .map_err(|e| ApiError::internal(format!("Failed to issue session: {}", e)))?; + + // Redirect to the frontend with the token in the fragment, or return JSON. + if let Some(redirect) = cfg_success_redirect(&cfg) { + let sep = if redirect.contains('#') { '&' } else { '#' }; + let url = format!("{}{}token={}", redirect, sep, urlencoding::encode(&token)); + Ok(Redirect::to(&url).into_response()) + } else { + let resp = ApiData::ok(OAuthTokenResponse { + token, + token_type: "Bearer".to_string(), + expires_in: cfg_session_ttl(&cfg), + })?; + Ok(resp.into_response()) + } +} + +/// Look up the OAuth config and the specific provider, cloning the provider so +/// the borrow on settings is not held across awaits. +fn resolve_provider( + this: &RouterState, + provider: &str, +) -> Result<(crate::settings::OAuthConfig, OAuthProviderConfig), ApiError> { + let cfg = this + .settings + .oauth + .clone() + .ok_or_else(|| ApiError::from(anyhow::anyhow!("OAuth not configured")))?; + let provider_cfg = cfg + .providers + .get(provider) + .cloned() + .ok_or_else(|| ApiError::from(anyhow::anyhow!("Unknown OAuth provider")))?; + Ok((cfg, provider_cfg)) +} + +fn cfg_session_ttl(cfg: &crate::settings::OAuthConfig) -> u64 { + cfg.session_ttl +} + +fn cfg_success_redirect(cfg: &crate::settings::OAuthConfig) -> Option<&str> { + cfg.success_redirect.as_deref() +} + +/// Identifying details resolved from a provider after the token exchange. +struct OAuthProfile { + /// Stable provider subject id. + subject: String, + /// Email address, if the provider returned one. + email: Option, + /// Whether the provider asserts the email is verified. + email_verified: bool, +} + +/// Populate the account's email from the provider on first login only (when the +/// account has no email yet). Non-destructive: a user who later edits their +/// email is not overwritten on subsequent logins. OAuth users have no NIP-17 +/// channel, so email contact is enabled by default when set. +async fn sync_user_email( + db: &std::sync::Arc, + uid: u64, + profile: &OAuthProfile, +) -> anyhow::Result<()> { + let Some(email) = profile.email.as_deref() else { + return Ok(()); + }; + let mut user = db.get_user(uid).await?; + if !user.email.is_empty() { + return Ok(()); // already set — don't clobber user changes + } + user.email = EncryptedString::new(email.to_string()); + user.email_verified = profile.email_verified; + user.contact_email = true; + db.update_user(&user).await?; + Ok(()) +} + +/// Exchange an authorization code for a token and resolve the user's profile. +async fn exchange_and_identify( + cfg: &OAuthProviderConfig, + code: &str, + redirect_uri: &str, +) -> anyhow::Result { + let client = reqwest::Client::new(); + let client_secret = client_secret(cfg)?; + + // 1. Authorization-code -> token (application/x-www-form-urlencoded body). + let body = [ + ("grant_type", "authorization_code"), + ("code", code), + ("redirect_uri", redirect_uri), + ("client_id", cfg.client_id()), + ("client_secret", client_secret.as_str()), + ] + .iter() + .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v))) + .collect::>() + .join("&"); + + let mut token_req = client + .post(cfg.token_url()) + .header("Accept", "application/json") + .header("Content-Type", "application/x-www-form-urlencoded"); + if cfg.needs_user_agent() { + token_req = token_req.header("User-Agent", "lnvps"); + } + let token_resp = token_req.body(body).send().await?.error_for_status()?; + + #[derive(Deserialize)] + struct TokenResponse { + access_token: Option, + id_token: Option, + } + let token: TokenResponse = token_resp.json().await?; + + // 2. Resolve the profile (subject id + email). + match cfg.subject_source() { + SubjectSource::IdToken => { + // Apple: subject and email live in the id_token claims. + let id_token = token + .id_token + .ok_or_else(|| anyhow::anyhow!("token response missing id_token"))?; + let claims = decode_id_token(&id_token)?; + if claims.sub.is_empty() { + anyhow::bail!("id_token missing sub"); + } + Ok(OAuthProfile { + subject: claims.sub, + email: claims.email.filter(|s| !s.is_empty()), + email_verified: claims.email_verified.map(parse_bool_ish).unwrap_or(false), + }) + } + SubjectSource::Userinfo { url, field } => { + let access_token = token + .access_token + .ok_or_else(|| anyhow::anyhow!("token response missing access_token"))?; + let userinfo = fetch_json(&client, &url, &access_token, cfg.needs_user_agent()).await?; + let subject = userinfo + .get(&field) + .map(value_to_string) + .filter(|s| !s.is_empty()) + .ok_or_else(|| anyhow::anyhow!("userinfo missing subject field '{}'", field))?; + + let (email, email_verified) = + resolve_email(&client, cfg, &userinfo, &access_token).await; + + Ok(OAuthProfile { + subject, + email, + email_verified, + }) + } + } +} + +/// GET a JSON document with the provider access token (and optional User-Agent). +async fn fetch_json( + client: &reqwest::Client, + url: &str, + access_token: &str, + user_agent: bool, +) -> anyhow::Result { + let mut req = client + .get(url) + .bearer_auth(access_token) + .header("Accept", "application/json"); + if user_agent { + req = req.header("User-Agent", "lnvps"); + } + Ok(req.send().await?.error_for_status()?.json().await?) +} + +/// Resolve an `(email, verified)` pair for a userinfo-based provider. +/// +/// - Google / generic OIDC: `email` + `email_verified` from userinfo. +/// - Facebook: `email` from the Graph response (Facebook only exposes verified +/// emails, so it is treated as verified). +/// - GitHub: `/user` often omits a private email, so the primary verified +/// address is fetched from `/user/emails`. +async fn resolve_email( + client: &reqwest::Client, + cfg: &OAuthProviderConfig, + userinfo: &serde_json::Value, + access_token: &str, +) -> (Option, bool) { + let direct_email = userinfo + .get("email") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + + match cfg { + OAuthProviderConfig::Github(_) => { + // Prefer the authoritative primary verified address from /user/emails. + match github_primary_email(client, access_token).await { + Some((email, verified)) => (Some(email), verified), + None => (direct_email, false), + } + } + OAuthProviderConfig::Facebook(_) => { + let verified = direct_email.is_some(); + (direct_email, verified) + } + _ => { + let verified = userinfo + .get("email_verified") + .map(|v| parse_bool_ish(v.clone())) + .unwrap_or(false); + (direct_email, verified) + } + } +} + +/// Fetch the GitHub user's primary verified email from `/user/emails`. +async fn github_primary_email( + client: &reqwest::Client, + access_token: &str, +) -> Option<(String, bool)> { + let emails: Vec = client + .get("https://api.github.com/user/emails") + .bearer_auth(access_token) + .header("Accept", "application/json") + .header("User-Agent", "lnvps") + .send() + .await + .and_then(|r| r.error_for_status()) + .ok()? + .json() + .await + .ok()?; + emails + .iter() + .find(|e| e.primary && e.verified) + .or_else(|| emails.iter().find(|e| e.verified)) + .map(|e| (e.email.clone(), e.verified)) +} + +#[derive(Deserialize)] +struct GithubEmail { + email: String, + primary: bool, + verified: bool, +} + +/// Parse a boolean that some providers send as a JSON string (`"true"`) rather +/// than a JSON bool (Apple sends `email_verified` as a string). +fn parse_bool_ish(v: serde_json::Value) -> bool { + match v { + serde_json::Value::Bool(b) => b, + serde_json::Value::String(s) => s.eq_ignore_ascii_case("true"), + _ => false, + } +} + +/// Resolve the `client_secret` to send in the token request. For most providers +/// this is the static configured secret; for Apple it is a freshly-signed ES256 +/// JWT. +fn client_secret(cfg: &OAuthProviderConfig) -> anyhow::Result { + match cfg { + OAuthProviderConfig::Apple(a) => { + apple_client_secret(&a.team_id, &a.client_id, &a.key_id, &a.private_key) + } + OAuthProviderConfig::Google(c) + | OAuthProviderConfig::Github(c) + | OAuthProviderConfig::Facebook(c) + | OAuthProviderConfig::Oidc(c) => Ok(c.client_secret.clone()), + } +} + +/// Claims we read from an OIDC `id_token`. +#[derive(Deserialize)] +struct IdTokenClaims { + #[serde(default)] + sub: String, + email: Option, + email_verified: Option, +} + +/// Decode (without re-verifying) the claims from an OIDC `id_token`. +/// +/// The token is trusted because it was just received over TLS directly from the +/// provider's token endpoint (back-channel), so its signature is not re-verified +/// here. +fn decode_id_token(id_token: &str) -> anyhow::Result { + let payload_b64 = id_token + .split('.') + .nth(1) + .ok_or_else(|| anyhow::anyhow!("Malformed id_token"))?; + let payload = BASE64_URL_SAFE_NO_PAD.decode(payload_b64.as_bytes())?; + Ok(serde_json::from_slice(&payload)?) +} + +/// Generate a Sign in with Apple `client_secret`: an ES256 JWT signed with the +/// `.p8` private key. +fn apple_client_secret( + team_id: &str, + client_id: &str, + key_id: &str, + private_key_pem: &str, +) -> anyhow::Result { + use p256::ecdsa::{Signature, SigningKey, signature::Signer}; + use p256::pkcs8::DecodePrivateKey; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + let header = serde_json::json!({ "alg": "ES256", "kid": key_id, "typ": "JWT" }); + let claims = serde_json::json!({ + "iss": team_id, + "iat": now, + "exp": now + 3600, + "aud": "https://appleid.apple.com", + "sub": client_id, + }); + + let signing_input = format!( + "{}.{}", + BASE64_URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header)?), + BASE64_URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims)?), + ); + + let key = SigningKey::from_pkcs8_pem(private_key_pem) + .map_err(|e| anyhow::anyhow!("Invalid Apple private key: {}", e))?; + // ES256 = ECDSA/P-256/SHA-256; `to_bytes()` yields fixed-size r||s (64 bytes). + let sig: Signature = key.sign(signing_input.as_bytes()); + let sig_b64 = BASE64_URL_SAFE_NO_PAD.encode(sig.to_bytes()); + Ok(format!("{signing_input}.{sig_b64}")) +} + +/// Stringify a JSON scalar (numeric provider ids arrive as JSON numbers). +fn value_to_string(v: &serde_json::Value) -> String { + match v { + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Number(n) => n.to_string(), + _ => String::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The Apple client-secret is a well-formed ES256 JWT with the expected + /// header/claims, signed by the provided P-256 key. + #[test] + fn apple_client_secret_is_valid_es256_jwt() { + use p256::ecdsa::{Signature, SigningKey, VerifyingKey, signature::Verifier}; + use p256::pkcs8::{DecodePrivateKey, EncodePrivateKey, LineEnding}; + + // Fixed, valid P-256 scalar so the test needs no RNG (avoids rand_core + // version coupling). + let key = SigningKey::from_slice(&[0x11u8; 32]).unwrap(); + let pem = key.to_pkcs8_pem(LineEnding::LF).unwrap(); + + let jwt = apple_client_secret("TEAMID1234", "com.example.svc", "KEYID5678", pem.as_str()) + .unwrap(); + + let parts: Vec<&str> = jwt.split('.').collect(); + assert_eq!(parts.len(), 3); + + // Header carries alg/kid. + let header: serde_json::Value = + serde_json::from_slice(&BASE64_URL_SAFE_NO_PAD.decode(parts[0]).unwrap()).unwrap(); + assert_eq!(header["alg"], "ES256"); + assert_eq!(header["kid"], "KEYID5678"); + + // Claims carry iss/sub/aud. + let claims: serde_json::Value = + serde_json::from_slice(&BASE64_URL_SAFE_NO_PAD.decode(parts[1]).unwrap()).unwrap(); + assert_eq!(claims["iss"], "TEAMID1234"); + assert_eq!(claims["sub"], "com.example.svc"); + assert_eq!(claims["aud"], "https://appleid.apple.com"); + + // Signature verifies against the public key over "
.". + let signing_input = format!("{}.{}", parts[0], parts[1]); + let sig_bytes = BASE64_URL_SAFE_NO_PAD.decode(parts[2]).unwrap(); + let sig = Signature::from_slice(&sig_bytes).unwrap(); + let vk = VerifyingKey::from(SigningKey::from_pkcs8_pem(pem.as_str()).unwrap()); + assert!(vk.verify(signing_input.as_bytes(), &sig).is_ok()); + } + + /// Subject and email are read from an `id_token` payload, including Apple's + /// string-encoded `email_verified`. + #[test] + fn id_token_claims_extraction() { + let payload = BASE64_URL_SAFE_NO_PAD + .encode(br#"{"sub":"001234.abcd","email":"a@b.c","email_verified":"true"}"#); + let token = format!("header.{}.sig", payload); + let claims = decode_id_token(&token).unwrap(); + assert_eq!(claims.sub, "001234.abcd"); + assert_eq!(claims.email.as_deref(), Some("a@b.c")); + assert!(parse_bool_ish(claims.email_verified.unwrap())); + } + + /// `email_verified` is accepted as JSON bool or string; anything else false. + #[test] + fn parse_bool_ish_variants() { + assert!(parse_bool_ish(serde_json::json!(true))); + assert!(parse_bool_ish(serde_json::json!("true"))); + assert!(parse_bool_ish(serde_json::json!("TRUE"))); + assert!(!parse_bool_ish(serde_json::json!(false))); + assert!(!parse_bool_ish(serde_json::json!("false"))); + assert!(!parse_bool_ish(serde_json::json!(1))); + } + + /// Numeric subject ids (GitHub/Facebook) stringify correctly. + #[test] + fn numeric_subject_stringifies() { + assert_eq!(value_to_string(&serde_json::json!(12345)), "12345"); + assert_eq!(value_to_string(&serde_json::json!("abc")), "abc"); + assert_eq!(value_to_string(&serde_json::json!(null)), ""); + } + + /// Email is populated from the provider on first login, marks the account + /// verified + email-contactable, and is not clobbered on later logins. + #[tokio::test] + async fn sync_user_email_first_login_only() { + use lnvps_api_common::MockDb; + use lnvps_db::oauth_pubkey; + use std::sync::Arc; + + let db: Arc = Arc::new(MockDb::default()); + let uid = db + .upsert_oauth_user(&oauth_pubkey("google", "sub-1")) + .await + .unwrap(); + + sync_user_email( + &db, + uid, + &OAuthProfile { + subject: "sub-1".to_string(), + email: Some("user@example.com".to_string()), + email_verified: true, + }, + ) + .await + .unwrap(); + + let user = db.get_user(uid).await.unwrap(); + assert_eq!(user.email.as_str(), "user@example.com"); + assert!(user.email_verified); + assert!(user.contact_email); + + // A later login with a different email must not overwrite it. + sync_user_email( + &db, + uid, + &OAuthProfile { + subject: "sub-1".to_string(), + email: Some("changed@example.com".to_string()), + email_verified: true, + }, + ) + .await + .unwrap(); + assert_eq!( + db.get_user(uid).await.unwrap().email.as_str(), + "user@example.com" + ); + } +} diff --git a/lnvps_api/src/api/referral.rs b/lnvps_api/src/api/referral.rs index a0e978db..f1726945 100644 --- a/lnvps_api/src/api/referral.rs +++ b/lnvps_api/src/api/referral.rs @@ -69,7 +69,10 @@ impl From for ApiReferral { /// Resolve the commission rate that currently applies to a referrer: their /// per-referrer override if set, otherwise the default (primary, lowest-id) /// company's `referral_rate`. -async fn resolve_effective_rate(db: &std::sync::Arc, referral: &Referral) -> f32 { +async fn resolve_effective_rate( + db: &std::sync::Arc, + referral: &Referral, +) -> f32 { if let Some(r) = referral.referral_rate { return r; } @@ -268,7 +271,7 @@ async fn v1_get_referral( auth: Nip98Auth, State(this): State, ) -> ApiResult { - let pubkey = auth.event.pubkey.to_bytes(); + let pubkey = auth.pubkey(); let uid = this.db.upsert_user(&pubkey).await?; let referral = this @@ -299,7 +302,7 @@ async fn v1_signup_referral( State(this): State, Json(req): Json, ) -> ApiResult { - let pubkey = auth.event.pubkey.to_bytes(); + let pubkey = auth.pubkey(); let uid = this.db.upsert_user(&pubkey).await?; // Check if already enrolled @@ -357,7 +360,7 @@ async fn v1_update_referral( State(this): State, Json(req): Json, ) -> ApiResult { - let pubkey = auth.event.pubkey.to_bytes(); + let pubkey = auth.pubkey(); let uid = this.db.upsert_user(&pubkey).await?; let mut referral = this @@ -398,7 +401,7 @@ async fn v1_update_referral( /// first, and paid payout history is retained for accounting (so a referrer who /// has ever been paid cannot delete their enrollment). async fn v1_delete_referral(auth: Nip98Auth, State(this): State) -> ApiResult<()> { - let pubkey = auth.event.pubkey.to_bytes(); + let pubkey = auth.pubkey(); let uid = this.db.upsert_user(&pubkey).await?; let referral = this @@ -430,7 +433,7 @@ async fn v1_get_referral_usage( State(this): State, Query(q): Query, ) -> ApiPaginatedResult { - let pubkey = auth.event.pubkey.to_bytes(); + let pubkey = auth.pubkey(); let uid = this.db.upsert_user(&pubkey).await?; let referral = this diff --git a/lnvps_api/src/api/routes.rs b/lnvps_api/src/api/routes.rs index e7a84aa7..b8b0865a 100644 --- a/lnvps_api/src/api/routes.rs +++ b/lnvps_api/src/api/routes.rs @@ -161,7 +161,7 @@ async fn v1_patch_account( State(this): State, req: Json, ) -> ApiResult { - let pubkey = auth.event.pubkey.to_bytes(); + let pubkey = auth.pubkey(); let uid = this.db.upsert_user(&pubkey).await?; let mut user = this.db.get_user(uid).await?; @@ -215,6 +215,11 @@ async fn v1_patch_account( return ApiData::err("Verify your WhatsApp number before enabling WhatsApp notifications"); } + // NIP-17 DMs require a real Nostr key; OAuth accounts have a synthetic pubkey. + if req.contact_nip17 && user.account_type != lnvps_db::AccountType::Nostr { + return ApiData::err("Nostr DM notifications are only available for Nostr accounts"); + } + user.contact_nip17 = req.contact_nip17; user.contact_email = req.contact_email; user.contact_telegram = req.contact_telegram; @@ -379,7 +384,7 @@ async fn v1_get_account( auth: Nip98Auth, State(this): State, ) -> ApiResult { - let pubkey = auth.event.pubkey.to_bytes(); + let pubkey = auth.pubkey(); let uid = this.db.upsert_user(&pubkey).await?; let user = this.db.get_user(uid).await?; let mut rsp: AccountPatchRequest = user.into(); @@ -412,7 +417,7 @@ async fn v1_list_payment_methods( auth: Nip98Auth, State(this): State, ) -> ApiResult> { - let pubkey = auth.event.pubkey.to_bytes(); + let pubkey = auth.pubkey(); let uid = this.db.upsert_user(&pubkey).await?; let methods = this.db.list_user_payment_methods(uid, None).await?; ApiData::ok(methods.into_iter().map(Into::into).collect()) @@ -424,7 +429,7 @@ async fn v1_add_nwc_payment_method( State(this): State, req: Json, ) -> ApiResult { - let pubkey = auth.event.pubkey.to_bytes(); + let pubkey = auth.pubkey(); let uid = this.db.upsert_user(&pubkey).await?; let nwc = req.nwc_connection_string.trim().to_string(); @@ -481,7 +486,7 @@ async fn v1_patch_payment_method( Path(id): Path, req: Json, ) -> ApiResult { - let pubkey = auth.event.pubkey.to_bytes(); + let pubkey = auth.pubkey(); let uid = this.db.upsert_user(&pubkey).await?; let mut method = this.db.get_user_payment_method(id).await?; @@ -520,7 +525,7 @@ async fn v1_delete_payment_method( State(this): State, Path(id): Path, ) -> ApiResult<()> { - let pubkey = auth.event.pubkey.to_bytes(); + let pubkey = auth.pubkey(); let uid = this.db.upsert_user(&pubkey).await?; let method = this.db.get_user_payment_method(id).await?; if method.user_id != uid { @@ -576,7 +581,7 @@ async fn v1_telegram_link( let Some(tg) = this.settings.telegram.as_ref() else { return ApiData::err("Telegram notifications are not enabled on this server"); }; - let pubkey = auth.event.pubkey.to_bytes(); + let pubkey = auth.pubkey(); let uid = this.db.upsert_user(&pubkey).await?; let mut user = this.db.get_user(uid).await?; @@ -592,7 +597,7 @@ async fn v1_telegram_link( /// Unlink the user's Telegram chat and disable Telegram notifications. async fn v1_telegram_unlink(auth: Nip98Auth, State(this): State) -> ApiResult<()> { - let pubkey = auth.event.pubkey.to_bytes(); + let pubkey = auth.pubkey(); let uid = this.db.upsert_user(&pubkey).await?; let mut user = this.db.get_user(uid).await?; @@ -630,7 +635,7 @@ async fn v1_whatsapp_verify( return ApiData::err("A valid phone number in international format is required"); } - let pubkey = auth.event.pubkey.to_bytes(); + let pubkey = auth.pubkey(); let uid = this.db.upsert_user(&pubkey).await?; let mut user = this.db.get_user(uid).await?; @@ -659,7 +664,7 @@ async fn v1_whatsapp_confirm( State(this): State, Json(req): Json, ) -> ApiResult<()> { - let pubkey = auth.event.pubkey.to_bytes(); + let pubkey = auth.pubkey(); let uid = this.db.upsert_user(&pubkey).await?; let mut user = this.db.get_user(uid).await?; @@ -678,7 +683,7 @@ async fn v1_whatsapp_confirm( /// Remove the user's WhatsApp number and disable WhatsApp notifications. async fn v1_whatsapp_unlink(auth: Nip98Auth, State(this): State) -> ApiResult<()> { - let pubkey = auth.event.pubkey.to_bytes(); + let pubkey = auth.pubkey(); let uid = this.db.upsert_user(&pubkey).await?; let mut user = this.db.get_user(uid).await?; @@ -696,7 +701,7 @@ async fn v1_list_vms( auth: Nip98Auth, State(this): State, ) -> ApiResult> { - let pubkey = auth.event.pubkey.to_bytes(); + let pubkey = auth.pubkey(); let uid = this.db.upsert_user(&pubkey).await?; let vms = this.db.list_user_vms(uid).await?; let mut ret = vec![]; @@ -920,7 +925,7 @@ async fn v1_create_custom_vm_order( State(this): State, Json(req): Json, ) -> ApiResult { - let pubkey = auth.event.pubkey.to_bytes(); + let pubkey = auth.pubkey(); let uid = this.db.upsert_user(&pubkey).await?; // Capture place-of-supply evidence at purchase time (see capture_client_geo). @@ -959,7 +964,7 @@ async fn v1_list_ssh_keys( auth: Nip98Auth, State(this): State, ) -> ApiResult> { - let uid = this.db.upsert_user(&auth.event.pubkey.to_bytes()).await?; + let uid = this.db.upsert_user(&auth.pubkey()).await?; let vms = this.db.list_user_vms(uid).await?; let ret = this .db @@ -985,7 +990,7 @@ async fn v1_add_ssh_key( State(this): State, Json(req): Json, ) -> ApiResult { - let uid = this.db.upsert_user(&auth.event.pubkey.to_bytes()).await?; + let uid = this.db.upsert_user(&auth.pubkey()).await?; let pk: PublicKey = req .key_data @@ -1017,7 +1022,7 @@ async fn v1_delete_ssh_key( State(this): State, Path(id): Path, ) -> ApiResult<()> { - let uid = this.db.upsert_user(&auth.event.pubkey.to_bytes()).await?; + let uid = this.db.upsert_user(&auth.pubkey()).await?; let ssh_key = this.db.get_user_ssh_key(id).await?; if ssh_key.user_id != uid { @@ -1048,7 +1053,7 @@ async fn v1_create_vm_order( State(this): State, Json(req): Json, ) -> ApiResult { - let pubkey = auth.event.pubkey.to_bytes(); + let pubkey = auth.pubkey(); let uid = this.db.upsert_user(&pubkey).await?; // Capture place-of-supply evidence at purchase time (see capture_client_geo). @@ -1351,7 +1356,7 @@ async fn v1_terminal_proxy( { return Err("Invalid auth event"); } - let pubkey = auth.event.pubkey.to_bytes(); + let pubkey = auth.pubkey(); let uid = this .db .upsert_user(&pubkey) @@ -1498,7 +1503,7 @@ async fn v1_get_payment( State(this): State, Path(id): Path, ) -> ApiResult { - let pubkey = auth.event.pubkey.to_bytes(); + let pubkey = auth.pubkey(); let uid = this.db.upsert_user(&pubkey).await?; let id = if let Ok(i) = hex::decode(&id) { i @@ -1578,7 +1583,7 @@ async fn v1_get_payment_invoice( { return Err("Invalid auth event"); } - let pubkey = auth.event.pubkey.to_bytes(); + let pubkey = auth.pubkey(); let uid = this .db .upsert_user(&pubkey) @@ -1617,7 +1622,10 @@ async fn v1_get_payment_invoice( payment: ApiVmPayment, invoice_item: ApiInvoiceItem, user: AccountPatchRequest, - npub: String, + /// Billing email shown on the invoice (empty when the account has none). + email: String, + /// Whether an email is present, so the template can hide the line. + has_email: bool, total: u64, total_formatted: String, company: Option, @@ -1730,10 +1738,8 @@ async fn v1_get_payment_invoice( payment: ApiVmPayment::from_subscription_payment(payment, vm_id_for_payment) .map_err(|_| "Failed to parse payment data")?, invoice_item, - npub: nostr_sdk::PublicKey::from_slice(&user.pubkey) - .map_err(|_| "Invalid pubkey")? - .to_bech32() - .unwrap(), + email: user.email.as_str().to_string(), + has_email: !user.email.is_empty(), user: user.into(), company: company.map(|c| c.into()), upgrade_details, @@ -1751,7 +1757,7 @@ async fn v1_payment_history( Path(id): Path, Query(q): Query, ) -> ApiResult> { - let pubkey = auth.event.pubkey.to_bytes(); + let pubkey = auth.pubkey(); let uid = this.db.upsert_user(&pubkey).await?; let vm = this.db.get_vm(id).await?; if vm.user_id != uid { @@ -1780,7 +1786,7 @@ async fn v1_get_vm_history( Path(id): Path, Query(q): Query, ) -> ApiResult> { - let pubkey = auth.event.pubkey.to_bytes(); + let pubkey = auth.pubkey(); let uid = this.db.upsert_user(&pubkey).await?; let vm = this.db.get_vm(id).await?; if vm.user_id != uid { @@ -1808,7 +1814,7 @@ async fn v1_vm_upgrade_quote( Query(q): Query, Json(req): Json, ) -> ApiResult { - let pubkey = auth.event.pubkey.to_bytes(); + let pubkey = auth.pubkey(); let uid = this.db.upsert_user(&pubkey).await?; let vm = this.db.get_vm(id).await?; if vm.user_id != uid { @@ -1857,7 +1863,7 @@ async fn v1_vm_upgrade( Query(q): Query, Json(req): Json, ) -> ApiResult { - let pubkey = auth.event.pubkey.to_bytes(); + let pubkey = auth.pubkey(); let uid = this.db.upsert_user(&pubkey).await?; let vm = this.db.get_vm(id).await?; if vm.user_id != uid { @@ -2092,7 +2098,7 @@ async fn apply_firewall(this: &RouterState, vm_id: u64) -> Result<(), ApiError> } async fn get_user_vm(auth: &Nip98Auth, this: &RouterState, id: u64) -> Result<(u64, Vm), ApiError> { - let pubkey = auth.event.pubkey.to_bytes(); + let pubkey = auth.pubkey(); let uid = this.db.upsert_user(&pubkey).await?; let vm = this.db.get_vm(id).await?; if uid != vm.user_id { diff --git a/lnvps_api/src/api/subscriptions.rs b/lnvps_api/src/api/subscriptions.rs index f0a0186e..8b2f5195 100644 --- a/lnvps_api/src/api/subscriptions.rs +++ b/lnvps_api/src/api/subscriptions.rs @@ -43,7 +43,7 @@ async fn v1_list_subscriptions( State(this): State, Query(q): Query, ) -> ApiPaginatedResult { - let pubkey = auth.event.pubkey.to_bytes(); + let pubkey = auth.pubkey(); let uid = this.db.upsert_user(&pubkey).await?; let limit = q.limit.unwrap_or(50).min(100); @@ -69,7 +69,7 @@ pub async fn v1_get_subscription( State(this): State, Path(id): Path, ) -> ApiResult { - let pubkey = auth.event.pubkey.to_bytes(); + let pubkey = auth.pubkey(); let uid = this.db.upsert_user(&pubkey).await?; let subscription = this.db.get_subscription(id).await?; @@ -89,7 +89,7 @@ pub async fn v1_list_subscription_payments( Path(id): Path, Query(q): Query, ) -> ApiPaginatedResult { - let pubkey = auth.event.pubkey.to_bytes(); + let pubkey = auth.pubkey(); let uid = this.db.upsert_user(&pubkey).await?; // Verify subscription ownership @@ -121,7 +121,7 @@ async fn v1_update_subscription( Path(id): Path, Json(req): Json, ) -> ApiResult { - let pubkey = auth.event.pubkey.to_bytes(); + let pubkey = auth.pubkey(); let uid = this.db.upsert_user(&pubkey).await?; let mut subscription = this.db.get_subscription(id).await?; @@ -160,7 +160,7 @@ async fn v1_create_subscription( State(this): State, Json(req): Json, ) -> ApiResult { - let pubkey = auth.event.pubkey.to_bytes(); + let pubkey = auth.pubkey(); let uid = this.db.upsert_user(&pubkey).await?; // Validate that we have at least one line item @@ -307,7 +307,7 @@ async fn v1_renew_subscription( Path(id): Path, Query(q): Query, ) -> ApiResult { - let pubkey = auth.event.pubkey.to_bytes(); + let pubkey = auth.pubkey(); let uid = this.db.upsert_user(&pubkey).await?; // Get and verify subscription ownership diff --git a/lnvps_api/src/bin/api.rs b/lnvps_api/src/bin/api.rs index 87acfb83..f0c2e62b 100644 --- a/lnvps_api/src/bin/api.rs +++ b/lnvps_api/src/bin/api.rs @@ -112,6 +112,19 @@ async fn main() -> Result<(), Error> { info!("Executed dev_setup.sql"); } let db: Arc = Arc::new(db); + + // Enable session (Bearer JWT) auth for external OAuth logins when configured. + if let Some(ref oauth) = settings.oauth { + if lnvps_api_common::init_session_secret(oauth.session_secret.as_bytes().to_vec()) { + info!( + "OAuth session auth enabled ({} provider(s))", + oauth.providers.len() + ); + } else { + warn!("OAuth configured but session secret was empty or already set"); + } + } + let nostr_client = if let Some(ref c) = settings.nostr { let cx = Client::builder().signer(Keys::parse(&c.nsec)?).build(); for r in &c.relays { @@ -320,7 +333,8 @@ async fn main() -> Result<(), Error> { .merge(subscriptions_router()) .merge(ip_space_router()) .merge(referral_router()) - .merge(legal_router()); + .merge(legal_router()) + .merge(oauth_router()); #[cfg(feature = "openapi")] { diff --git a/lnvps_api/src/notifications/nip17.rs b/lnvps_api/src/notifications/nip17.rs index f3fe2373..5e07bfc9 100644 --- a/lnvps_api/src/notifications/nip17.rs +++ b/lnvps_api/src/notifications/nip17.rs @@ -3,7 +3,7 @@ use super::{Notification, NotificationChannel}; use async_trait::async_trait; use lnvps_api_common::retry::OpError; -use lnvps_db::User; +use lnvps_db::{AccountType, User}; use nostr_sdk::{Client, EventBuilder, PublicKey}; /// Delivers notifications as NIP-17 private direct messages over Nostr. @@ -24,7 +24,10 @@ impl NotificationChannel for Nip17Channel { } fn wants(&self, user: &User) -> bool { - user.contact_nip17 + // Only native Nostr accounts have a real key to DM. OAuth accounts store + // a synthetic `pubkey` that is not a valid Nostr key, so never attempt + // NIP-17 for them even if the flag were somehow set. + user.contact_nip17 && user.account_type == AccountType::Nostr } async fn send( diff --git a/lnvps_api/src/settings.rs b/lnvps_api/src/settings.rs index 721fa45c..6b6d7e2d 100644 --- a/lnvps_api/src/settings.rs +++ b/lnvps_api/src/settings.rs @@ -71,6 +71,232 @@ pub struct Settings { /// opt-in**: when this section is omitted, commission still accrues and can /// be paid manually by admins, but no automatic Lightning payouts are made. pub referral: Option, + + /// External OAuth/OIDC login support. When omitted, only Nostr (NIP-98) + /// authentication is available. + pub oauth: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct OAuthConfig { + /// Secret used to sign stateless session JWTs issued after a successful + /// OAuth login (and the short-lived CSRF `state` values). Must be a strong, + /// stable random string — changing it invalidates all outstanding sessions. + pub session_secret: String, + + /// Session token lifetime in seconds. Defaults to 30 days. + #[serde(default = "default_session_ttl")] + pub session_ttl: u64, + + /// After a successful login the browser is redirected here with the issued + /// token appended as `#token=` (fragment). Typically your frontend. + /// When omitted, the callback returns the token as JSON instead. + pub success_redirect: Option, + + /// Configured identity providers, keyed by a short provider tag (e.g. + /// `google`, `github`). The tag is part of the synthetic identity + /// (`sha256("{tag}:{subject}")`) so it must remain stable. + pub providers: std::collections::HashMap, +} + +fn default_session_ttl() -> u64 { + lnvps_api_common::DEFAULT_SESSION_TTL_SECS +} + +/// How the stable subject id is obtained after the token exchange. +pub enum SubjectSource { + /// GET the userinfo endpoint and read `field` from the JSON response. + Userinfo { url: String, field: String }, + /// Decode the `id_token` returned by the token endpoint and read `sub` + /// (used by "Sign in with Apple", which has no userinfo endpoint). + IdToken, +} + +/// A configured OAuth/OIDC identity provider. The `type` tag selects a built-in +/// flavor with sensible default endpoints/scopes and any provider-specific +/// quirks; `oidc` is a fully generic provider requiring explicit endpoints. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(tag = "type", rename_all = "kebab-case")] +pub enum OAuthProviderConfig { + /// Google (OpenID Connect). + Google(OAuthCommon), + /// GitHub (OAuth2; subject is the numeric user `id`). + Github(OAuthCommon), + /// Facebook Login (Graph API; subject is the numeric user `id`). + Facebook(OAuthCommon), + /// Sign in with Apple (subject comes from the `id_token`; client secret is + /// a dynamically-signed ES256 JWT). + Apple(AppleConfig), + /// Fully generic OIDC/OAuth2 provider. + Oidc(OAuthCommon), +} + +/// Common fields for standard client-secret providers. Endpoint/scope fields are +/// optional overrides — built-in flavors supply defaults, `oidc` requires them. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct OAuthCommon { + /// OAuth2 client id. + pub client_id: String, + /// OAuth2 client secret. + pub client_secret: String, + /// Override the authorization endpoint. + pub auth_url: Option, + /// Override the token endpoint. + pub token_url: Option, + /// Override the userinfo endpoint. + pub userinfo_url: Option, + /// Override the requested scopes. + pub scopes: Option>, + /// Override the userinfo JSON field used as the stable subject id. + pub subject_field: Option, +} + +/// Sign in with Apple configuration. Apple requires the OAuth `client_secret` to +/// be a short-lived ES256 JWT signed with your private key, so it takes key +/// material instead of a static secret. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct AppleConfig { + /// The Services ID (client id / `sub` of the client-secret JWT). + pub client_id: String, + /// Apple Developer Team ID (`iss` of the client-secret JWT). + pub team_id: String, + /// The key id of the `.p8` private key (`kid` header of the JWT). + pub key_id: String, + /// PEM contents of the Apple `.p8` (PKCS#8) private key. + pub private_key: String, + /// Scopes to request. Requesting `name`/`email` forces Apple's `form_post` + /// response mode (the callback arrives as a POST). Defaults to none. + pub scopes: Option>, + /// Override the authorization endpoint. + pub auth_url: Option, + /// Override the token endpoint. + pub token_url: Option, +} + +impl OAuthProviderConfig { + /// OAuth2 client id sent in the authorization/token requests. + pub fn client_id(&self) -> &str { + match self { + OAuthProviderConfig::Google(c) + | OAuthProviderConfig::Github(c) + | OAuthProviderConfig::Facebook(c) + | OAuthProviderConfig::Oidc(c) => &c.client_id, + OAuthProviderConfig::Apple(a) => &a.client_id, + } + } + + /// The provider authorization endpoint (built-in default or override). + pub fn auth_url(&self) -> &str { + match self { + OAuthProviderConfig::Google(c) => c + .auth_url + .as_deref() + .unwrap_or("https://accounts.google.com/o/oauth2/v2/auth"), + OAuthProviderConfig::Github(c) => c + .auth_url + .as_deref() + .unwrap_or("https://github.com/login/oauth/authorize"), + OAuthProviderConfig::Facebook(c) => c + .auth_url + .as_deref() + .unwrap_or("https://www.facebook.com/v21.0/dialog/oauth"), + OAuthProviderConfig::Apple(a) => a + .auth_url + .as_deref() + .unwrap_or("https://appleid.apple.com/auth/authorize"), + OAuthProviderConfig::Oidc(c) => c.auth_url.as_deref().unwrap_or_default(), + } + } + + /// The provider token endpoint (built-in default or override). + pub fn token_url(&self) -> &str { + match self { + OAuthProviderConfig::Google(c) => c + .token_url + .as_deref() + .unwrap_or("https://oauth2.googleapis.com/token"), + OAuthProviderConfig::Github(c) => c + .token_url + .as_deref() + .unwrap_or("https://github.com/login/oauth/access_token"), + OAuthProviderConfig::Facebook(c) => c + .token_url + .as_deref() + .unwrap_or("https://graph.facebook.com/v21.0/oauth/access_token"), + OAuthProviderConfig::Apple(a) => a + .token_url + .as_deref() + .unwrap_or("https://appleid.apple.com/auth/token"), + OAuthProviderConfig::Oidc(c) => c.token_url.as_deref().unwrap_or_default(), + } + } + + /// Scopes to request at the authorization endpoint. + pub fn scopes(&self) -> Vec { + let default: &[&str] = match self { + OAuthProviderConfig::Google(_) => &["openid", "email"], + OAuthProviderConfig::Github(_) => &["read:user", "user:email"], + OAuthProviderConfig::Facebook(_) => &["email"], + OAuthProviderConfig::Apple(_) => &[], + OAuthProviderConfig::Oidc(_) => &["openid", "email"], + }; + let override_scopes = match self { + OAuthProviderConfig::Google(c) + | OAuthProviderConfig::Github(c) + | OAuthProviderConfig::Facebook(c) + | OAuthProviderConfig::Oidc(c) => c.scopes.clone(), + OAuthProviderConfig::Apple(a) => a.scopes.clone(), + }; + override_scopes.unwrap_or_else(|| default.iter().map(|s| s.to_string()).collect()) + } + + /// Apple's `name`/`email` scopes require `response_mode=form_post`, which + /// makes the callback a POST. Returns the response mode to request, if any. + pub fn response_mode(&self) -> Option<&'static str> { + match self { + OAuthProviderConfig::Apple(_) if !self.scopes().is_empty() => Some("form_post"), + _ => None, + } + } + + /// Whether requests to this provider need a `User-Agent` header (GitHub's + /// API rejects requests without one). + pub fn needs_user_agent(&self) -> bool { + matches!(self, OAuthProviderConfig::Github(_)) + } + + /// How to obtain the stable subject id after the token exchange. + pub fn subject_source(&self) -> SubjectSource { + match self { + OAuthProviderConfig::Apple(_) => SubjectSource::IdToken, + OAuthProviderConfig::Google(c) => SubjectSource::Userinfo { + url: c.userinfo_url.clone().unwrap_or_else(|| { + "https://openidconnect.googleapis.com/v1/userinfo".to_string() + }), + field: c.subject_field.clone().unwrap_or_else(|| "sub".to_string()), + }, + OAuthProviderConfig::Github(c) => SubjectSource::Userinfo { + url: c + .userinfo_url + .clone() + .unwrap_or_else(|| "https://api.github.com/user".to_string()), + field: c.subject_field.clone().unwrap_or_else(|| "id".to_string()), + }, + OAuthProviderConfig::Facebook(c) => SubjectSource::Userinfo { + url: c.userinfo_url.clone().unwrap_or_else(|| { + "https://graph.facebook.com/me?fields=id,name,email".to_string() + }), + field: c.subject_field.clone().unwrap_or_else(|| "id".to_string()), + }, + OAuthProviderConfig::Oidc(c) => SubjectSource::Userinfo { + url: c.userinfo_url.clone().unwrap_or_default(), + field: c.subject_field.clone().unwrap_or_else(|| "sub".to_string()), + }, + } + } } fn default_min_payout_sats() -> u64 { @@ -363,6 +589,7 @@ pub fn mock_settings() -> Settings { captcha: None, geoip_database: None, referral: None, + oauth: None, } } @@ -390,4 +617,94 @@ mod tests { let (kind, _) = mock.to_db_kind_token(); assert!(matches!(kind, DnsServerKind::MockDns)); } + + #[test] + fn test_oauth_provider_defaults_and_quirks() { + // Google: OIDC defaults, userinfo `sub`. + let g: OAuthProviderConfig = serde_json::from_value(serde_json::json!({ + "type": "google", + "client-id": "gid", + "client-secret": "gsecret", + })) + .unwrap(); + assert_eq!(g.client_id(), "gid"); + assert_eq!(g.auth_url(), "https://accounts.google.com/o/oauth2/v2/auth"); + assert_eq!(g.scopes(), vec!["openid", "email"]); + assert!(!g.needs_user_agent()); + assert!(g.response_mode().is_none()); + assert!(matches!( + g.subject_source(), + SubjectSource::Userinfo { field, .. } if field == "sub" + )); + + // GitHub: needs UA, numeric `id` subject. + let gh: OAuthProviderConfig = serde_json::from_value(serde_json::json!({ + "type": "github", + "client-id": "ghid", + "client-secret": "ghsecret", + })) + .unwrap(); + assert!(gh.needs_user_agent()); + assert_eq!( + gh.token_url(), + "https://github.com/login/oauth/access_token" + ); + assert!(matches!( + gh.subject_source(), + SubjectSource::Userinfo { field, .. } if field == "id" + )); + + // Facebook: Graph me endpoint. + let fb: OAuthProviderConfig = serde_json::from_value(serde_json::json!({ + "type": "facebook", + "client-id": "fbid", + "client-secret": "fbsecret", + })) + .unwrap(); + assert!(matches!( + fb.subject_source(), + SubjectSource::Userinfo { url, .. } if url.contains("graph.facebook.com") + )); + + // Apple: id_token subject, no static secret, form_post only with scopes. + let apple: OAuthProviderConfig = serde_json::from_value(serde_json::json!({ + "type": "apple", + "client-id": "com.example.svc", + "team-id": "TEAM", + "key-id": "KEY", + "private-key": "pem", + })) + .unwrap(); + assert!(matches!(apple.subject_source(), SubjectSource::IdToken)); + assert!(apple.response_mode().is_none()); // no scopes -> query callback + assert_eq!(apple.client_id(), "com.example.svc"); + + let apple_scoped: OAuthProviderConfig = serde_json::from_value(serde_json::json!({ + "type": "apple", + "client-id": "com.example.svc", + "team-id": "TEAM", + "key-id": "KEY", + "private-key": "pem", + "scopes": ["name", "email"], + })) + .unwrap(); + assert_eq!(apple_scoped.response_mode(), Some("form_post")); + + // Generic OIDC honours explicit overrides. + let oidc: OAuthProviderConfig = serde_json::from_value(serde_json::json!({ + "type": "oidc", + "client-id": "oid", + "client-secret": "osecret", + "auth-url": "https://id.example.com/authorize", + "token-url": "https://id.example.com/token", + "userinfo-url": "https://id.example.com/userinfo", + "subject-field": "uid", + })) + .unwrap(); + assert_eq!(oidc.auth_url(), "https://id.example.com/authorize"); + assert!(matches!( + oidc.subject_source(), + SubjectSource::Userinfo { field, url } if field == "uid" && url.contains("id.example.com") + )); + } } diff --git a/lnvps_api/src/worker.rs b/lnvps_api/src/worker.rs index d60f3965..a2ae0051 100644 --- a/lnvps_api/src/worker.rs +++ b/lnvps_api/src/worker.rs @@ -20,7 +20,7 @@ use lnvps_db::{ VmIpAssignment, VmOsImage, }; use log::{debug, error, info, warn}; -use nostr_sdk::{Client, PublicKey, ToBech32}; +use nostr_sdk::Client; use payments_rs::currency::{Currency, CurrencyAmount}; use serde::Deserialize; use std::collections::HashMap; @@ -881,24 +881,22 @@ impl Worker { .collect::>() .join("\n"); let user_msg = format!( - "Your VM #{} has been created!\n\nOS: {}\nCPU: {} vCPU\nRAM: {} GB\nDisk: {} GB\n{}\n\nNPUB: {}", + "Your VM #{} has been created!\n\nOS: {}\nCPU: {} vCPU\nRAM: {} GB\nDisk: {} GB\n{}", vm.id, image, resources.cpu, resources.memory / crate::GB, resources.disk_size / crate::GB, ip_lines, - PublicKey::from_slice(&user.pubkey)?.to_bech32()? ); let admin_msg = format!( - "VM #{} has been created.\n\nOS: {}\nCPU: {} vCPU\nRAM: {} GB\nDisk: {} GB\n{}\n\nUser NPUB: {}", + "VM #{} has been created.\n\nOS: {}\nCPU: {} vCPU\nRAM: {} GB\nDisk: {} GB\n{}", vm.id, image, resources.cpu, resources.memory / crate::GB, resources.disk_size / crate::GB, ip_lines, - PublicKey::from_slice(&user.pubkey)?.to_bech32()? ); self.queue_notification(vm.user_id, user_msg, Some(format!("[VM{}] Created", vm.id))) .await; diff --git a/lnvps_api_admin/src/admin/auth.rs b/lnvps_api_admin/src/admin/auth.rs index f2106268..1f9314a7 100644 --- a/lnvps_api_admin/src/admin/auth.rs +++ b/lnvps_api_admin/src/admin/auth.rs @@ -20,7 +20,7 @@ pub struct AdminAuth { impl AdminAuth { pub async fn from_nip98_auth(nip98_auth: Nip98Auth, db: &Arc) -> Result { - let pubkey = nip98_auth.event.pubkey.to_bytes(); + let pubkey = nip98_auth.pubkey(); let user_id = db.upsert_user(&pubkey).await?; // Check if user has admin privileges and get their permissions diff --git a/lnvps_api_admin/src/bin/generate_demo_data.rs b/lnvps_api_admin/src/bin/generate_demo_data.rs index 8ff787fd..683c65d2 100644 --- a/lnvps_api_admin/src/bin/generate_demo_data.rs +++ b/lnvps_api_admin/src/bin/generate_demo_data.rs @@ -1075,6 +1075,7 @@ async fn create_users(db: &LNVpsDbMysql) -> Result> { let user = User { id: 0, // Will be auto-generated pubkey: pubkey_bytes.clone(), + account_type: lnvps_db::AccountType::Nostr, created, email: EncryptedString::new(email.to_string()), email_verified: true, diff --git a/lnvps_api_common/Cargo.toml b/lnvps_api_common/Cargo.toml index 89c34ba6..86584235 100644 --- a/lnvps_api_common/Cargo.toml +++ b/lnvps_api_common/Cargo.toml @@ -29,6 +29,8 @@ nostr = { version = "0.44", default-features = false, features = ["std"] } reqwest = { version = "0.13", features = ["json"] } rand = "0.9" sha1 = "0.10" +sha2 = "0.10" +hmac = "0.12" isocountry = "0.3" redis = { version = "1", features = ["tokio-comp", "cluster"] } maxminddb = "0.29" diff --git a/lnvps_api_common/src/lib.rs b/lnvps_api_common/src/lib.rs index 44d1dfcb..6ac9e1ba 100644 --- a/lnvps_api_common/src/lib.rs +++ b/lnvps_api_common/src/lib.rs @@ -13,6 +13,7 @@ mod ovh; mod pricing; pub mod retry; mod routes; +mod session; pub mod shasum; mod status; mod vat; @@ -34,6 +35,7 @@ pub use ovh::*; pub use pricing::*; pub use routes::*; use serde::{Deserialize, Deserializer}; +pub use session::*; pub use status::*; pub use vat::*; pub use vm_history::*; diff --git a/lnvps_api_common/src/mock.rs b/lnvps_api_common/src/mock.rs index e326522b..e4bff648 100644 --- a/lnvps_api_common/src/mock.rs +++ b/lnvps_api_common/src/mock.rs @@ -362,6 +362,27 @@ impl LNVpsDbBase for MockDb { } } + async fn upsert_oauth_user(&self, pubkey: &[u8; 32]) -> DbResult { + let mut users = self.users.lock().await; + if let Some(e) = users.iter().find(|(_k, u)| u.pubkey == *pubkey) { + Ok(*e.0) + } else { + let max = *users.keys().max().unwrap_or(&0); + users.insert( + max + 1, + User { + id: max + 1, + pubkey: pubkey.to_vec(), + account_type: lnvps_db::AccountType::OAuth, + created: Utc::now(), + country_code: Some("USA".to_string()), + ..Default::default() + }, + ); + Ok(max + 1) + } + } + async fn get_user(&self, id: u64) -> DbResult { let users = self.users.lock().await; Ok(users.get(&id).ok_or(anyhow!("no user"))?.clone()) @@ -4429,6 +4450,43 @@ mod tests { db.replace_router_bgp_routes(1, &[]).await.unwrap(); assert!(db.list_router_bgp_routes(1).await.unwrap().is_empty()); } + + /// An OAuth user is created with `AccountType::OAuth` and is idempotent on + /// the synthetic identity, distinct from Nostr users. + #[tokio::test] + async fn test_upsert_oauth_user() { + use lnvps_db::{AccountType, oauth_pubkey}; + + let db = MockDb::default(); + let pk = oauth_pubkey("google", "subject-123"); + + let uid = db.upsert_oauth_user(&pk).await.unwrap(); + // Idempotent: same identity returns the same user id. + assert_eq!(uid, db.upsert_oauth_user(&pk).await.unwrap()); + + let user = db.get_user(uid).await.unwrap(); + assert_eq!(user.account_type, AccountType::OAuth); + assert_eq!(user.pubkey, pk.to_vec()); + // OAuth accounts must not opt into NIP-17 (synthetic key is not a Nostr key). + assert!(!user.contact_nip17); + + // A different subject yields a different user. + let other = db + .upsert_oauth_user(&oauth_pubkey("google", "subject-999")) + .await + .unwrap(); + assert_ne!(uid, other); + } + + /// `oauth_pubkey` is deterministic and provider/subject sensitive. + #[test] + fn test_oauth_pubkey_derivation() { + use lnvps_db::oauth_pubkey; + assert_eq!(oauth_pubkey("a", "b"), oauth_pubkey("a", "b")); + assert_ne!(oauth_pubkey("a", "b"), oauth_pubkey("a", "c")); + // Provider tag is part of the identity, so `a:bc` != `ab:c`. + assert_ne!(oauth_pubkey("a", "bc"), oauth_pubkey("ab", "c")); + } } use crate::dns::{BasicRecord, DnsRef, DnsZone, RecordType}; diff --git a/lnvps_api_common/src/nip98.rs b/lnvps_api_common/src/nip98.rs index 107ff82d..4c0493ba 100644 --- a/lnvps_api_common/src/nip98.rs +++ b/lnvps_api_common/src/nip98.rs @@ -6,19 +6,73 @@ use axum::{ use base64::Engine; use base64::prelude::BASE64_STANDARD; use log::debug; -use nostr::{Event, JsonUtil, Kind, Timestamp}; +use nostr::{Event, JsonUtil, Kind, PublicKey, Timestamp}; +use crate::session::{SessionClaims, verify_session_token}; + +/// How a request authenticated. +pub enum AuthKind { + /// NIP-98 signed Nostr HTTP-auth event. `pubkey` is a real Nostr key. + Nostr(Event), + /// Session JWT issued after an external (OAuth/OIDC) login. The identity is + /// a synthetic `oauth_pubkey`, NOT a real Nostr key. + Session(SessionClaims), +} + +/// Request authentication. +/// +/// Despite the historical name, this now accepts **two** schemes: +/// - `Authorization: Nostr ` — NIP-98 (native Nostr accounts) +/// - `Authorization: Bearer ` — a session token issued after OAuth login +/// +/// Handlers should use [`Nip98Auth::pubkey`] to get the 32-byte identity +/// (works for both schemes) rather than reaching for the underlying event. pub struct Nip98Auth { - pub event: Event, + /// The concrete auth scheme and its payload. + pub kind: AuthKind, + /// Resolved 32-byte identity: a real Nostr key for [`AuthKind::Nostr`], or a + /// synthetic `oauth_pubkey` for [`AuthKind::Session`]. + pubkey: [u8; 32], } impl Nip98Auth { + /// The 32-byte identity that authenticated this request. This is the value + /// used as the `users.pubkey` primary identity for both Nostr and OAuth + /// accounts. + pub fn pubkey(&self) -> [u8; 32] { + self.pubkey + } + + /// The real Nostr public key, if this request used NIP-98. Returns `None` + /// for OAuth session auth (whose identity is not a usable Nostr key). + pub fn nostr_pubkey(&self) -> Option { + match &self.kind { + AuthKind::Nostr(ev) => Some(ev.pubkey), + AuthKind::Session(_) => None, + } + } + + /// The underlying NIP-98 event, if any. + pub fn event(&self) -> Option<&Event> { + match &self.kind { + AuthKind::Nostr(ev) => Some(ev), + AuthKind::Session(_) => None, + } + } + + /// Validate the auth against the request `path`/`method`. For NIP-98 this + /// checks the `u`/`method` tags, timestamp window and signature. Session + /// (bearer) tokens are not bound to a path/method (verified at parse time), + /// so this is a no-op success for them. pub fn check(&self, path: &str, method: &str) -> anyhow::Result<()> { - if self.event.kind != Kind::HttpAuth { + let event = match &self.kind { + AuthKind::Nostr(ev) => ev, + AuthKind::Session(_) => return Ok(()), + }; + if event.kind != Kind::HttpAuth { bail!("Wrong event kind"); } - if self - .event + if event .created_at .as_secs() .abs_diff(Timestamp::now().as_secs()) @@ -28,7 +82,7 @@ impl Nip98Auth { } // check url tag - if let Some(url) = self.event.tags.iter().find_map(|t| { + if let Some(url) = event.tags.iter().find_map(|t| { let vec = t.as_slice(); // Use get() to avoid panicking on a malformed single-element tag // like ["u"] supplied in an attacker-controlled auth event. @@ -50,7 +104,7 @@ impl Nip98Auth { } // check method tag - if let Some(t_method) = self.event.tags.iter().find_map(|t| { + if let Some(t_method) = event.tags.iter().find_map(|t| { let vec = t.as_slice(); match (vec.first(), vec.get(1)) { (Some(k), Some(v)) if k == "method" => Some(v.clone()), @@ -64,18 +118,23 @@ impl Nip98Auth { bail!("Missing method tag") } - if let Err(_err) = self.event.verify() { + if let Err(_err) = event.verify() { bail!("Event signature invalid"); } - debug!("{}", self.event.as_json()); + debug!("{}", event.as_json()); Ok(()) } + /// Parse a NIP-98 auth from a base64-encoded Nostr event (used by the + /// query-parameter auth-token path). Does not validate path/method. pub fn from_base64(i: &str) -> anyhow::Result { if let Ok(j) = BASE64_STANDARD.decode(i) { if let Ok(ev) = Event::from_json(j) { - Ok(Self { event: ev }) + Ok(Self { + pubkey: ev.pubkey.to_bytes(), + kind: AuthKind::Nostr(ev), + }) } else { bail!("Invalid nostr event") } @@ -83,6 +142,15 @@ impl Nip98Auth { bail!("Invalid auth string"); } } + + /// Build a session (bearer) auth from verified JWT claims. + fn from_session_claims(claims: SessionClaims) -> anyhow::Result { + let pubkey = claims.pubkey()?; + Ok(Self { + pubkey, + kind: AuthKind::Session(claims), + }) + } } #[cfg(test)] @@ -90,13 +158,17 @@ mod tests { use super::*; use nostr::{EventBuilder, Keys, Tag}; - fn signed_event(tags: Vec) -> Event { + fn signed_auth(tags: Vec) -> Nip98Auth { let keys = Keys::generate(); - EventBuilder::new(Kind::HttpAuth, "") + let event = EventBuilder::new(Kind::HttpAuth, "") .tags(tags) .custom_created_at(Timestamp::now()) .sign_with_keys(&keys) - .unwrap() + .unwrap(); + Nip98Auth { + pubkey: event.pubkey.to_bytes(), + kind: AuthKind::Nostr(event), + } } /// Regression: a validly-signed auth event containing a malformed @@ -104,11 +176,10 @@ mod tests { /// out of bounds). It should be treated as a missing url tag. #[test] fn malformed_single_element_u_tag_does_not_panic() { - let event = signed_event(vec![ + let auth = signed_auth(vec![ Tag::parse(["u"]).unwrap(), Tag::parse(["method", "GET"]).unwrap(), ]); - let auth = Nip98Auth { event }; let res = auth.check("/api/v1/account", "GET"); assert!(res.is_err(), "expected error, not a panic"); } @@ -116,11 +187,10 @@ mod tests { /// Same for a malformed single-element `["method"]` tag. #[test] fn malformed_single_element_method_tag_does_not_panic() { - let event = signed_event(vec![ + let auth = signed_auth(vec![ Tag::parse(["u", "https://example.com/api/v1/account"]).unwrap(), Tag::parse(["method"]).unwrap(), ]); - let auth = Nip98Auth { event }; let res = auth.check("/api/v1/account", "GET"); assert!(res.is_err(), "expected error, not a panic"); } @@ -128,11 +198,10 @@ mod tests { /// A well-formed auth event still validates successfully. #[test] fn well_formed_tags_pass() { - let event = signed_event(vec![ + let auth = signed_auth(vec![ Tag::parse(["u", "https://example.com/api/v1/account"]).unwrap(), Tag::parse(["method", "GET"]).unwrap(), ]); - let auth = Nip98Auth { event }; assert!(auth.check("/api/v1/account", "GET").is_ok()); } } @@ -154,10 +223,19 @@ where .and_then(|v| v.to_str().ok()) .ok_or((StatusCode::FORBIDDEN, "Auth header not found".to_string()))?; + // Session (bearer) scheme: JWT issued after an external OAuth login. + if let Some(token) = auth_header.strip_prefix("Bearer ") { + let claims = verify_session_token(token.trim()) + .map_err(|e| (StatusCode::UNAUTHORIZED, format!("Invalid session: {}", e)))?; + return Nip98Auth::from_session_claims(claims) + .map_err(|e| (StatusCode::UNAUTHORIZED, format!("Invalid session: {}", e))); + } + + // Nostr (NIP-98) scheme. if !auth_header.starts_with("Nostr ") { return Err(( StatusCode::FORBIDDEN, - "Auth scheme must be Nostr".to_string(), + "Auth scheme must be Nostr or Bearer".to_string(), )); } diff --git a/lnvps_api_common/src/session.rs b/lnvps_api_common/src/session.rs new file mode 100644 index 00000000..59a1e8fe --- /dev/null +++ b/lnvps_api_common/src/session.rs @@ -0,0 +1,241 @@ +//! Stateless session tokens issued after a successful external (OAuth/OIDC) +//! login. +//! +//! These are standard compact HS256 JWTs signed with a server-side secret. They +//! let external-account users authenticate to the same API surface that Nostr +//! users reach via NIP-98, without the API having to hold a Nostr key on their +//! behalf. The token carries the user's synthetic identity (`sub` = hex-encoded +//! 32-byte `oauth_pubkey`) plus their numeric `uid`, so request handling stays +//! stateless (no per-request DB/IdP round-trip just to authenticate). + +use std::sync::OnceLock; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{Result, bail}; +use base64::Engine; +use base64::prelude::BASE64_URL_SAFE_NO_PAD; +use hmac::{Hmac, Mac}; +use serde::{Deserialize, Serialize}; +use sha2::Sha256; + +/// Process-wide session signing secret, initialised once at startup from +/// settings via [`init_session_secret`]. When unset, session (`Bearer`) auth is +/// disabled and only Nostr (NIP-98) auth is accepted. +static SESSION_SECRET: OnceLock> = OnceLock::new(); + +/// Default session lifetime (30 days) if the caller does not specify one. +pub const DEFAULT_SESSION_TTL_SECS: u64 = 60 * 60 * 24 * 30; + +/// Install the session signing secret. Idempotent — the first non-empty secret +/// wins; subsequent calls are ignored. Returns `true` if this call installed it. +pub fn init_session_secret(secret: impl Into>) -> bool { + let secret = secret.into(); + if secret.is_empty() { + return false; + } + SESSION_SECRET.set(secret).is_ok() +} + +/// Whether session (`Bearer` JWT) authentication is enabled. +pub fn session_auth_enabled() -> bool { + SESSION_SECRET.get().is_some() +} + +/// Claims carried by a session token. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionClaims { + /// Subject: lowercase hex of the user's 32-byte identity (`oauth_pubkey`). + pub sub: String, + /// Numeric user id (fast path so handlers can skip a lookup). + pub uid: u64, + /// Issued-at (unix seconds). + pub iat: u64, + /// Expiry (unix seconds). + pub exp: u64, +} + +impl SessionClaims { + /// The 32-byte identity this token authenticates, decoded from `sub`. + pub fn pubkey(&self) -> Result<[u8; 32]> { + let bytes = hex::decode(&self.sub)?; + if bytes.len() != 32 { + bail!("Invalid session subject length"); + } + let mut out = [0u8; 32]; + out.copy_from_slice(&bytes); + Ok(out) + } +} + +fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +fn sign(signing_input: &[u8], secret: &[u8]) -> String { + let mut mac = Hmac::::new_from_slice(secret).expect("HMAC accepts any key length"); + mac.update(signing_input); + BASE64_URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()) +} + +/// Issue a signed session token for `(pubkey, uid)` valid for `ttl_secs`. +/// +/// Returns an error if no session secret has been configured. +pub fn issue_session_token(pubkey: &[u8; 32], uid: u64, ttl_secs: u64) -> Result { + let secret = SESSION_SECRET + .get() + .ok_or_else(|| anyhow::anyhow!("Session auth not configured"))?; + + let iat = now_secs(); + let claims = SessionClaims { + sub: hex::encode(pubkey), + uid, + iat, + exp: iat + ttl_secs, + }; + + // Fixed HS256 header. + let header = BASE64_URL_SAFE_NO_PAD.encode(br#"{"alg":"HS256","typ":"JWT"}"#); + let payload = BASE64_URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims)?); + let signing_input = format!("{header}.{payload}"); + let sig = sign(signing_input.as_bytes(), secret); + Ok(format!("{signing_input}.{sig}")) +} + +/// Verify a session token and return its claims. Checks signature (constant-time +/// via HMAC verify) and expiry. Errors if session auth is disabled, the token is +/// malformed, the signature is invalid, or it has expired. +pub fn verify_session_token(token: &str) -> Result { + let secret = SESSION_SECRET + .get() + .ok_or_else(|| anyhow::anyhow!("Session auth not configured"))?; + + let mut parts = token.split('.'); + let header_b64 = parts.next().unwrap_or_default(); + let payload_b64 = parts.next().unwrap_or_default(); + let sig_b64 = parts.next().unwrap_or_default(); + if header_b64.is_empty() + || payload_b64.is_empty() + || sig_b64.is_empty() + || parts.next().is_some() + { + bail!("Malformed session token"); + } + + // Verify signature over "
.". + let signing_input = format!("{header_b64}.{payload_b64}"); + let expected_sig = BASE64_URL_SAFE_NO_PAD.decode(sig_b64.as_bytes())?; + let mut mac = Hmac::::new_from_slice(secret).expect("HMAC accepts any key length"); + mac.update(signing_input.as_bytes()); + mac.verify_slice(&expected_sig) + .map_err(|_| anyhow::anyhow!("Invalid session signature"))?; + + let claims: SessionClaims = + serde_json::from_slice(&BASE64_URL_SAFE_NO_PAD.decode(payload_b64.as_bytes())?)?; + + if now_secs() >= claims.exp { + bail!("Session token expired"); + } + Ok(claims) +} + +/// Claims for a short-lived OAuth CSRF `state` value. +#[derive(Debug, Clone, Serialize, Deserialize)] +struct StateClaims { + /// Provider tag this login flow was started for. + prov: String, + /// Random nonce (hex). + nonce: String, + /// Expiry (unix seconds). + exp: u64, +} + +/// Default CSRF `state` lifetime (10 minutes) — long enough to complete a login. +pub const DEFAULT_STATE_TTL_SECS: u64 = 600; + +/// Issue a signed, short-lived CSRF `state` value binding an OAuth login flow to +/// a specific provider. Verified on the callback via [`verify_state_token`]. +pub fn issue_state_token(provider: &str, nonce: &str, ttl_secs: u64) -> Result { + let secret = SESSION_SECRET + .get() + .ok_or_else(|| anyhow::anyhow!("Session auth not configured"))?; + let claims = StateClaims { + prov: provider.to_string(), + nonce: nonce.to_string(), + exp: now_secs() + ttl_secs, + }; + let payload = BASE64_URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims)?); + let sig = sign(payload.as_bytes(), secret); + Ok(format!("{payload}.{sig}")) +} + +/// Verify a CSRF `state` value and return the provider tag it was issued for. +pub fn verify_state_token(token: &str) -> Result { + let secret = SESSION_SECRET + .get() + .ok_or_else(|| anyhow::anyhow!("Session auth not configured"))?; + let mut parts = token.split('.'); + let payload_b64 = parts.next().unwrap_or_default(); + let sig_b64 = parts.next().unwrap_or_default(); + if payload_b64.is_empty() || sig_b64.is_empty() || parts.next().is_some() { + bail!("Malformed state token"); + } + let expected_sig = BASE64_URL_SAFE_NO_PAD.decode(sig_b64.as_bytes())?; + let mut mac = Hmac::::new_from_slice(secret).expect("HMAC accepts any key length"); + mac.update(payload_b64.as_bytes()); + mac.verify_slice(&expected_sig) + .map_err(|_| anyhow::anyhow!("Invalid state signature"))?; + let claims: StateClaims = + serde_json::from_slice(&BASE64_URL_SAFE_NO_PAD.decode(payload_b64.as_bytes())?)?; + if now_secs() >= claims.exp { + bail!("State token expired"); + } + Ok(claims.prov) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn state_token_roundtrip() { + init_session_secret(b"unit-test-secret".to_vec()); + let token = issue_state_token("google", "abc123", DEFAULT_STATE_TTL_SECS).unwrap(); + assert_eq!(verify_state_token(&token).unwrap(), "google"); + } + + #[test] + fn issue_and_verify_roundtrip() { + // OnceLock is process-global; set once for the whole test binary. + init_session_secret(b"unit-test-secret".to_vec()); + assert!(session_auth_enabled()); + + let pk = [7u8; 32]; + let token = issue_session_token(&pk, 42, DEFAULT_SESSION_TTL_SECS).unwrap(); + let claims = verify_session_token(&token).unwrap(); + assert_eq!(claims.uid, 42); + assert_eq!(claims.pubkey().unwrap(), pk); + } + + #[test] + fn rejects_tampered_token() { + init_session_secret(b"unit-test-secret".to_vec()); + let token = issue_session_token(&[1u8; 32], 1, DEFAULT_SESSION_TTL_SECS).unwrap(); + // Flip a character in the payload segment. + let mut segs: Vec<&str> = token.split('.').collect(); + let bad_payload = format!("{}x", segs[1]); + segs[1] = &bad_payload; + let tampered = segs.join("."); + assert!(verify_session_token(&tampered).is_err()); + } + + #[test] + fn rejects_expired_token() { + init_session_secret(b"unit-test-secret".to_vec()); + // ttl 0 => exp == iat == now, and verify checks `now >= exp`. + let token = issue_session_token(&[2u8; 32], 5, 0).unwrap(); + assert!(verify_session_token(&token).is_err()); + } +} diff --git a/lnvps_db/migrations/20260718003000_user_account_type.sql b/lnvps_db/migrations/20260718003000_user_account_type.sql new file mode 100644 index 00000000..2fce7c97 --- /dev/null +++ b/lnvps_db/migrations/20260718003000_user_account_type.sql @@ -0,0 +1,6 @@ +-- Distinguishes native Nostr accounts (pubkey is a real schnorr x-only key) +-- from external OAuth/OIDC accounts (pubkey is a synthetic +-- sha256("{provider}:{subject}") identifier, not a real Nostr key). +-- Existing rows are all Nostr accounts (0). +alter table users + add column account_type smallint unsigned not null default 0; diff --git a/lnvps_db/src/lib.rs b/lnvps_db/src/lib.rs index ebe2a39f..ff3ed389 100644 --- a/lnvps_db/src/lib.rs +++ b/lnvps_db/src/lib.rs @@ -38,6 +38,24 @@ pub fn email_hash(email: &str) -> [u8; 32] { out } +/// Derive the synthetic 32-byte identity used as the `pubkey` for an external +/// OAuth/OIDC account: `SHA-256("{provider}:{subject}")`. +/// +/// This is a stable, opaque identifier — the same `(provider, subject)` always +/// maps to the same user. It is NOT a real Nostr key; users created this way are +/// stored with [`model::AccountType::OAuth`]. +pub fn oauth_pubkey(provider: &str, subject: &str) -> [u8; 32] { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(provider.as_bytes()); + hasher.update(b":"); + hasher.update(subject.as_bytes()); + let result = hasher.finalize(); + let mut out = [0u8; 32]; + out.copy_from_slice(&result); + out +} + #[derive(Error, Debug)] pub enum DbError { #[error("sqlx: {0}")] @@ -103,6 +121,12 @@ pub trait LNVpsDbBase: Send + Sync { /// Insert/Fetch user by pubkey async fn upsert_user(&self, pubkey: &[u8; 32]) -> DbResult; + /// Insert/Fetch an external OAuth/OIDC user by their synthetic identity + /// (see [`oauth_pubkey`]). Created accounts are marked + /// [`model::AccountType::OAuth`] and do not opt into NIP-17 DMs (their + /// `pubkey` is not a real Nostr key). + async fn upsert_oauth_user(&self, pubkey: &[u8; 32]) -> DbResult; + /// Get a user by id async fn get_user(&self, id: u64) -> DbResult; diff --git a/lnvps_db/src/model.rs b/lnvps_db/src/model.rs index 4b7c195b..3d8c2c77 100644 --- a/lnvps_db/src/model.rs +++ b/lnvps_db/src/model.rs @@ -9,13 +9,42 @@ use std::path::PathBuf; use std::str::FromStr; use url::Url; +/// How a user authenticates / what their `pubkey` represents. +#[derive(Clone, Copy, Debug, sqlx::Type, Default, PartialEq, Eq)] +#[repr(u16)] +pub enum AccountType { + /// Native Nostr account. `pubkey` is a real 32-byte schnorr x-only public + /// key and can be used for NIP-17 DMs, npub display, event signing, etc. + #[default] + Nostr = 0, + /// External OAuth/OIDC account. `pubkey` is a synthetic + /// `sha256("{provider}:{subject}")` identifier used only as an opaque + /// primary identity — it is NOT a real Nostr key and must never be treated + /// as one (no NIP-17, no npub, no signing). + OAuth = 1, +} + +impl Display for AccountType { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + AccountType::Nostr => write!(f, "nostr"), + AccountType::OAuth => write!(f, "oauth"), + } + } +} + #[derive(FromRow, Clone, Debug, Default)] /// Users who buy VM's pub struct User { /// Unique ID of this user (database generated) pub id: u64, - /// The nostr public key for this user + /// The nostr public key for this user (or a synthetic identifier for + /// non-Nostr accounts — see [`User::account_type`]). pub pubkey: Vec, + /// Whether this user is a native Nostr account or an external OAuth account. + /// Determines whether `pubkey` is a usable Nostr key. + #[sqlx(default)] + pub account_type: AccountType, /// When this user first started using the service (first login) pub created: DateTime, /// Users email address for notifications (encrypted) diff --git a/lnvps_db/src/mysql.rs b/lnvps_db/src/mysql.rs index b4ef60e1..31ef9578 100644 --- a/lnvps_db/src/mysql.rs +++ b/lnvps_db/src/mysql.rs @@ -63,6 +63,25 @@ impl LNVpsDbBase for LNVpsDbMysql { }) } + async fn upsert_oauth_user(&self, pubkey: &[u8; 32]) -> DbResult { + // account_type=1 (OAuth), contact_nip17=0 — the synthetic pubkey is not + // a real Nostr key so NIP-17 DMs must not be attempted. + let res = sqlx::query( + "insert ignore into users(pubkey,contact_nip17,account_type) values(?,0,1) returning id", + ) + .bind(pubkey.as_slice()) + .fetch_optional(&self.db) + .await?; + Ok(match res { + None => sqlx::query("select id from users where pubkey = ?") + .bind(pubkey.as_slice()) + .fetch_one(&self.db) + .await? + .try_get(0)?, + Some(res) => res.try_get(0)?, + }) + } + async fn get_user(&self, id: u64) -> DbResult { Ok(sqlx::query_as("select * from users where id=?") .bind(id)