Skip to main content

pray_core/
auth_store_tokens.rs

1use super::secrets::*;
2use super::support::*;
3use super::RegistryAuthStore;
4use crate::{PrayError, PrayResult};
5use rusqlite::OptionalExtension;
6
7pub const PUBLISH_SCOPE: &str = "publish";
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct PublishTokenRecord {
11    pub email: String,
12    pub token: String,
13    pub scopes: Vec<String>,
14}
15
16impl RegistryAuthStore {
17    pub fn ensure_publish_tokens_table(&self) -> PrayResult<()> {
18        let connection = self.connection()?;
19        connection.execute_batch(
20            "CREATE TABLE IF NOT EXISTS publish_tokens (
21                token TEXT PRIMARY KEY,
22                email TEXT NOT NULL,
23                scopes TEXT NOT NULL,
24                created_at INTEGER NOT NULL,
25                last_used_at INTEGER,
26                FOREIGN KEY(email) REFERENCES users(email) ON DELETE CASCADE
27            );",
28        )?;
29        Ok(())
30    }
31
32    pub fn issue_publish_token(
33        &self,
34        email: &str,
35        scopes: &[String],
36    ) -> PrayResult<PublishTokenRecord> {
37        validate_email(email)?;
38        self.ensure_publish_tokens_table()?;
39        let scopes = normalize_scopes(scopes)?;
40        let connection = self.connection()?;
41        let exists: Option<String> = connection
42            .query_row(
43                "SELECT email FROM users WHERE email = ?1",
44                rusqlite::params![email],
45                |row| row.get(0),
46            )
47            .optional()?;
48        if exists.is_none() {
49            return Err(PrayError::Resolution(format!("unknown user: {email}")));
50        }
51        let timestamp = current_unix_timestamp()?;
52        let token = generate_publish_token()?;
53        let stored_token = stored_token(&token);
54        let connection = self.connection()?;
55        connection.execute(
56            "INSERT INTO publish_tokens (token, email, scopes, created_at, last_used_at)
57             VALUES (?1, ?2, ?3, ?4, ?4)",
58            rusqlite::params![stored_token, email, scopes.join(","), timestamp],
59        )?;
60        Ok(PublishTokenRecord {
61            email: email.to_string(),
62            token,
63            scopes,
64        })
65    }
66
67    pub fn resolve_publish_token(&self, token: &str) -> PrayResult<Option<PublishTokenRecord>> {
68        if token.trim().is_empty() {
69            return Ok(None);
70        }
71        self.ensure_publish_tokens_table()?;
72        let connection = self.connection()?;
73        let stored_token = stored_token(token);
74        let row: Option<(String, String, u64)> = connection
75            .query_row(
76                "SELECT email, scopes, created_at FROM publish_tokens WHERE token = ?1",
77                rusqlite::params![stored_token],
78                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
79            )
80            .optional()?;
81        let Some((email, scopes_text, created_at)) = row else {
82            return Ok(None);
83        };
84        if record_expired(created_at, PUBLISH_TOKEN_TTL_SECONDS)? {
85            return Ok(None);
86        }
87        let scopes = parse_scopes(&scopes_text);
88        if !scopes.iter().any(|scope| scope == PUBLISH_SCOPE) {
89            return Err(PrayError::Resolution(
90                "publish token missing publish scope".to_string(),
91            ));
92        }
93        let timestamp = current_unix_timestamp()?;
94        connection.execute(
95            "UPDATE publish_tokens SET last_used_at = ?2 WHERE token = ?1",
96            rusqlite::params![stored_token, timestamp],
97        )?;
98        Ok(Some(PublishTokenRecord {
99            email,
100            token: token.to_string(),
101            scopes,
102        }))
103    }
104
105    pub fn revoke_publish_token(&self, token: &str) -> PrayResult<()> {
106        self.ensure_publish_tokens_table()?;
107        let connection = self.connection()?;
108        let stored_token = stored_token(token);
109        let deleted = connection.execute(
110            "DELETE FROM publish_tokens WHERE token = ?1",
111            rusqlite::params![stored_token],
112        )?;
113        if deleted == 0 {
114            return Err(PrayError::Resolution("publish token not found".to_string()));
115        }
116        Ok(())
117    }
118}
119
120pub fn bearer_token_from_authorization(header: Option<&str>) -> Option<String> {
121    let header = header?.trim();
122    let token = header
123        .strip_prefix("Bearer ")
124        .or_else(|| header.strip_prefix("bearer "))?;
125    let token = token.trim();
126    if token.is_empty() {
127        None
128    } else {
129        Some(token.to_string())
130    }
131}
132
133fn normalize_scopes(scopes: &[String]) -> PrayResult<Vec<String>> {
134    let mut normalized = Vec::new();
135    for scope in scopes {
136        let scope = scope.trim().to_ascii_lowercase();
137        if scope.is_empty() {
138            continue;
139        }
140        if scope != PUBLISH_SCOPE && scope != "publish-new" && scope != "publish-update" {
141            return Err(PrayError::Unsupported(format!(
142                "unsupported publish token scope: {scope}"
143            )));
144        }
145        if !normalized.iter().any(|existing| existing == &scope) {
146            normalized.push(scope);
147        }
148    }
149    if !normalized.iter().any(|scope| scope == PUBLISH_SCOPE) {
150        normalized.insert(0, PUBLISH_SCOPE.to_string());
151    }
152    Ok(normalized)
153}
154
155fn parse_scopes(scopes_text: &str) -> Vec<String> {
156    scopes_text
157        .split(',')
158        .map(str::trim)
159        .filter(|scope| !scope.is_empty())
160        .map(|scope| scope.to_ascii_lowercase())
161        .collect()
162}