pray_core/
auth_store_tokens.rs1use 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}