Skip to main content

pray_core/
auth_store_tokens.rs

1use super::support::*;
2use super::RegistryAuthStore;
3use crate::hashing::sha256_prefixed;
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(email, &scopes.join(","), timestamp);
53        let connection = self.connection()?;
54        connection.execute(
55            "INSERT INTO publish_tokens (token, email, scopes, created_at, last_used_at)
56             VALUES (?1, ?2, ?3, ?4, ?4)",
57            rusqlite::params![token, email, scopes.join(","), timestamp],
58        )?;
59        Ok(PublishTokenRecord {
60            email: email.to_string(),
61            token,
62            scopes,
63        })
64    }
65
66    pub fn resolve_publish_token(&self, token: &str) -> PrayResult<Option<PublishTokenRecord>> {
67        if token.trim().is_empty() {
68            return Ok(None);
69        }
70        self.ensure_publish_tokens_table()?;
71        let connection = self.connection()?;
72        let row: Option<(String, String)> = connection
73            .query_row(
74                "SELECT email, scopes FROM publish_tokens WHERE token = ?1",
75                rusqlite::params![token],
76                |row| Ok((row.get(0)?, row.get(1)?)),
77            )
78            .optional()?;
79        let Some((email, scopes_text)) = row else {
80            return Ok(None);
81        };
82        let scopes = parse_scopes(&scopes_text);
83        if !scopes.iter().any(|scope| scope == PUBLISH_SCOPE) {
84            return Err(PrayError::Resolution(
85                "publish token missing publish scope".to_string(),
86            ));
87        }
88        let timestamp = current_unix_timestamp()?;
89        connection.execute(
90            "UPDATE publish_tokens SET last_used_at = ?2 WHERE token = ?1",
91            rusqlite::params![token, timestamp],
92        )?;
93        Ok(Some(PublishTokenRecord {
94            email,
95            token: token.to_string(),
96            scopes,
97        }))
98    }
99
100    pub fn revoke_publish_token(&self, token: &str) -> PrayResult<()> {
101        self.ensure_publish_tokens_table()?;
102        let connection = self.connection()?;
103        let deleted = connection.execute(
104            "DELETE FROM publish_tokens WHERE token = ?1",
105            rusqlite::params![token],
106        )?;
107        if deleted == 0 {
108            return Err(PrayError::Resolution("publish token not found".to_string()));
109        }
110        Ok(())
111    }
112}
113
114pub fn bearer_token_from_authorization(header: Option<&str>) -> Option<String> {
115    let header = header?.trim();
116    let token = header
117        .strip_prefix("Bearer ")
118        .or_else(|| header.strip_prefix("bearer "))?;
119    let token = token.trim();
120    if token.is_empty() {
121        None
122    } else {
123        Some(token.to_string())
124    }
125}
126
127fn normalize_scopes(scopes: &[String]) -> PrayResult<Vec<String>> {
128    let mut normalized = Vec::new();
129    for scope in scopes {
130        let scope = scope.trim().to_ascii_lowercase();
131        if scope.is_empty() {
132            continue;
133        }
134        if scope != PUBLISH_SCOPE && scope != "publish-new" && scope != "publish-update" {
135            return Err(PrayError::Unsupported(format!(
136                "unsupported publish token scope: {scope}"
137            )));
138        }
139        if !normalized.iter().any(|existing| existing == &scope) {
140            normalized.push(scope);
141        }
142    }
143    if !normalized.iter().any(|scope| scope == PUBLISH_SCOPE) {
144        normalized.insert(0, PUBLISH_SCOPE.to_string());
145    }
146    Ok(normalized)
147}
148
149fn parse_scopes(scopes_text: &str) -> Vec<String> {
150    scopes_text
151        .split(',')
152        .map(str::trim)
153        .filter(|scope| !scope.is_empty())
154        .map(|scope| scope.to_ascii_lowercase())
155        .collect()
156}
157
158fn generate_publish_token(email: &str, scopes: &str, timestamp: u64) -> String {
159    let payload = format!(
160        "{email}\0publish-token\0{scopes}\0{timestamp}\0{}",
161        std::process::id()
162    );
163    sha256_prefixed(payload.as_bytes())
164}