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