Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
//! Client <=> Signer authing (“pubkyauth”) as a single, self-contained flow.
//!
//! ## TL;DR (happy path)
//!
//! ### Sign in
//! ```no_run
//! # use pubky::{Capabilities, PubkyAuthFlow, AuthFlowKind};
//! # async fn run() -> pubky::Result<()> {
//! let caps = Capabilities::default();
//! let flow = PubkyAuthFlow::start(&caps, AuthFlowKind::signin())?; // starts background polling immediately
//! println!("Scan to sign in: {}", flow.authorization_url());
//!
//! // Blocks until the signer (e.g., Pubky Ring) approves and server issues a session.
//! let session = flow.await_approval().await?;
//! println!("Signed in as {}", session.info().public_key());
//! # Ok(()) }
//! ```
//!
//! ### Sign up
//! ```no_run
//! # use pubky::{Capabilities, PubkyAuthFlow, AuthFlowKind, PublicKey};
//! # async fn run() -> pubky::Result<()> {
//! let caps = Capabilities::default();
//! let homeserver_public_key: PublicKey = "8pinxxgqs41n4aididenw5apqp1urfmzdztr8jt4abrkdn435ewo".parse().unwrap();
//! let signup_token = "1234567890";
//! let flow = PubkyAuthFlow::start(&caps, AuthFlowKind::signup(homeserver_public_key, Some(signup_token.to_string())))?; // starts background polling immediately
//! println!("Scan to sign up: {}", flow.authorization_url());
//!
//! // Blocks until the signer (e.g., Pubky Ring) approves and server issues a session.
//! let session = flow.await_approval().await?;
//! println!("Signed in as {}", session.info().public_key());
//! # Ok(()) }
//! ```
//!
//! ## Custom relay / non-blocking UI
//! ```no_run
//! # use pubky::{Capabilities, PubkyAuthFlow, AuthFlowKind};
//! # use std::time::Duration;
//! # async fn ui() -> pubky::Result<()> {
//! let flow = PubkyAuthFlow::builder(&Capabilities::default(), AuthFlowKind::signin())
//!     .relay(url::Url::parse("http://localhost:8080/inbox/")?) // your relay
//!     .start()?; // starts background polling immediately
//!
//! // show_qr(flow.authorization_url()); // render QR or deeplink in your UI
//!
//! loop {
//!     if let Some(session) = flow.try_poll_once().await? {
//!         // on_logged_in(session);
//!         break;
//!     }
//!     tokio::time::sleep(Duration::from_millis(300)).await;
//! }
//! # Ok(()) }
//! ```
//!
//! ## How it works (security)
//! Each flow generates a random `client_secret` (32 bytes). The relay **channel id**
//! is `base64url( hash(client_secret) )`. The signer encrypts an `AuthToken` with
//! `client_secret` and POSTs it to the channel; your app long-polls `GET` on the same
//! URL and decrypts the payload locally. The relay **cannot decrypt anything**, it
//! simply forwards bytes.
//!
//! ## Persisting `authorization_url` for resume
//!
//! [`PubkyAuthFlow::authorization_url()`] embeds the `client_secret` in plaintext.
//! If you persist it to survive refresh/restart and later call
//! [`Pubky::resume_auth_flow`](crate::Pubky::resume_auth_flow), treat it as a
//! short-lived secret: store it only in appropriate secure storage and delete
//! it once the flow completes or is abandoned.
//!
//! The relay inbox TTL is ~5 minutes; after expiry, start a fresh flow.

use url::Url;

use pubky_common::crypto::random_bytes;

use crate::{
    AuthToken, Capabilities, PubkyHttpClient, PubkySession, PublicKey,
    actors::{
        DEFAULT_HTTP_RELAY_INBOX,
        auth::{
            auth_subscription::AuthSubscription,
            deep_links::{DeepLink, SigninDeepLink, SignupDeepLink},
        },
    },
    errors::Result,
};

/// End-to-end **auth flow** (request + live polling) you *hold on to*.
///
/// Supports both sign in and sign up flows.
///
/// Use it like this:
/// 1. Construct with [`PubkyAuthFlow::start`] (happy path) or the builder
///    [`PubkyAuthFlow::builder`] to override relay/client.
/// 2. Display [`authorization_url`](Self::authorization_url) (QR/deeplink) to the signer.
/// 3. Complete the flow with [`await_approval`](Self::await_approval) **or**
///    poll with [`try_poll_once`](Self::try_poll_once) / [`try_token`](Self::try_token).
///
/// Background polling **starts immediately** at construction. Dropping this value cancels
/// the background task; the relay channel itself expires server-side after its TTL.
#[derive(Debug)]
pub struct PubkyAuthFlow {
    subscription: AuthSubscription,
    auth_url: DeepLink,
}

impl PubkyAuthFlow {
    /// Start a flow with the default HTTP relay.
    ///
    /// Spawns the background poller immediately and returns a handle.
    ///
    /// # Errors
    /// - Returns [`crate::errors::Error`] if constructing the backing [`PubkyHttpClient`]
    ///   or generating the relay URL fails.
    pub fn start(caps: &Capabilities, auth_kind: AuthFlowKind) -> Result<Self> {
        PubkyAuthFlowBuilder::new(caps.clone(), auth_kind).start()
    }

    /// Create a builder to override **relay** and/or provide a custom **client**.
    #[must_use]
    pub fn builder(caps: &Capabilities, auth_kind: AuthFlowKind) -> PubkyAuthFlowBuilder {
        PubkyAuthFlowBuilder::new(caps.clone(), auth_kind)
    }

    /// The `pubkyauth://` deep link you display (QR/URL) to the signer.
    ///
    /// Contains the **capabilities**, **`client_secret`** (base64url), and **relay** base.
    ///
    /// # Security
    ///
    /// This URL contains the `client_secret` in plaintext. Treat it as a
    /// short-lived secret and delete it after the flow completes.
    /// See the [module-level docs](self) for storage guidance.
    #[must_use]
    pub fn authorization_url(&self) -> Url {
        self.auth_url.clone().into()
    }

    /// Block until the signer approves and the server issues a session.
    ///
    /// This awaits the background poller’s result, verifies/decrypts the token,
    /// and completes the `/session` exchange to return a ready-to-use [`PubkySession`].
    ///
    /// # Errors
    /// - Returns [`crate::errors::Error::Authentication`] if the relay channel expires before approval.
    /// - Propagates HTTP/transport failures while polling the relay.
    /// - Propagates errors from the internal session exchange if it fails.
    pub async fn await_approval(self) -> Result<PubkySession> {
        self.subscription.await_approval().await
    }

    /// Block until the signer approves and we receive an [`AuthToken`].
    ///
    /// This awaits the background poller’s result.
    ///
    /// # Errors
    /// - Returns [`crate::errors::Error::Authentication`] if the relay channel expires before approval.
    /// - Propagates HTTP/transport failures encountered while polling the relay.
    pub async fn await_token(self) -> Result<AuthToken> {
        self.subscription.await_token().await
    }

    /// Non-blocking probe (single step) that **consumes any ready token** and returns:
    /// - `Ok(Some(session))` when a token was delivered and the session established.
    /// - `Ok(None)` if no payload yet (keep polling later).
    /// - `Err(e)` on transport/server errors or if the channel expired.
    ///
    /// # Errors
    /// - Returns [`crate::errors::Error::Authentication`] if the relay channel expired before a token arrived.
    /// - Propagates HTTP/transport failures from constructing the session.
    pub async fn try_poll_once(&self) -> Result<Option<PubkySession>> {
        self.subscription.try_poll_once().await
    }

    /// Non-blocking check: returns a verified `AuthToken` if the background poller has delivered it.
    ///
    /// - `Some(Ok(AuthToken))` when ready.
    /// - `Some(Err(_))` if the background task failed (expired/transport error).
    /// - `None` if not yet delivered.
    #[must_use]
    pub fn try_token(&self) -> Option<Result<AuthToken>> {
        self.subscription.try_token()
    }
}

/// The kind of authentication flow to perform.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthFlowKind {
    /// Sign in to an existing account.
    SignIn,
    /// Sign up for a new account.
    SignUp {
        /// The public key of the homeserver to sign up on.
        homeserver_public_key: Box<PublicKey>,
        /// The signup token to use for the signup flow.
        /// This is optional.
        signup_token: Option<String>,
    },
}

impl AuthFlowKind {
    /// Create a sign in flow.
    #[must_use]
    pub fn signin() -> Self {
        Self::SignIn
    }

    /// Create a sign up flow.
    /// # Arguments
    /// * `homeserver_public_key` - The public key of the homeserver to sign up on.
    /// * `signup_token` - The signup token to use for the signup flow. This is optional.
    #[must_use]
    pub fn signup(homeserver_public_key: PublicKey, signup_token: Option<String>) -> Self {
        Self::SignUp {
            homeserver_public_key: Box::new(homeserver_public_key),
            signup_token,
        }
    }
}

/// Builder for [`PubkyAuthFlow`].
///
/// Use to override the HTTP relay and/or the `PubkyHttpClient`.
#[derive(Debug, Clone)]
pub struct PubkyAuthFlowBuilder {
    caps: Capabilities,
    base_relay: Url,
    client: Option<PubkyHttpClient>,
    auth_kind: AuthFlowKind,
    client_secret: [u8; 32],
}

impl PubkyAuthFlowBuilder {
    /// Create a new builder for the auth flow.
    /// # Arguments
    /// * `caps` - The capabilities to use for the auth flow.
    /// * `auth_kind` - The kind of auth flow to perform.
    /// # Returns
    /// A new builder for the auth flow.
    pub(crate) fn new(caps: Capabilities, auth_kind: AuthFlowKind) -> Self {
        Self {
            caps,
            base_relay: Url::parse(DEFAULT_HTTP_RELAY_INBOX)
                .expect("Should be able to parse the default HTTP relay"),
            client: None,
            auth_kind,
            client_secret: random_bytes::<32>(),
        }
    }

    /// Set a custom relay base URL. Trailing slash optional.
    pub fn relay(mut self, url: Url) -> Self {
        self.base_relay = url;
        self
    }

    /// Provide a custom `PubkyHttpClient` (e.g., with custom TLS, roots, or test wiring).
    pub fn client(mut self, client: PubkyHttpClient) -> Self {
        self.client = Some(client);
        self
    }

    /// Set the client secret to use for the auth flow.
    /// By default, a random client secret is generated.
    pub fn client_secret(mut self, client_secret: [u8; 32]) -> Self {
        self.client_secret = client_secret;
        self
    }

    /// Finalize: derive channel, compute the `pubkyauth://` deep link, spawn the background poller,
    /// and return the flow handle.
    pub fn start(self) -> Result<PubkyAuthFlow> {
        let client = match &self.client {
            Some(c) => c.clone(),
            None => PubkyHttpClient::new()?,
        };

        let auth_url = self.create_url();

        let subscription = AuthSubscription::builder(self.client_secret)
            .relay_base_url(self.base_relay)
            .client(client)
            .start()?;

        Ok(PubkyAuthFlow {
            subscription,
            auth_url,
        })
    }

    /// Create the auth URL for the auth flow.
    /// Depending on the auth kind, the URL will be different
    fn create_url(&self) -> DeepLink {
        match &self.auth_kind {
            AuthFlowKind::SignIn => DeepLink::Signin(SigninDeepLink::new(
                self.caps.clone(),
                self.base_relay.clone(),
                self.client_secret,
            )),
            AuthFlowKind::SignUp {
                homeserver_public_key,
                signup_token,
            } => DeepLink::Signup(SignupDeepLink::new(
                self.caps.clone(),
                self.base_relay.clone(),
                self.client_secret,
                *homeserver_public_key.clone(),
                signup_token.clone(),
            )),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{Keypair, Pubky};
    use std::str::FromStr;

    async fn assert_resume_reconnects(auth_kind: AuthFlowKind) {
        let relay = http_relay::HttpRelay::builder()
            .http_port(0)
            .run()
            .await
            .unwrap();
        let inbox_base = relay.local_url().join("inbox").unwrap();
        let client = PubkyHttpClient::new().unwrap();
        let pubky = Pubky::with_client(client.clone());

        let caps = Capabilities::default();
        let flow = PubkyAuthFlow::builder(&caps, auth_kind)
            .client(client.clone())
            .relay(inbox_base)
            .start()
            .unwrap();

        let auth_url_str = flow.authorization_url().as_str().to_string();

        let deep_link = DeepLink::from_str(&auth_url_str).unwrap();
        let secret = match &deep_link {
            DeepLink::Signin(s) => *s.secret(),
            DeepLink::Signup(s) => *s.secret(),
            _ => panic!("Expected signin or signup deep link"),
        };

        // Drop the original flow — simulates a page refresh wiping WASM memory.
        drop(flow);

        // Signer approves while the original flow is gone (token waits in the relay inbox).
        let keypair = Keypair::random();
        let token = AuthToken::sign(&keypair, caps);
        let token_bytes = token.serialize();

        let encrypted_channel =
            crate::actors::auth::http_relay_inbox_channel::EncryptedHttpRelayInboxChannel::new(
                relay.local_url().join("inbox").unwrap(),
                secret,
            )
            .unwrap();
        encrypted_channel
            .produce(&client, &token_bytes)
            .await
            .unwrap();

        let resumed = pubky.resume_auth_flow(&auth_url_str).unwrap();

        assert_eq!(
            resumed.authorization_url().as_str(),
            auth_url_str,
            "resumed flow produces the same authorization URL"
        );

        let received_token = resumed.await_token().await.unwrap();
        assert_eq!(
            received_token, token,
            "resumed flow retrieves the original token"
        );
    }

    #[tokio::test]
    async fn resume_signin_reconnects_to_same_channel() {
        assert_resume_reconnects(AuthFlowKind::signin()).await;
    }

    #[tokio::test]
    async fn resume_signup_reconnects_to_same_channel() {
        let homeserver = Keypair::random().public_key();
        let signup_token = Some("test-signup-token".to_string());
        assert_resume_reconnects(AuthFlowKind::signup(homeserver, signup_token)).await;
    }

    #[test]
    fn resume_rejects_invalid_url() {
        let client = PubkyHttpClient::new().unwrap();
        let pubky = Pubky::with_client(client);

        let result = pubky.resume_auth_flow("https://not-a-pubkyauth-url.com");
        assert!(result.is_err(), "non-pubkyauth URL should fail to resume");
    }

    #[test]
    fn resume_rejects_seed_export_url() {
        let client = PubkyHttpClient::new().unwrap();
        let pubky = Pubky::with_client(client);

        let url = "pubkyauth://secret_export?secret=kqnceEMgrNQM_xi06oQXjA3cJHX_RQmw1BY6JE1bse8";
        let result = pubky.resume_auth_flow(url);
        assert!(result.is_err(), "seed export URL should fail to resume");
    }
}