Skip to main content

pray_core/
auth_store.rs

1#[path = "auth_store_keys.rs"]
2mod keys;
3#[path = "auth_store_support.rs"]
4mod support;
5#[path = "auth_store_tokens.rs"]
6mod tokens;
7
8use support::*;
9
10pub use tokens::{bearer_token_from_authorization, PublishTokenRecord, PUBLISH_SCOPE};
11
12use crate::auth::{
13    AuthRegistrationResponse, AuthSessionKind, AuthSessionResponse, AuthVerificationResponse,
14};
15use crate::trust::EmailConfirmationPolicy;
16use crate::{PrayError, PrayResult};
17use rusqlite::{Connection, OptionalExtension};
18use std::fs;
19use std::path::{Path, PathBuf};
20
21#[derive(Debug, Clone)]
22pub struct RegistryAuthStore {
23    database_path: PathBuf,
24}
25
26impl RegistryAuthStore {
27    pub fn open(root: &Path) -> PrayResult<Self> {
28        let database_path = root.join(".pray/auth.db");
29        if let Some(parent) = database_path.parent() {
30            fs::create_dir_all(parent)?;
31        }
32        let store = Self { database_path };
33        store.initialize()?;
34        Ok(store)
35    }
36    pub fn register_email(
37        &self,
38        email: &str,
39        policy: EmailConfirmationPolicy,
40    ) -> PrayResult<AuthRegistrationResponse> {
41        validate_email(email)?;
42        let connection = self.connection()?;
43        let timestamp = current_unix_timestamp()?;
44        let verified = matches!(policy, EmailConfirmationPolicy::Disabled);
45        let verification_code = if verified {
46            None
47        } else {
48            Some(generate_verification_code(email, timestamp))
49        };
50        let policy_text = email_confirmation_policy_text(policy);
51
52        connection.execute(
53        "INSERT INTO users (email, email_verified, email_confirmation_policy, created_at) VALUES (?1, ?2, ?3, ?4)
54         ON CONFLICT(email) DO UPDATE SET email_verified = excluded.email_verified, email_confirmation_policy = excluded.email_confirmation_policy",
55        rusqlite::params![email, verified, policy_text, timestamp],
56    )?;
57        if let Some(code) = verification_code.as_ref() {
58            connection.execute(
59            "INSERT INTO email_verification_codes (email, code, created_at, verified_at)
60             VALUES (?1, ?2, ?3, NULL)
61             ON CONFLICT(email) DO UPDATE SET code = excluded.code, created_at = excluded.created_at, verified_at = NULL",
62            rusqlite::params![email, code, timestamp],
63        )?;
64        }
65
66        Ok(AuthRegistrationResponse {
67            email: email.to_string(),
68            verified,
69            verification_code,
70        })
71    }
72    pub fn verify_email(&self, email: &str, code: &str) -> PrayResult<AuthVerificationResponse> {
73        validate_email(email)?;
74        if code.trim().is_empty() {
75            return Err(PrayError::Unsupported(
76                "verification code cannot be empty".to_string(),
77            ));
78        }
79        let connection = self.connection()?;
80        let stored_code: Option<String> = connection
81            .query_row(
82                "SELECT code FROM email_verification_codes WHERE email = ?1",
83                rusqlite::params![email],
84                |row| row.get(0),
85            )
86            .optional()?;
87        let Some(stored_code) = stored_code else {
88            return Err(PrayError::Resolution(format!(
89                "no verification code found for {email}"
90            )));
91        };
92        if stored_code != code {
93            return Err(PrayError::Resolution(format!(
94                "verification code mismatch for {email}"
95            )));
96        }
97        let timestamp = current_unix_timestamp()?;
98        connection.execute(
99            "UPDATE users SET email_verified = 1 WHERE email = ?1",
100            rusqlite::params![email],
101        )?;
102        connection.execute(
103            "UPDATE email_verification_codes SET verified_at = ?2 WHERE email = ?1",
104            rusqlite::params![email, timestamp],
105        )?;
106        Ok(AuthVerificationResponse {
107            email: email.to_string(),
108            verified: true,
109        })
110    }
111    pub fn user_verified(&self, email: &str) -> PrayResult<bool> {
112        validate_email(email)?;
113        let connection = self.connection()?;
114        let verified: Option<bool> = connection
115            .query_row(
116                "SELECT email_verified FROM users WHERE email = ?1",
117                rusqlite::params![email],
118                |row| row.get(0),
119            )
120            .optional()?;
121        Ok(verified.unwrap_or(false))
122    }
123    pub fn issue_session(
124        &self,
125        email: &str,
126        kind: AuthSessionKind,
127    ) -> PrayResult<AuthSessionResponse> {
128        validate_email(email)?;
129        let connection = self.connection()?;
130        let user: Option<(bool, String)> = connection
131            .query_row(
132                "SELECT email_verified, email_confirmation_policy FROM users WHERE email = ?1",
133                rusqlite::params![email],
134                |row| Ok((row.get(0)?, row.get(1)?)),
135            )
136            .optional()?;
137        let Some((verified, policy)) = user else {
138            return Err(PrayError::Resolution(format!("unknown user: {email}")));
139        };
140        if !verified && policy != email_confirmation_policy_text(EmailConfirmationPolicy::Optional)
141        {
142            return Err(PrayError::Resolution(format!(
143                "email confirmation required for {email}"
144            )));
145        }
146        let timestamp = current_unix_timestamp()?;
147        let token = generate_session_token(email, &kind, timestamp);
148        connection.execute(
149            "INSERT INTO sessions (token, email, kind, created_at, last_used_at)
150         VALUES (?1, ?2, ?3, ?4, ?4)
151         ON CONFLICT(token) DO UPDATE SET last_used_at = excluded.last_used_at",
152            rusqlite::params![token, email, auth_session_kind_text(&kind), timestamp],
153        )?;
154        Ok(AuthSessionResponse {
155            email: email.to_string(),
156            token,
157            kind,
158        })
159    }
160    pub fn resolve_session(&self, token: &str) -> PrayResult<Option<AuthSessionResponse>> {
161        if token.trim().is_empty() {
162            return Ok(None);
163        }
164        let connection = self.connection()?;
165        let session: Option<(String, String)> = connection
166            .query_row(
167                "SELECT email, kind FROM sessions WHERE token = ?1",
168                rusqlite::params![token],
169                |row| Ok((row.get(0)?, row.get(1)?)),
170            )
171            .optional()?;
172        let Some((email, kind_text)) = session else {
173            return Ok(None);
174        };
175        let kind = parse_auth_session_kind(&kind_text)?;
176        let timestamp = current_unix_timestamp()?;
177        connection.execute(
178            "UPDATE sessions SET last_used_at = ?2 WHERE token = ?1",
179            rusqlite::params![token, timestamp],
180        )?;
181        Ok(Some(AuthSessionResponse {
182            email,
183            token: token.to_string(),
184            kind,
185        }))
186    }
187    fn initialize(&self) -> PrayResult<()> {
188        let connection = self.connection()?;
189        connection.execute_batch(
190            "CREATE TABLE IF NOT EXISTS users (
191            email TEXT PRIMARY KEY,
192            email_verified INTEGER NOT NULL,
193            email_confirmation_policy TEXT NOT NULL,
194            created_at INTEGER NOT NULL
195        );
196        CREATE TABLE IF NOT EXISTS email_verification_codes (
197            email TEXT PRIMARY KEY,
198            code TEXT NOT NULL,
199            created_at INTEGER NOT NULL,
200            verified_at INTEGER
201        );
202        CREATE TABLE IF NOT EXISTS passkeys (
203            credential_id TEXT PRIMARY KEY,
204            email TEXT NOT NULL,
205            public_key TEXT NOT NULL,
206            label TEXT,
207            created_at INTEGER NOT NULL,
208            last_used_at INTEGER,
209            FOREIGN KEY(email) REFERENCES users(email) ON DELETE CASCADE
210        );
211        CREATE TABLE IF NOT EXISTS ssh_keys (
212            fingerprint TEXT PRIMARY KEY,
213            email TEXT NOT NULL,
214            public_key TEXT NOT NULL,
215            label TEXT,
216            created_at INTEGER NOT NULL,
217            last_used_at INTEGER,
218            FOREIGN KEY(email) REFERENCES users(email) ON DELETE CASCADE
219        );
220        CREATE TABLE IF NOT EXISTS sessions (
221            token TEXT PRIMARY KEY,
222            email TEXT NOT NULL,
223            kind TEXT NOT NULL,
224            created_at INTEGER NOT NULL,
225            last_used_at INTEGER,
226            FOREIGN KEY(email) REFERENCES users(email) ON DELETE CASCADE
227        );
228        CREATE TABLE IF NOT EXISTS auth_challenges (
229            challenge_id TEXT PRIMARY KEY,
230            email TEXT NOT NULL,
231            kind TEXT NOT NULL,
232            challenge TEXT NOT NULL,
233            created_at INTEGER NOT NULL,
234            used_at INTEGER,
235            FOREIGN KEY(email) REFERENCES users(email) ON DELETE CASCADE
236        );
237        CREATE TABLE IF NOT EXISTS publish_tokens (
238            token TEXT PRIMARY KEY,
239            email TEXT NOT NULL,
240            scopes TEXT NOT NULL,
241            created_at INTEGER NOT NULL,
242            last_used_at INTEGER,
243            FOREIGN KEY(email) REFERENCES users(email) ON DELETE CASCADE
244        );",
245        )?;
246        Ok(())
247    }
248    fn connection(&self) -> PrayResult<Connection> {
249        let connection = Connection::open(&self.database_path)?;
250        connection.pragma_update(None, "foreign_keys", "ON")?;
251        Ok(connection)
252    }
253}
254
255pub use support::ssh_public_key_fingerprint_text;