Skip to main content

anytype_rpc/
auth.rs

1//! Authentication helpers for Anytype gRPC clients.
2
3use tonic::{
4    metadata::{Ascii, MetadataValue},
5    service::Interceptor,
6    {Request, Status, transport::Channel},
7};
8
9use crate::client::AnytypeGrpcConfig;
10use crate::deadline::{
11    GrpcCallOptions, GrpcDeadlineError, GrpcDeadlineService, GrpcTimeoutClass, GrpcTimeoutOutcome,
12    GrpcTimeoutPolicy, with_grpc_call_options,
13};
14use crate::error::AuthError;
15use crate::{
16    anytype::ClientCommandsClient,
17    anytype::rpc::account::local_link::{
18        new_challenge::Request as LocalLinkChallengeRequest,
19        new_challenge::Response as LocalLinkChallengeResponse,
20        solve_challenge::Request as LocalLinkSolveRequest,
21        solve_challenge::Response as LocalLinkSolveResponse,
22    },
23    anytype::rpc::wallet::create_session::{
24        Request as CreateSessionRequest, Response as CreateSessionResponse, request::Auth,
25    },
26    model::account::auth::LocalApiScope,
27};
28
29/// Authentication options for `WalletCreateSession`.
30#[derive(Clone)]
31pub enum SessionAuth {
32    /// Local app key created via LocalLink (limited scope).
33    AppKey(String),
34    /// Account key from the headless CLI (full scope).
35    AccountKey(String),
36    /// Mnemonic phrase (full scope).
37    Mnemonic(String),
38    /// Existing session token to refresh.
39    Token(String),
40}
41
42impl std::fmt::Debug for SessionAuth {
43    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        formatter.write_str(match self {
45            Self::AppKey(_) => "SessionAuth::AppKey(redacted)",
46            Self::AccountKey(_) => "SessionAuth::AccountKey(redacted)",
47            Self::Mnemonic(_) => "SessionAuth::Mnemonic(redacted)",
48            Self::Token(_) => "SessionAuth::Token(redacted)",
49        })
50    }
51}
52
53impl SessionAuth {
54    fn into_request(self) -> CreateSessionRequest {
55        let auth = match self {
56            SessionAuth::AppKey(value) => Auth::AppKey(value),
57            SessionAuth::AccountKey(value) => Auth::AccountKey(value),
58            SessionAuth::Mnemonic(value) => Auth::Mnemonic(value),
59            SessionAuth::Token(value) => Auth::Token(value),
60        };
61        CreateSessionRequest { auth: Some(auth) }
62    }
63}
64
65/// Create a session and return the full response for additional fields (like `app_token`).
66pub async fn create_session(
67    channel: Channel,
68    auth: SessionAuth,
69) -> Result<CreateSessionResponse, AuthError> {
70    let policy = GrpcTimeoutPolicy::resolve(None)?;
71    create_session_with_policy(channel, auth, policy).await
72}
73
74/// Creates a session using an already resolved logical deadline policy.
75pub async fn create_session_with_policy(
76    channel: Channel,
77    auth: SessionAuth,
78    policy: GrpcTimeoutPolicy,
79) -> Result<CreateSessionResponse, AuthError> {
80    let policy = policy.validate()?;
81    let mut client = ClientCommandsClient::new(GrpcDeadlineService::new_resolved(channel, policy));
82    let request = with_grpc_call_options(
83        Request::new(auth.into_request()),
84        GrpcCallOptions::new(
85            GrpcTimeoutClass::CredentialSetup,
86            GrpcTimeoutOutcome::MutationIndeterminate,
87        ),
88    );
89    let started = std::time::Instant::now();
90    let response: tonic::Response<CreateSessionResponse> = client
91        .wallet_create_session(request)
92        .await
93        .map_err(|status| {
94            deadline_or_auth_status(
95                status,
96                GrpcTimeoutClass::CredentialSetup,
97                GrpcTimeoutOutcome::MutationIndeterminate,
98                started.elapsed(),
99            )
100        })?;
101    let response = response.into_inner();
102
103    if let Some(error) = response.error.as_ref()
104        && error.code != 0
105    {
106        return Err(AuthError::Api {
107            code: error.code,
108            description: error.description.clone(),
109        });
110    }
111
112    Ok(response)
113}
114
115/// Create a session and return just the session token.
116pub async fn create_session_token(
117    channel: Channel,
118    auth: SessionAuth,
119) -> Result<String, AuthError> {
120    let response = create_session(channel, auth).await?;
121    if response.token.is_empty() {
122        return Err(AuthError::EmptyToken);
123    }
124    Ok(response.token)
125}
126
127/// Create a session token from a LocalLink app key.
128pub async fn create_session_token_from_app_key(
129    channel: Channel,
130    app_key: impl AsRef<str>,
131) -> Result<String, AuthError> {
132    create_session_token(channel, SessionAuth::AppKey(app_key.as_ref().to_string())).await
133}
134
135/// Creates an app-key session token with an already resolved deadline policy.
136pub async fn create_session_token_from_app_key_with_policy(
137    channel: Channel,
138    app_key: impl AsRef<str>,
139    policy: GrpcTimeoutPolicy,
140) -> Result<String, AuthError> {
141    let response = create_session_with_policy(
142        channel,
143        SessionAuth::AppKey(app_key.as_ref().to_string()),
144        policy,
145    )
146    .await?;
147    if response.token.is_empty() {
148        return Err(AuthError::EmptyToken);
149    }
150    Ok(response.token)
151}
152
153/// Create a session token from a headless account key.
154pub async fn create_session_token_from_account_key(
155    channel: Channel,
156    account_key: impl AsRef<str>,
157) -> Result<String, AuthError> {
158    create_session_token(
159        channel,
160        SessionAuth::AccountKey(account_key.as_ref().to_string()),
161    )
162    .await
163}
164
165/// Creates an account-key session token with an already resolved deadline policy.
166pub async fn create_session_token_from_account_key_with_policy(
167    channel: Channel,
168    account_key: impl AsRef<str>,
169    policy: GrpcTimeoutPolicy,
170) -> Result<String, AuthError> {
171    let response = create_session_with_policy(
172        channel,
173        SessionAuth::AccountKey(account_key.as_ref().to_string()),
174        policy,
175    )
176    .await?;
177    if response.token.is_empty() {
178        return Err(AuthError::EmptyToken);
179    }
180    Ok(response.token)
181}
182
183/// Response from LocalLink SolveChallenge.
184#[derive(Clone)]
185pub struct LocalLinkCredentials {
186    pub app_key: String,
187    pub session_token: Option<String>,
188}
189
190impl std::fmt::Debug for LocalLinkCredentials {
191    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
192        formatter
193            .debug_struct("LocalLinkCredentials")
194            .field("app_key", &"redacted")
195            .field("session_token_configured", &self.session_token.is_some())
196            .finish()
197    }
198}
199
200/// Create a LocalLink challenge for the given app name and scope.
201pub async fn create_local_link_challenge(
202    channel: Channel,
203    app_name: impl Into<String>,
204    scope: LocalApiScope,
205) -> Result<String, AuthError> {
206    let policy = GrpcTimeoutPolicy::resolve(None)?;
207    create_local_link_challenge_with_policy(channel, app_name, scope, policy).await
208}
209
210/// Creates a LocalLink challenge using an explicit client configuration.
211pub async fn create_local_link_challenge_with_config(
212    channel: Channel,
213    app_name: impl Into<String>,
214    scope: LocalApiScope,
215    config: &AnytypeGrpcConfig,
216) -> Result<String, AuthError> {
217    let policy = config.resolved_grpc_timeouts()?;
218    create_local_link_challenge_with_policy(channel, app_name, scope, policy).await
219}
220
221/// Creates a LocalLink challenge using an already resolved deadline policy.
222pub async fn create_local_link_challenge_with_policy(
223    channel: Channel,
224    app_name: impl Into<String>,
225    scope: LocalApiScope,
226    policy: GrpcTimeoutPolicy,
227) -> Result<String, AuthError> {
228    let policy = policy.validate()?;
229    let mut client = ClientCommandsClient::new(GrpcDeadlineService::new_resolved(channel, policy));
230    let request = LocalLinkChallengeRequest {
231        app_name: app_name.into(),
232        scope: scope as i32,
233    };
234    let request = with_grpc_call_options(
235        Request::new(request),
236        GrpcCallOptions::new(
237            GrpcTimeoutClass::CredentialSetup,
238            GrpcTimeoutOutcome::MutationIndeterminate,
239        ),
240    );
241    let started = std::time::Instant::now();
242    let response: tonic::Response<LocalLinkChallengeResponse> = client
243        .account_local_link_new_challenge(request)
244        .await
245        .map_err(|status| {
246            deadline_or_auth_status(
247                status,
248                GrpcTimeoutClass::CredentialSetup,
249                GrpcTimeoutOutcome::MutationIndeterminate,
250                started.elapsed(),
251            )
252        })?;
253    let response = response.into_inner();
254    if let Some(error) = response.error.as_ref()
255        && error.code != 0
256    {
257        return Err(AuthError::Api {
258            code: error.code,
259            description: error.description.clone(),
260        });
261    }
262    Ok(response.challenge_id)
263}
264
265/// Solve a LocalLink challenge and return the app key.
266pub async fn solve_local_link_challenge(
267    channel: Channel,
268    challenge_id: impl Into<String>,
269    answer: impl Into<String>,
270) -> Result<LocalLinkCredentials, AuthError> {
271    let policy = GrpcTimeoutPolicy::resolve(None)?;
272    solve_local_link_challenge_with_policy(channel, challenge_id, answer, policy).await
273}
274
275/// Solves a LocalLink challenge using an explicit client configuration.
276pub async fn solve_local_link_challenge_with_config(
277    channel: Channel,
278    challenge_id: impl Into<String>,
279    answer: impl Into<String>,
280    config: &AnytypeGrpcConfig,
281) -> Result<LocalLinkCredentials, AuthError> {
282    let policy = config.resolved_grpc_timeouts()?;
283    solve_local_link_challenge_with_policy(channel, challenge_id, answer, policy).await
284}
285
286/// Solves a LocalLink challenge using an already resolved deadline policy.
287pub async fn solve_local_link_challenge_with_policy(
288    channel: Channel,
289    challenge_id: impl Into<String>,
290    answer: impl Into<String>,
291    policy: GrpcTimeoutPolicy,
292) -> Result<LocalLinkCredentials, AuthError> {
293    let policy = policy.validate()?;
294    let mut client = ClientCommandsClient::new(GrpcDeadlineService::new_resolved(channel, policy));
295    let request = LocalLinkSolveRequest {
296        challenge_id: challenge_id.into(),
297        answer: answer.into(),
298    };
299    let request = with_grpc_call_options(
300        Request::new(request),
301        GrpcCallOptions::new(
302            GrpcTimeoutClass::CredentialSetup,
303            GrpcTimeoutOutcome::MutationIndeterminate,
304        ),
305    );
306    let started = std::time::Instant::now();
307    let response: tonic::Response<LocalLinkSolveResponse> = client
308        .account_local_link_solve_challenge(request)
309        .await
310        .map_err(|status| {
311            deadline_or_auth_status(
312                status,
313                GrpcTimeoutClass::CredentialSetup,
314                GrpcTimeoutOutcome::MutationIndeterminate,
315                started.elapsed(),
316            )
317        })?;
318    let response = response.into_inner();
319    if let Some(error) = response.error.as_ref()
320        && error.code != 0
321    {
322        return Err(AuthError::Api {
323            code: error.code,
324            description: error.description.clone(),
325        });
326    }
327    Ok(LocalLinkCredentials {
328        app_key: response.app_key,
329        session_token: if response.session_token.is_empty() {
330            None
331        } else {
332            Some(response.session_token)
333        },
334    })
335}
336
337/// Convenience helper to add the `token` metadata to a request.
338pub fn with_token<T>(mut request: Request<T>, token: &str) -> Result<Request<T>, AuthError> {
339    let token_value: MetadataValue<Ascii> = token.parse()?;
340    request.metadata_mut().insert("token", token_value);
341    Ok(request)
342}
343
344/// gRPC interceptor that injects a static session token.
345pub struct TokenInterceptor {
346    token: MetadataValue<Ascii>,
347}
348
349impl TokenInterceptor {
350    pub fn new(token: impl AsRef<str>) -> Result<Self, AuthError> {
351        let token_value: MetadataValue<Ascii> = token.as_ref().parse()?;
352        Ok(Self { token: token_value })
353    }
354}
355
356impl Interceptor for TokenInterceptor {
357    fn call(&mut self, mut request: Request<()>) -> Result<Request<()>, Status> {
358        request.metadata_mut().insert("token", self.token.clone());
359        Ok(request)
360    }
361}
362
363fn deadline_or_auth_status(
364    status: Status,
365    class: GrpcTimeoutClass,
366    outcome: GrpcTimeoutOutcome,
367    elapsed: std::time::Duration,
368) -> AuthError {
369    GrpcDeadlineError::from_status(&status, class, outcome, elapsed).map_or_else(
370        || AuthError::Status { source: status },
371        |source| AuthError::Deadline { source },
372    )
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378
379    #[test]
380    fn credential_debug_output_is_redacted() {
381        for auth in [
382            SessionAuth::AppKey("APP_KEY_SECRET".to_owned()),
383            SessionAuth::AccountKey("ACCOUNT_KEY_SECRET".to_owned()),
384            SessionAuth::Mnemonic("MNEMONIC_SECRET".to_owned()),
385            SessionAuth::Token("TOKEN_SECRET".to_owned()),
386        ] {
387            assert!(!format!("{auth:?}").contains("SECRET"));
388        }
389        let credentials = LocalLinkCredentials {
390            app_key: "APP_KEY_SECRET".to_owned(),
391            session_token: Some("TOKEN_SECRET".to_owned()),
392        };
393        assert!(!format!("{credentials:?}").contains("SECRET"));
394    }
395}