Skip to main content

systemprompt_cloud/operator/
mod.rs

1//! Operator identity for self-managed tenants.
2//!
3//! A self-managed tenant is not reachable through the systemprompt.io
4//! control plane, so its operators sign in against an OIDC issuer the
5//! tenant trusts (`security.trusted_issuers` on the tenant).
6//! [`run_operator_login`] runs the browser authorization-code flow with PKCE
7//! over the loopback callback and stores the issuer's ID token as
8//! [`OperatorCredentials`]; [`exchange_for_tenant`] trades it for a tenant
9//! session through RFC 8693 token exchange at the tenant's own token endpoint.
10//! The ID token's signature is verified by the tenant during that exchange, not
11//! here.
12//!
13//! Copyright (c) systemprompt.io — Business Source License 1.1.
14//! See <https://systemprompt.io> for licensing details.
15
16mod endpoints;
17mod login;
18
19use std::fs;
20use std::path::Path;
21
22use base64::prelude::*;
23use chrono::{DateTime, Utc};
24use serde::{Deserialize, Serialize};
25use systemprompt_identifiers::{ClientId, CloudAuthToken, Email};
26
27use crate::error::{CloudError, CloudResult};
28use crate::private_dir::write_private_json;
29use crate::{CloudApiClient, auth};
30
31pub use login::{LoginIo, OperatorLoginRequest, run_operator_login, run_operator_login_with};
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct OperatorCredentials {
35    pub issuer: String,
36
37    pub client_id: ClientId,
38
39    pub id_token: CloudAuthToken,
40
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub email: Option<Email>,
43
44    pub authenticated_at: DateTime<Utc>,
45}
46
47#[derive(Deserialize)]
48struct IdentityClaims {
49    #[serde(default)]
50    email: Option<String>,
51    #[serde(default)]
52    nonce: Option<String>,
53}
54
55impl OperatorCredentials {
56    #[must_use]
57    pub fn is_expired(&self) -> bool {
58        auth::is_expired(&self.id_token)
59    }
60
61    pub fn load_from_path(path: &Path) -> CloudResult<Self> {
62        if !path.exists() {
63            return Err(CloudError::OperatorNotAuthenticated);
64        }
65        let content = fs::read_to_string(path)?;
66        serde_json::from_str(&content).map_err(|e| CloudError::CredentialsCorrupted {
67            source: Box::new(e),
68        })
69    }
70
71    pub fn load_valid(path: &Path) -> CloudResult<Self> {
72        let creds = Self::load_from_path(path)?;
73        if creds.is_expired() {
74            return Err(CloudError::OperatorNotAuthenticated);
75        }
76        Ok(creds)
77    }
78
79    pub fn save_to_path(&self, path: &Path) -> CloudResult<()> {
80        write_private_json(path, self)
81    }
82
83    pub fn delete_from_path(path: &Path) -> CloudResult<()> {
84        if path.exists() {
85            fs::remove_file(path)?;
86        }
87        Ok(())
88    }
89}
90
91fn identity_claims(id_token: &str) -> CloudResult<IdentityClaims> {
92    let payload = id_token.split('.').nth(1).ok_or(CloudError::JwtDecode)?;
93    let raw = BASE64_URL_SAFE_NO_PAD
94        .decode(payload)
95        .map_err(|_e| CloudError::JwtDecode)?;
96    serde_json::from_slice(&raw).map_err(|_e| CloudError::JwtDecode)
97}
98
99#[derive(Debug, Clone)]
100pub struct TenantSession {
101    pub access_token: String,
102}
103
104pub async fn exchange_for_tenant(
105    tenant_base_url: &str,
106    operator: &OperatorCredentials,
107) -> CloudResult<TenantSession> {
108    let client = CloudApiClient::new(tenant_base_url, operator.id_token.as_str())?;
109    let access_token = client.tenant_session_token().await?;
110    Ok(TenantSession { access_token })
111}