diff --git a/docs/rest/v4/users.md b/docs/rest/v4/users.md index 54fb988..4304379 100644 --- a/docs/rest/v4/users.md +++ b/docs/rest/v4/users.md @@ -9,6 +9,9 @@ This document describes the endpoints for interacting with users in REST API v4. - [Create Token](#create-token) - [Change Password](#change-password) - [Update Username](#update-username) +- [Get Linked Nostr Identity](#get-linked-nostr-identity) +- [Link Nostr Identity](#link-nostr-identity) +- [Unlink Nostr Identity](#unlink-nostr-identity) ### Get Authenticated User @@ -36,7 +39,8 @@ curl https://api.btcmap.org/v4/users/me \ "name": "satoshi", "roles": ["user", "admin"], "saved_places": [{"id": 1, "name": "Bitcoin Cafe"}], - "saved_areas": [{"id": 2, "name": "Downtown District"}] + "saved_areas": [{"id": 2, "name": "Downtown District"}], + "npub": "npub1..." } ``` @@ -47,6 +51,7 @@ curl https://api.btcmap.org/v4/users/me \ | roles | Array | List of user roles (e.g., "user", "admin", "root") | | saved_places | Array | List of saved places with `id` and `name` fields | | saved_areas | Array | List of saved areas with `id` and `name` fields | +| npub | String \| null | Bech32 npub of the linked Nostr identity, or `null` if none is linked | ### Create User @@ -212,4 +217,103 @@ curl -X PUT https://api.btcmap.org/v4/users/me/username \ |-------|------|-------------| | id | Number | User ID | | name | String | Updated username | -| roles | Array | List of user roles | \ No newline at end of file +| roles | Array | List of user roles | + +### Get Linked Nostr Identity + +Returns the Nostr pubkey currently linked to the authenticated account, or `null` if none is linked. Requires a valid Bearer token. This is the same `npub` exposed on [Get Authenticated User](#get-authenticated-user), offered as a dedicated sub-resource so a client can poll just the link state. + +#### Example Request + +```bash +curl https://api.btcmap.org/v4/users/me/nostr \ + -H "Authorization: Bearer " +``` + +#### Response + +| Code | Description | +|------|-------------| +| 200 | Success - Returns the linked npub (or null) | +| 401 | Unauthorized - Missing or invalid token | + +##### Example Response (200 OK) + +```json +{ + "npub": "npub1..." +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| npub | String \| null | Bech32 npub of the linked Nostr identity, or `null` if none is linked | + +### Link Nostr Identity + +Links (or replaces) the Nostr pubkey on the authenticated account. This requires **two** credentials at once: + +- `Authorization: Bearer ` — identifies the account being modified. +- `X-Nostr-Authorization: Nostr ` — a NIP-98 event proving control of the pubkey being linked. + +The two cannot share the `Authorization` header, so the NIP-98 proof is carried on the dedicated `X-Nostr-Authorization` header. The request body is empty. The proof event must sign `u = /v4/users/me/nostr` with method `PUT` (the `u`/`method` are matched against the server's configured base URL and the actual request method — both are case-sensitive). + +#### Example Request + +```bash +curl -X PUT https://api.btcmap.org/v4/users/me/nostr \ + -H "Authorization: Bearer " \ + -H "X-Nostr-Authorization: Nostr " +``` + +#### Response + +| Code | Description | +|------|-------------| +| 200 | Success - Pubkey linked (or already linked to this account) | +| 400 | Bad Request - The npub is already linked to a different account | +| 401 | Unauthorized - Missing/invalid Bearer token, or missing/invalid NIP-98 proof | +| 500 | Internal Server Error - Database error | + +##### Example Response (200 OK) + +```json +{ + "npub": "npub1..." +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| npub | String | Bech32 npub now linked to the account | + +### Unlink Nostr Identity + +Clears the Nostr pubkey linked to the authenticated account. Requires a valid Bearer token only — removing your own link needs no NIP-98 proof. Idempotent: succeeds with `npub: null` even if nothing was linked. + +#### Example Request + +```bash +curl -X DELETE https://api.btcmap.org/v4/users/me/nostr \ + -H "Authorization: Bearer " +``` + +#### Response + +| Code | Description | +|------|-------------| +| 200 | Success - Link cleared (or already absent) | +| 401 | Unauthorized - Missing or invalid token | +| 500 | Internal Server Error - Database error | + +##### Example Response (200 OK) + +```json +{ + "npub": null +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| npub | null | Always `null` after unlinking | \ No newline at end of file diff --git a/src/db/main/user/queries.rs b/src/db/main/user/queries.rs index 82102b1..f71bb34 100644 --- a/src/db/main/user/queries.rs +++ b/src/db/main/user/queries.rs @@ -106,7 +106,6 @@ pub async fn set_saved_areas(id: i64, saved_areas: &[i64], pool: &Pool) -> Resul .await? } -#[allow(dead_code)] pub async fn set_npub(id: i64, npub: Option, pool: &Pool) -> Result { pool.get() .await? diff --git a/src/main.rs b/src/main.rs index 7686c76..bf7c242 100644 --- a/src/main.rs +++ b/src/main.rs @@ -211,6 +211,9 @@ async fn main() -> Result<()> { .service(rest::v4::users::post) .service(rest::v4::users::change_password) .service(rest::v4::users::update_username) + .service(rest::v4::users::get_nostr) + .service(rest::v4::users::put_nostr) + .service(rest::v4::users::delete_nostr) .service(rest::v4::users::create_token), ), ) diff --git a/src/rest/nostr_auth.rs b/src/rest/nostr_auth.rs index 7bae45f..0b93a6b 100644 --- a/src/rest/nostr_auth.rs +++ b/src/rest/nostr_auth.rs @@ -15,6 +15,57 @@ use crate::service::nip98; #[derive(Clone)] pub struct ApiBaseUrl(pub String); +/// Header carrying the NIP-98 proof on endpoints that ALSO need Bearer +/// auth (e.g. `PUT /v4/users/me/nostr`, which links a pubkey to an +/// already-authenticated account). The standard `Authorization` header is +/// taken by the Bearer token there, so the signed Nostr event rides this +/// one instead. Endpoints authenticated by Nostr alone (the sign-in +/// endpoint) keep using `Authorization: Nostr ...` via [`NostrAuth`]. +pub const X_NOSTR_AUTHORIZATION: &str = "x-nostr-authorization"; + +/// Verify a NIP-98 event carried in `header_name` against the trusted +/// `ApiBaseUrl` + this request's path/method, returning the bech32 npub on +/// success. Returns `None` when state is missing, the header is absent or +/// not a `Nostr ...` value, or verification fails. Shared by [`NostrAuth`] +/// (reads `Authorization`) and [`NostrProof`] (reads `X-Nostr-Authorization`). +fn verified_npub(req: &HttpRequest, header_name: impl header::AsHeaderName) -> Option { + // Without a trusted base URL we can't safely verify the `u` tag, so + // refuse to attempt verification (matches the `Auth` extractor's + // fail-closed pattern when state is missing). + let base_url = req.app_data::>()?; + + let payload = req + .headers() + .get(header_name) + .and_then(|h| h.to_str().ok()) + .and_then(nip98::extract_nostr_auth)?; + + // Base URL comes from config, never from request headers — path and + // query are the only request-derived pieces. An attacker who spoofs + // `Host` or `X-Forwarded-*` cannot influence what the signature is + // checked against. + let path_and_query = req + .uri() + .path_and_query() + .map(|p| p.as_str()) + .unwrap_or(req.uri().path()); + let full_url = format!("{}{}", base_url.0.trim_end_matches('/'), path_and_query); + let method = req.method().as_str(); + + match nip98::verify(payload, &full_url, method) { + Ok(event) => Some(event.npub), + Err(e) => { + tracing::debug!( + error = %e, + url = %full_url, + method = %method, + "NIP-98 verification failed" + ); + None + } + } +} + /// NIP-98 extractor. Mirrors the `Auth` bearer extractor: never fails the /// request, always yields a struct. When `npub` is `None` the handler /// decides whether to reject (401) or treat auth as optional. @@ -27,7 +78,8 @@ pub struct ApiBaseUrl(pub String); /// - verified under a valid Schnorr signature /// /// The `npub` is bech32-encoded (`npub1...`), matching the encoding used by -/// the `user.npub` DB column. +/// the `user.npub` DB column. The signed event is read from the standard +/// `Authorization: Nostr ...` header. pub struct NostrAuth { pub npub: Option, } @@ -37,55 +89,27 @@ impl FromRequest for NostrAuth { type Future = Pin>>>; fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future { - let req = req.clone(); - let base_url = req.app_data::>().cloned(); - Box::pin(async move { - // Without a trusted base URL we can't safely verify the `u` tag, - // so refuse to attempt verification: yield `npub: None` and let - // the handler decide whether to reject (matches the `Auth` - // extractor's pattern when state is missing). Whether this ends - // up fail-closed or fail-open in practice depends on the - // handler — required-auth handlers must treat `None` as 401. - let Some(base_url) = base_url else { - return Ok(NostrAuth { npub: None }); - }; - - let Some(payload) = req - .headers() - .get(header::AUTHORIZATION) - .and_then(|h| h.to_str().ok()) - .and_then(nip98::extract_nostr_auth) - else { - return Ok(NostrAuth { npub: None }); - }; - - // Base URL comes from config, never from request headers — path - // and query are the only request-derived pieces. An attacker who - // spoofs `Host` or `X-Forwarded-*` cannot influence what the - // signature is checked against. - let path_and_query = req - .uri() - .path_and_query() - .map(|p| p.as_str()) - .unwrap_or(req.uri().path()); - let full_url = format!("{}{}", base_url.0.trim_end_matches('/'), path_and_query); - let method = req.method().as_str(); - - match nip98::verify(payload, &full_url, method) { - Ok(event) => Ok(NostrAuth { - npub: Some(event.npub), - }), - Err(e) => { - tracing::debug!( - error = %e, - url = %full_url, - method = %method, - "NIP-98 verification failed" - ); - Ok(NostrAuth { npub: None }) - } - } - }) + let npub = verified_npub(req, header::AUTHORIZATION); + Box::pin(async move { Ok(NostrAuth { npub }) }) + } +} + +/// Same NIP-98 guarantees as [`NostrAuth`], but reads the signed event from +/// the [`X_NOSTR_AUTHORIZATION`] header. Use this on endpoints that need +/// BOTH a Bearer token (to identify the account, via `Auth`) AND a Nostr +/// signature (to prove control of the pubkey) — the two can't share the +/// `Authorization` header. +pub struct NostrProof { + pub npub: Option, +} + +impl FromRequest for NostrProof { + type Error = actix_web::Error; + type Future = Pin>>>; + + fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future { + let npub = verified_npub(req, X_NOSTR_AUTHORIZATION); + Box::pin(async move { Ok(NostrProof { npub }) }) } } diff --git a/src/rest/v4/users.rs b/src/rest/v4/users.rs index 29f4a57..27fc0e2 100644 --- a/src/rest/v4/users.rs +++ b/src/rest/v4/users.rs @@ -3,6 +3,8 @@ use crate::db::main::MainPool; use crate::db::{self, main::user::schema::Role}; use crate::rest::auth::Auth; use crate::rest::error::RestApiError; +use crate::rest::nostr_auth::NostrProof; +use actix_web::delete; use actix_web::get; use actix_web::http::header; use actix_web::post; @@ -41,6 +43,9 @@ pub struct MeResponse { pub roles: Vec, pub saved_places: Vec, pub saved_areas: Vec, + /// Bech32 npub (`npub1...`) of the Nostr identity linked to this user, + /// or `null` when no pubkey is linked. + pub npub: Option, } impl From<&User> for MeResponse { @@ -51,6 +56,7 @@ impl From<&User> for MeResponse { roles: user.roles.iter().map(|r| r.to_string()).collect(), saved_places: vec![], saved_areas: vec![], + npub: user.npub.clone(), } } } @@ -82,6 +88,7 @@ pub async fn me(auth: Auth, pool: Data) -> Result, Re roles: user.roles.iter().map(|r| r.to_string()).collect(), saved_places, saved_areas, + npub: user.npub, })) } @@ -185,6 +192,90 @@ pub async fn update_username( Ok(Json(MeResponse::from(&updated_user))) } +#[derive(Serialize, Deserialize)] +pub struct NostrIdentityResponse { + /// Bech32 npub (`npub1...`) currently linked to the account, or `null`. + pub npub: Option, +} + +/// `GET /v4/users/me/nostr` +/// +/// Returns the Nostr pubkey currently linked to the authenticated account +/// (or `null`). A thin read of the same `npub` exposed on `GET /me`, kept +/// as a dedicated sub-resource so a client can poll just the link state. +#[get("/me/nostr")] +pub async fn get_nostr(auth: Auth) -> Result, RestApiError> { + let user = auth.user.ok_or_else(RestApiError::unauthorized)?; + Ok(Json(NostrIdentityResponse { npub: user.npub })) +} + +/// `PUT /v4/users/me/nostr` +/// +/// Links (or replaces) the Nostr pubkey on the authenticated account. +/// Requires TWO credentials: a Bearer token (`Authorization`, via [`Auth`]) +/// to say *which account*, and a NIP-98 proof (`X-Nostr-Authorization`, via +/// [`NostrProof`]) to prove control of the pubkey being linked. The request +/// body is empty; the proof event must sign `u = /v4/users/me/nostr` +/// with method `PUT`. +/// +/// Conflict handling is application-level: if the proven npub is already +/// linked to a *different* account, returns 400. Idempotent: re-linking the +/// npub already on this account returns 200. +/// +/// NOTE (concurrency): there is no UNIQUE index on `user.npub` yet, so the +/// `select_by_npub` check and the `set_npub` write are not atomic — two +/// concurrent PUTs linking the same npub to two different accounts could +/// both pass the check and both succeed (TOCTOU). This is accepted for now; +/// the maintainer-owned partial unique index on `user.npub` is what closes +/// the window (after which a UNIQUE violation on write could be mapped to +/// 400). Do not add that index here. +#[put("/me/nostr")] +pub async fn put_nostr( + auth: Auth, + proof: NostrProof, + pool: Data, +) -> Result, RestApiError> { + let user = auth.user.ok_or_else(RestApiError::unauthorized)?; + let npub = proof.npub.ok_or_else(RestApiError::unauthorized)?; + + // Refuse to steal a pubkey already linked to someone else. Linking the + // npub this account already has is a no-op that still returns 200. + if let Some(existing) = db::main::user::queries::select_by_npub(npub.clone(), &pool) + .await + .map_err(|_| RestApiError::database())? + { + if existing.id != user.id { + return Err(RestApiError::invalid_input( + "npub already linked to another account", + )); + } + } + + db::main::user::queries::set_npub(user.id, Some(npub.clone()), &pool) + .await + .map_err(|_| RestApiError::database())?; + + Ok(Json(NostrIdentityResponse { npub: Some(npub) })) +} + +/// `DELETE /v4/users/me/nostr` +/// +/// Clears the Nostr pubkey linked to the authenticated account. Requires +/// only account auth (Bearer) — removing your own link needs no NIP-98 +/// proof. Idempotent: succeeds with `npub: null` even if nothing was +/// linked. +#[delete("/me/nostr")] +pub async fn delete_nostr( + auth: Auth, + pool: Data, +) -> Result, RestApiError> { + let user = auth.user.ok_or_else(RestApiError::unauthorized)?; + db::main::user::queries::set_npub(user.id, None, &pool) + .await + .map_err(|_| RestApiError::database())?; + Ok(Json(NostrIdentityResponse { npub: None })) +} + #[post("/{username}/tokens")] pub async fn create_token( req: actix_web::HttpRequest, @@ -257,6 +348,7 @@ pub async fn create_token( roles: user.roles.iter().map(|r| r.to_string()).collect(), saved_places, saved_areas, + npub: user.npub, }, })) } @@ -266,12 +358,36 @@ mod test { use super::*; use crate::db::main::test::pool; use crate::db::main::user::schema::Role; + use crate::rest::nostr_auth::{ApiBaseUrl, X_NOSTR_AUTHORIZATION}; use crate::{db, Result}; use actix_web::http::header; use actix_web::http::StatusCode; use actix_web::test::TestRequest; use actix_web::web::{scope, Data}; use actix_web::{test, App}; + use base64::engine::general_purpose::STANDARD as BASE64; + use base64::Engine; + use nostr::event::EventBuilder; + use nostr::key::Keys; + use nostr::nips::nip19::ToBech32; + use nostr::{JsonUtil, Kind, Tag, Timestamp}; + + // Trusted base URL the NIP-98 `u` tag must bind to in PUT /me/nostr tests. + const BASE: &str = "https://api.example.test"; + + // Base64-encoded NIP-98 event signing `url` with `method`, for the + // `X-Nostr-Authorization` header. Mirrors the helper in nostr.rs/nostr_auth.rs. + fn signed_nip98(keys: &Keys, url: &str, method: &str) -> String { + let event = EventBuilder::new(Kind::from_u16(27235), "") + .tags(vec![ + Tag::parse(["u", url]).unwrap(), + Tag::parse(["method", method]).unwrap(), + ]) + .custom_created_at(Timestamp::now()) + .sign_with_keys(keys) + .unwrap(); + BASE64.encode(event.as_json().as_bytes()) + } #[test] async fn me_unauthenticated_returns_401() -> Result<()> { @@ -315,6 +431,347 @@ mod test { let res: MeResponse = test::call_and_read_body_json(&app, req).await; assert_eq!(res.id, user.id); assert_eq!(res.name, "test_user"); + // A password-only user has no linked Nostr identity. + assert_eq!(res.npub, None); + Ok(()) + } + + #[test] + async fn me_returns_linked_npub() -> Result<()> { + let pool = pool(); + let npub = "npub1example".to_string(); + let user = db::main::user::queries::insert_with_npub( + "nostr_user", + "", + &npub, + &[Role::User], + &pool, + ) + .await?; + let _token = db::main::access_token::queries::insert( + user.id, + "".into(), + "secret".into(), + vec![Role::Root], + &pool, + ) + .await?; + + let app = test::init_service( + App::new() + .app_data(Data::new(pool)) + .service(scope("/users").service(me)), + ) + .await; + + let req = TestRequest::get() + .insert_header((header::AUTHORIZATION, "Bearer secret")) + .uri("/users/me") + .to_request(); + let res: MeResponse = test::call_and_read_body_json(&app, req).await; + assert_eq!(res.npub, Some(npub)); + Ok(()) + } + + // Inserts a user (optionally with an npub) plus an access token "secret" + // bound to it, returning the user. Mirrors the inline setup used by the + // `me` tests above. + async fn user_with_token( + name: &str, + npub: Option<&str>, + pool: &crate::db::main::MainPool, + ) -> Result { + let user = match npub { + Some(npub) => { + db::main::user::queries::insert_with_npub(name, "", npub, &[Role::User], pool) + .await? + } + None => db::main::user::queries::insert(name, "", pool).await?, + }; + db::main::access_token::queries::insert( + user.id, + "".into(), + "secret".into(), + vec![Role::User], + pool, + ) + .await?; + Ok(user) + } + + #[test] + async fn get_nostr_unauthenticated_returns_401() -> Result<()> { + let app = test::init_service( + App::new() + .app_data(Data::new(pool())) + .service(scope("/users").service(get_nostr)), + ) + .await; + + let req = TestRequest::get().uri("/users/me/nostr").to_request(); + let res = test::call_service(&app, req).await; + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + Ok(()) + } + + #[test] + async fn get_nostr_returns_null_when_unlinked() -> Result<()> { + let pool = pool(); + user_with_token("plain_user", None, &pool).await?; + + let app = test::init_service( + App::new() + .app_data(Data::new(pool)) + .service(scope("/users").service(get_nostr)), + ) + .await; + + let req = TestRequest::get() + .insert_header((header::AUTHORIZATION, "Bearer secret")) + .uri("/users/me/nostr") + .to_request(); + let res: NostrIdentityResponse = test::call_and_read_body_json(&app, req).await; + assert_eq!(res.npub, None); + Ok(()) + } + + #[test] + async fn get_nostr_returns_linked_npub() -> Result<()> { + let pool = pool(); + user_with_token("nostr_user", Some("npub1example"), &pool).await?; + + let app = test::init_service( + App::new() + .app_data(Data::new(pool)) + .service(scope("/users").service(get_nostr)), + ) + .await; + + let req = TestRequest::get() + .insert_header((header::AUTHORIZATION, "Bearer secret")) + .uri("/users/me/nostr") + .to_request(); + let res: NostrIdentityResponse = test::call_and_read_body_json(&app, req).await; + assert_eq!(res.npub, Some("npub1example".to_string())); + Ok(()) + } + + #[test] + async fn delete_nostr_unauthenticated_returns_401() -> Result<()> { + let app = test::init_service( + App::new() + .app_data(Data::new(pool())) + .service(scope("/users").service(delete_nostr)), + ) + .await; + + let req = TestRequest::delete().uri("/users/me/nostr").to_request(); + let res = test::call_service(&app, req).await; + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + Ok(()) + } + + #[test] + async fn delete_nostr_clears_link() -> Result<()> { + let pool = pool(); + let user = user_with_token("nostr_user", Some("npub1example"), &pool).await?; + + let app = test::init_service( + App::new() + .app_data(Data::new(pool.clone())) + .service(scope("/users").service(delete_nostr)), + ) + .await; + + let req = TestRequest::delete() + .insert_header((header::AUTHORIZATION, "Bearer secret")) + .uri("/users/me/nostr") + .to_request(); + let res: NostrIdentityResponse = test::call_and_read_body_json(&app, req).await; + assert_eq!(res.npub, None); + + // The link is actually gone from the database. + let reloaded = db::main::user::queries::select_by_id(user.id, &pool).await?; + assert_eq!(reloaded.npub, None); + Ok(()) + } + + #[test] + async fn delete_nostr_idempotent_when_unlinked() -> Result<()> { + let pool = pool(); + user_with_token("plain_user", None, &pool).await?; + + let app = test::init_service( + App::new() + .app_data(Data::new(pool)) + .service(scope("/users").service(delete_nostr)), + ) + .await; + + let req = TestRequest::delete() + .insert_header((header::AUTHORIZATION, "Bearer secret")) + .uri("/users/me/nostr") + .to_request(); + let res = test::call_service(&app, req).await; + assert_eq!(res.status(), StatusCode::OK); + Ok(()) + } + + // Builds the PUT /me/nostr test app: pool + the ApiBaseUrl the NIP-98 + // proof binds to, mounted at /users so the signed `u` is BASE/users/me/nostr. + fn put_app( + pool: crate::db::main::MainPool, + ) -> App< + impl actix_web::dev::ServiceFactory< + actix_web::dev::ServiceRequest, + Config = (), + Response = actix_web::dev::ServiceResponse, + Error = actix_web::Error, + InitError = (), + >, + > { + App::new() + .app_data(Data::new(pool)) + .app_data(Data::new(ApiBaseUrl(BASE.to_string()))) + .service(scope("/users").service(put_nostr)) + } + + const PUT_URL: &str = "/users/me/nostr"; + + #[test] + async fn put_nostr_links_pubkey() -> Result<()> { + let pool = pool(); + let user = user_with_token("plain_user", None, &pool).await?; + let keys = Keys::generate(); + let npub = keys.public_key().to_bech32().unwrap(); + let proof = signed_nip98(&keys, &format!("{BASE}{PUT_URL}"), "PUT"); + + let app = test::init_service(put_app(pool.clone())).await; + let req = TestRequest::put() + .insert_header((header::AUTHORIZATION, "Bearer secret")) + .insert_header((X_NOSTR_AUTHORIZATION, format!("Nostr {proof}"))) + .uri(PUT_URL) + .to_request(); + let res: NostrIdentityResponse = test::call_and_read_body_json(&app, req).await; + assert_eq!(res.npub, Some(npub.clone())); + + let reloaded = db::main::user::queries::select_by_id(user.id, &pool).await?; + assert_eq!(reloaded.npub, Some(npub)); + Ok(()) + } + + #[test] + async fn put_nostr_replaces_existing_link() -> Result<()> { + let pool = pool(); + let user = user_with_token("nostr_user", Some("npub1old"), &pool).await?; + let keys = Keys::generate(); + let new_npub = keys.public_key().to_bech32().unwrap(); + let proof = signed_nip98(&keys, &format!("{BASE}{PUT_URL}"), "PUT"); + + let app = test::init_service(put_app(pool.clone())).await; + let req = TestRequest::put() + .insert_header((header::AUTHORIZATION, "Bearer secret")) + .insert_header((X_NOSTR_AUTHORIZATION, format!("Nostr {proof}"))) + .uri(PUT_URL) + .to_request(); + let res: NostrIdentityResponse = test::call_and_read_body_json(&app, req).await; + assert_eq!(res.npub, Some(new_npub.clone())); + + let reloaded = db::main::user::queries::select_by_id(user.id, &pool).await?; + assert_eq!(reloaded.npub, Some(new_npub)); + Ok(()) + } + + #[test] + async fn put_nostr_idempotent_when_same_user() -> Result<()> { + // The account already owns this npub; re-linking it returns 200. + let pool = pool(); + let keys = Keys::generate(); + let npub = keys.public_key().to_bech32().unwrap(); + user_with_token("nostr_user", Some(&npub), &pool).await?; + let proof = signed_nip98(&keys, &format!("{BASE}{PUT_URL}"), "PUT"); + + let app = test::init_service(put_app(pool)).await; + let req = TestRequest::put() + .insert_header((header::AUTHORIZATION, "Bearer secret")) + .insert_header((X_NOSTR_AUTHORIZATION, format!("Nostr {proof}"))) + .uri(PUT_URL) + .to_request(); + let res: NostrIdentityResponse = test::call_and_read_body_json(&app, req).await; + assert_eq!(res.npub, Some(npub)); + Ok(()) + } + + #[test] + async fn put_nostr_conflict_returns_400() -> Result<()> { + // npub is already linked to a different account. + let pool = pool(); + let keys = Keys::generate(); + let npub = keys.public_key().to_bech32().unwrap(); + // Account A owns the pubkey. + db::main::user::queries::insert_with_npub("owner", "", &npub, &[Role::User], &pool).await?; + // Account B (the caller) tries to claim it. + user_with_token("claimer", None, &pool).await?; + let proof = signed_nip98(&keys, &format!("{BASE}{PUT_URL}"), "PUT"); + + let app = test::init_service(put_app(pool)).await; + let req = TestRequest::put() + .insert_header((header::AUTHORIZATION, "Bearer secret")) + .insert_header((X_NOSTR_AUTHORIZATION, format!("Nostr {proof}"))) + .uri(PUT_URL) + .to_request(); + let res = test::call_service(&app, req).await; + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + Ok(()) + } + + #[test] + async fn put_nostr_missing_bearer_returns_401() -> Result<()> { + let pool = pool(); + let keys = Keys::generate(); + let proof = signed_nip98(&keys, &format!("{BASE}{PUT_URL}"), "PUT"); + + let app = test::init_service(put_app(pool)).await; + let req = TestRequest::put() + .insert_header((X_NOSTR_AUTHORIZATION, format!("Nostr {proof}"))) + .uri(PUT_URL) + .to_request(); + let res = test::call_service(&app, req).await; + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + Ok(()) + } + + #[test] + async fn put_nostr_missing_proof_returns_401() -> Result<()> { + let pool = pool(); + user_with_token("plain_user", None, &pool).await?; + + let app = test::init_service(put_app(pool)).await; + let req = TestRequest::put() + .insert_header((header::AUTHORIZATION, "Bearer secret")) + .uri(PUT_URL) + .to_request(); + let res = test::call_service(&app, req).await; + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + Ok(()) + } + + #[test] + async fn put_nostr_proof_for_wrong_url_returns_401() -> Result<()> { + let pool = pool(); + user_with_token("plain_user", None, &pool).await?; + let keys = Keys::generate(); + // Proof signs a different path than the request targets. + let proof = signed_nip98(&keys, &format!("{BASE}/users/me/different"), "PUT"); + + let app = test::init_service(put_app(pool)).await; + let req = TestRequest::put() + .insert_header((header::AUTHORIZATION, "Bearer secret")) + .insert_header((X_NOSTR_AUTHORIZATION, format!("Nostr {proof}"))) + .uri(PUT_URL) + .to_request(); + let res = test::call_service(&app, req).await; + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); Ok(()) } diff --git a/src/rpc/auth/whoami.rs b/src/rpc/auth/whoami.rs index 3d478cc..774281a 100644 --- a/src/rpc/auth/whoami.rs +++ b/src/rpc/auth/whoami.rs @@ -8,6 +8,10 @@ use time::OffsetDateTime; pub struct Res { pub name: String, pub roles: Vec, + /// Bech32 npub (`npub1...`) of the Nostr identity linked to this user, + /// or `null` when no pubkey is linked. Mirrors the field on the REST + /// `GET /v4/users/me` response so both whoami surfaces stay in sync. + pub npub: Option, #[serde(with = "time::serde::rfc3339")] pub created_at: OffsetDateTime, } @@ -17,6 +21,7 @@ pub async fn run(user: &User) -> Result { Ok(Res { name: user.name.clone(), roles, + npub: user.npub.clone(), created_at: OffsetDateTime::parse(&user.created_at, &Rfc3339)?, }) } @@ -47,12 +52,33 @@ mod test { assert_eq!(result.name, "Test User"); assert_eq!(result.roles, vec!["admin".to_string(), "user".to_string()]); + assert_eq!(result.npub, None); assert_eq!( result.created_at, OffsetDateTime::parse("2023-01-01T00:00:00Z", &Rfc3339).unwrap() ); } + #[test] + async fn passes_through_linked_npub() { + let user = User { + id: 1, + name: "Nostr User".to_string(), + password: "".to_string(), + roles: vec![Role::User], + saved_places: vec![], + saved_areas: vec![], + npub: Some("npub1example".to_string()), + created_at: "2023-01-01T00:00:00Z".to_string(), + updated_at: "2023-01-01T00:00:00Z".to_string(), + deleted_at: None, + }; + + let result = super::run(&user).await.unwrap(); + + assert_eq!(result.npub, Some("npub1example".to_string())); + } + #[test] async fn empty_name() { // Test with empty name (should still work)