summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOwen Jacobson <owen@grimoire.ca>2025-08-24 17:03:16 -0400
committerOwen Jacobson <owen@grimoire.ca>2025-08-26 01:08:11 -0400
commit0bbc83f09cc7517dddf16770a15f9e90815f48ba (patch)
tree4b7ea51aab2e9255fb8832d3109b4bc8dc033f0c
parent218d6dbb56727721d19019c8514f5e4395596e98 (diff)
Generate tokens in memory and then store them.
This is the leading edge of a larger storage refactoring, where repo types stop doing things like generating secrets or deciding whether to carry out an operation. To make this work, there is now a `Token` type that holds the complete state of a token, in memory.
-rw-r--r--.sqlx/query-077f6c24043aa2316aeaae3bb514b7738aabfa740a19923d4c815bee860de1e8.json12
-rw-r--r--.sqlx/query-2cb4571882d54a4112d42e6678c9709a8637630faa759d404ebd4eb2545dab72.json38
-rw-r--r--.sqlx/query-3ab6df11f245d15f62461157469f1bff84ac343dc551c64e82e114b780e956a4.json12
-rw-r--r--.sqlx/query-7fb50c1ab5bafc69c43f6136d8c99e1ecfeab41fb7baba21a1a7fdc6d1134234.json26
-rw-r--r--.sqlx/query-8b474c8ed7859f745888644db639b7a4a21210ebf0b7fba97cd016ff6ab4d769.json20
-rw-r--r--.sqlx/query-b5305455d7a3bcd21d39d6e6eeff67a7bfb8402c09651f4034beb487c3d7d58f.json20
-rw-r--r--src/event/handlers/stream/mod.rs2
-rw-r--r--src/invite/app.rs6
-rw-r--r--src/setup/app.rs21
-rw-r--r--src/test/fixtures/identity.rs4
-rw-r--r--src/token/app.rs25
-rw-r--r--src/token/extract/identity.rs4
-rw-r--r--src/token/mod.rs32
-rw-r--r--src/token/repo/token.rs61
-rw-r--r--src/user/history.rs1
15 files changed, 158 insertions, 126 deletions
diff --git a/.sqlx/query-077f6c24043aa2316aeaae3bb514b7738aabfa740a19923d4c815bee860de1e8.json b/.sqlx/query-077f6c24043aa2316aeaae3bb514b7738aabfa740a19923d4c815bee860de1e8.json
new file mode 100644
index 0000000..37a6dd3
--- /dev/null
+++ b/.sqlx/query-077f6c24043aa2316aeaae3bb514b7738aabfa740a19923d4c815bee860de1e8.json
@@ -0,0 +1,12 @@
+{
+ "db_name": "SQLite",
+ "query": "\n insert\n into token (id, secret, login, issued_at, last_used_at)\n values ($1, $2, $3, $4, $5)\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Right": 5
+ },
+ "nullable": []
+ },
+ "hash": "077f6c24043aa2316aeaae3bb514b7738aabfa740a19923d4c815bee860de1e8"
+}
diff --git a/.sqlx/query-2cb4571882d54a4112d42e6678c9709a8637630faa759d404ebd4eb2545dab72.json b/.sqlx/query-2cb4571882d54a4112d42e6678c9709a8637630faa759d404ebd4eb2545dab72.json
new file mode 100644
index 0000000..99a05e3
--- /dev/null
+++ b/.sqlx/query-2cb4571882d54a4112d42e6678c9709a8637630faa759d404ebd4eb2545dab72.json
@@ -0,0 +1,38 @@
+{
+ "db_name": "SQLite",
+ "query": "\n update token\n set last_used_at = $1\n where secret = $2\n returning\n id as \"id: Id\",\n login as \"login: user::Id\",\n issued_at as \"issued_at: DateTime\",\n last_used_at as \"last_used_at: DateTime\"\n ",
+ "describe": {
+ "columns": [
+ {
+ "name": "id: Id",
+ "ordinal": 0,
+ "type_info": "Text"
+ },
+ {
+ "name": "login: user::Id",
+ "ordinal": 1,
+ "type_info": "Text"
+ },
+ {
+ "name": "issued_at: DateTime",
+ "ordinal": 2,
+ "type_info": "Text"
+ },
+ {
+ "name": "last_used_at: DateTime",
+ "ordinal": 3,
+ "type_info": "Text"
+ }
+ ],
+ "parameters": {
+ "Right": 2
+ },
+ "nullable": [
+ false,
+ false,
+ false,
+ false
+ ]
+ },
+ "hash": "2cb4571882d54a4112d42e6678c9709a8637630faa759d404ebd4eb2545dab72"
+}
diff --git a/.sqlx/query-3ab6df11f245d15f62461157469f1bff84ac343dc551c64e82e114b780e956a4.json b/.sqlx/query-3ab6df11f245d15f62461157469f1bff84ac343dc551c64e82e114b780e956a4.json
new file mode 100644
index 0000000..c9edfc9
--- /dev/null
+++ b/.sqlx/query-3ab6df11f245d15f62461157469f1bff84ac343dc551c64e82e114b780e956a4.json
@@ -0,0 +1,12 @@
+{
+ "db_name": "SQLite",
+ "query": "\n delete from token\n where id = $1\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Right": 1
+ },
+ "nullable": []
+ },
+ "hash": "3ab6df11f245d15f62461157469f1bff84ac343dc551c64e82e114b780e956a4"
+}
diff --git a/.sqlx/query-7fb50c1ab5bafc69c43f6136d8c99e1ecfeab41fb7baba21a1a7fdc6d1134234.json b/.sqlx/query-7fb50c1ab5bafc69c43f6136d8c99e1ecfeab41fb7baba21a1a7fdc6d1134234.json
deleted file mode 100644
index 1efb9a7..0000000
--- a/.sqlx/query-7fb50c1ab5bafc69c43f6136d8c99e1ecfeab41fb7baba21a1a7fdc6d1134234.json
+++ /dev/null
@@ -1,26 +0,0 @@
-{
- "db_name": "SQLite",
- "query": "\n update token\n set last_used_at = $1\n where secret = $2\n returning\n id as \"token: Id\",\n login as \"login: user::Id\"\n ",
- "describe": {
- "columns": [
- {
- "name": "token: Id",
- "ordinal": 0,
- "type_info": "Text"
- },
- {
- "name": "login: user::Id",
- "ordinal": 1,
- "type_info": "Text"
- }
- ],
- "parameters": {
- "Right": 2
- },
- "nullable": [
- false,
- false
- ]
- },
- "hash": "7fb50c1ab5bafc69c43f6136d8c99e1ecfeab41fb7baba21a1a7fdc6d1134234"
-}
diff --git a/.sqlx/query-8b474c8ed7859f745888644db639b7a4a21210ebf0b7fba97cd016ff6ab4d769.json b/.sqlx/query-8b474c8ed7859f745888644db639b7a4a21210ebf0b7fba97cd016ff6ab4d769.json
deleted file mode 100644
index b433e4c..0000000
--- a/.sqlx/query-8b474c8ed7859f745888644db639b7a4a21210ebf0b7fba97cd016ff6ab4d769.json
+++ /dev/null
@@ -1,20 +0,0 @@
-{
- "db_name": "SQLite",
- "query": "\n insert\n into token (id, secret, login, issued_at, last_used_at)\n values ($1, $2, $3, $4, $4)\n returning secret as \"secret!: Secret\"\n ",
- "describe": {
- "columns": [
- {
- "name": "secret!: Secret",
- "ordinal": 0,
- "type_info": "Text"
- }
- ],
- "parameters": {
- "Right": 4
- },
- "nullable": [
- false
- ]
- },
- "hash": "8b474c8ed7859f745888644db639b7a4a21210ebf0b7fba97cd016ff6ab4d769"
-}
diff --git a/.sqlx/query-b5305455d7a3bcd21d39d6e6eeff67a7bfb8402c09651f4034beb487c3d7d58f.json b/.sqlx/query-b5305455d7a3bcd21d39d6e6eeff67a7bfb8402c09651f4034beb487c3d7d58f.json
deleted file mode 100644
index 1be8e07..0000000
--- a/.sqlx/query-b5305455d7a3bcd21d39d6e6eeff67a7bfb8402c09651f4034beb487c3d7d58f.json
+++ /dev/null
@@ -1,20 +0,0 @@
-{
- "db_name": "SQLite",
- "query": "\n delete\n from token\n where id = $1\n returning id as \"id: Id\"\n ",
- "describe": {
- "columns": [
- {
- "name": "id: Id",
- "ordinal": 0,
- "type_info": "Text"
- }
- ],
- "parameters": {
- "Right": 1
- },
- "nullable": [
- false
- ]
- },
- "hash": "b5305455d7a3bcd21d39d6e6eeff67a7bfb8402c09651f4034beb487c3d7d58f"
-}
diff --git a/src/event/handlers/stream/mod.rs b/src/event/handlers/stream/mod.rs
index d0d3f08..63bfff3 100644
--- a/src/event/handlers/stream/mod.rs
+++ b/src/event/handlers/stream/mod.rs
@@ -27,7 +27,7 @@ pub async fn handler(
let resume_at = last_event_id.map_or(query.resume_point, LastEventId::into_inner);
let stream = app.events().subscribe(resume_at).await?;
- let stream = app.tokens().limit_stream(identity.token, stream).await?;
+ let stream = app.tokens().limit_stream(&identity.token, stream).await?;
Ok(Response(stream))
}
diff --git a/src/invite/app.rs b/src/invite/app.rs
index 1c85562..6e235b2 100644
--- a/src/invite/app.rs
+++ b/src/invite/app.rs
@@ -8,7 +8,7 @@ use crate::{
event::Broadcaster,
name::Name,
password::Password,
- token::{Secret, repo::Provider as _},
+ token::{Secret, Token, repo::Provider as _},
user::{
User,
create::{self, Create},
@@ -71,7 +71,9 @@ impl<'a> Invites<'a> {
.store(&mut tx)
.await
.duplicate(|| AcceptError::DuplicateLogin(name.clone()))?;
- let secret = tx.tokens().issue(stored.user(), accepted_at).await?;
+ let user = stored.user().as_created();
+ let (token, secret) = Token::generate(&user, accepted_at);
+ tx.tokens().create(&token, &secret).await?;
tx.commit().await?;
stored.publish(self.events);
diff --git a/src/setup/app.rs b/src/setup/app.rs
index 1856519..c1c53c5 100644
--- a/src/setup/app.rs
+++ b/src/setup/app.rs
@@ -6,7 +6,7 @@ use crate::{
event::Broadcaster,
name::Name,
password::Password,
- token::{Secret, repo::Provider as _},
+ token::{Secret, Token, repo::Provider as _},
user::create::{self, Create},
};
@@ -31,17 +31,20 @@ impl<'a> Setup<'a> {
let validated = create.validate()?;
let mut tx = self.db.begin().await?;
- let stored = if tx.setup().completed().await? {
- Err(Error::SetupCompleted)?
+ if tx.setup().completed().await? {
+ Err(Error::SetupCompleted)
} else {
- validated.store(&mut tx).await?
- };
- let secret = tx.tokens().issue(stored.user(), created_at).await?;
- tx.commit().await?;
+ let stored = validated.store(&mut tx).await?;
+ let user = stored.user().as_created();
+
+ let (token, secret) = Token::generate(&user, created_at);
+ tx.tokens().create(&token, &secret).await?;
+ tx.commit().await?;
- stored.publish(self.events);
+ stored.publish(self.events);
- Ok(secret)
+ Ok(secret)
+ }
}
pub async fn completed(&self) -> Result<bool, sqlx::Error> {
diff --git a/src/test/fixtures/identity.rs b/src/test/fixtures/identity.rs
index 29ff5ae..ac353de 100644
--- a/src/test/fixtures/identity.rs
+++ b/src/test/fixtures/identity.rs
@@ -5,7 +5,7 @@ use crate::{
password::Password,
test::fixtures,
token::{
- self,
+ Token,
extract::{Identity, IdentityCookie},
},
};
@@ -37,8 +37,8 @@ pub async fn logged_in(
}
pub fn fictitious() -> Identity {
- let token = token::Id::generate();
let user = fixtures::user::fictitious();
+ let (token, _) = Token::generate(&user, &fixtures::now());
Identity { token, user }
}
diff --git a/src/token/app.rs b/src/token/app.rs
index 8ec61c5..a7a843d 100644
--- a/src/token/app.rs
+++ b/src/token/app.rs
@@ -6,7 +6,7 @@ use futures::{
use sqlx::sqlite::SqlitePool;
use super::{
- Broadcaster, Event as TokenEvent, Id, Secret,
+ Broadcaster, Event as TokenEvent, Secret, Token,
extract::Identity,
repo::{self, Provider as _, auth::Provider as _},
};
@@ -48,12 +48,14 @@ impl<'a> Tokens<'a> {
// if the account is deleted during that time.
tx.commit().await?;
- user.as_snapshot().ok_or(LoginError::Rejected)?;
+ let user = user.as_snapshot().ok_or(LoginError::Rejected)?;
if stored_hash.verify(password)? {
let mut tx = self.db.begin().await?;
- let secret = tx.tokens().issue(&user, login_at).await?;
+ let (token, secret) = Token::generate(&user, login_at);
+ tx.tokens().create(&token, &secret).await?;
tx.commit().await?;
+
Ok(secret)
} else {
Err(LoginError::Rejected)
@@ -85,13 +87,16 @@ impl<'a> Tokens<'a> {
return Err(LoginError::Rejected);
}
- user.as_snapshot().ok_or(LoginError::Rejected)?;
+ let user_snapshot = user.as_snapshot().ok_or(LoginError::Rejected)?;
let to_hash = to.hash()?;
let mut tx = self.db.begin().await?;
- let tokens = tx.tokens().revoke_all(&user).await?;
tx.users().set_password(&user, &to_hash).await?;
- let secret = tx.tokens().issue(&user, changed_at).await?;
+
+ let tokens = tx.tokens().revoke_all(&user).await?;
+ let (token, secret) = Token::generate(&user_snapshot, changed_at);
+ tx.tokens().create(&token, &secret).await?;
+
tx.commit().await?;
for event in tokens.into_iter().map(TokenEvent::Revoked) {
@@ -121,13 +126,15 @@ impl<'a> Tokens<'a> {
pub async fn limit_stream<S, E>(
&self,
- token: Id,
+ token: &Token,
events: S,
) -> Result<impl Stream<Item = E> + std::fmt::Debug + use<S, E>, ValidateError>
where
S: Stream<Item = E> + std::fmt::Debug,
E: std::fmt::Debug,
{
+ let token = token.id.clone();
+
// Subscribe, first.
let token_events = self.token_events.subscribe();
@@ -188,13 +195,13 @@ impl<'a> Tokens<'a> {
Ok(())
}
- pub async fn logout(&self, token: &Id) -> Result<(), ValidateError> {
+ pub async fn logout(&self, token: &Token) -> Result<(), ValidateError> {
let mut tx = self.db.begin().await?;
tx.tokens().revoke(token).await?;
tx.commit().await?;
self.token_events
- .broadcast(TokenEvent::Revoked(token.clone()));
+ .broadcast(TokenEvent::Revoked(token.id.clone()));
Ok(())
}
diff --git a/src/token/extract/identity.rs b/src/token/extract/identity.rs
index 4d076d7..d01ab53 100644
--- a/src/token/extract/identity.rs
+++ b/src/token/extract/identity.rs
@@ -10,13 +10,13 @@ use crate::{
app::App,
clock::RequestedAt,
error::{Internal, Unauthorized},
- token::{self, app::ValidateError},
+ token::{Token, app::ValidateError},
user::User,
};
#[derive(Clone, Debug)]
pub struct Identity {
- pub token: token::Id,
+ pub token: Token,
pub user: User,
}
diff --git a/src/token/mod.rs b/src/token/mod.rs
index 33403ef..58ff08b 100644
--- a/src/token/mod.rs
+++ b/src/token/mod.rs
@@ -6,4 +6,36 @@ mod id;
pub mod repo;
mod secret;
+use uuid::Uuid;
+
+use crate::{
+ clock::DateTime,
+ user::{self, User},
+};
+
pub use self::{broadcaster::Broadcaster, event::Event, id::Id, secret::Secret};
+
+#[derive(Clone, Debug)]
+pub struct Token {
+ pub id: Id,
+ pub user: user::Id,
+ pub issued_at: DateTime,
+ pub last_used_at: DateTime,
+}
+
+impl Token {
+ pub fn generate(user: &User, issued_at: &DateTime) -> (Self, Secret) {
+ let id = Id::generate();
+ let secret = Uuid::new_v4().to_string().into();
+
+ (
+ Self {
+ id,
+ user: user.id.clone(),
+ issued_at: *issued_at,
+ last_used_at: *issued_at,
+ },
+ secret,
+ )
+ }
+}
diff --git a/src/token/repo/token.rs b/src/token/repo/token.rs
index 5368fee..afcde53 100644
--- a/src/token/repo/token.rs
+++ b/src/token/repo/token.rs
@@ -1,12 +1,11 @@
use sqlx::{SqliteConnection, Transaction, sqlite::Sqlite};
-use uuid::Uuid;
use crate::{
clock::DateTime,
db::NotFound,
event::{Instant, Sequence},
name::{self, Name},
- token::{Id, Secret},
+ token::{Id, Secret, Token},
user::{self, History, User},
};
@@ -23,33 +22,23 @@ impl Provider for Transaction<'_, Sqlite> {
pub struct Tokens<'t>(&'t mut SqliteConnection);
impl Tokens<'_> {
- // Issue a new token for an existing user. The issued_at timestamp will
- // determine the token's initial expiry deadline.
- pub async fn issue(
- &mut self,
- user: &History,
- issued_at: &DateTime,
- ) -> Result<Secret, sqlx::Error> {
- let id = Id::generate();
- let secret = Uuid::new_v4().to_string();
- let user = user.id();
-
- let secret = sqlx::query_scalar!(
+ pub async fn create(&mut self, token: &Token, secret: &Secret) -> Result<(), sqlx::Error> {
+ sqlx::query!(
r#"
insert
into token (id, secret, login, issued_at, last_used_at)
- values ($1, $2, $3, $4, $4)
- returning secret as "secret!: Secret"
+ values ($1, $2, $3, $4, $5)
"#,
- id,
+ token.id,
secret,
- user,
- issued_at,
+ token.user,
+ token.issued_at,
+ token.last_used_at,
)
.fetch_one(&mut *self.0)
.await?;
- Ok(secret)
+ Ok(())
}
pub async fn require(&mut self, token: &Id) -> Result<(), sqlx::Error> {
@@ -67,18 +56,15 @@ impl Tokens<'_> {
Ok(())
}
- // Revoke a token by its secret.
- pub async fn revoke(&mut self, token: &Id) -> Result<(), sqlx::Error> {
- sqlx::query_scalar!(
+ pub async fn revoke(&mut self, token: &Token) -> Result<(), sqlx::Error> {
+ sqlx::query!(
r#"
- delete
- from token
+ delete from token
where id = $1
- returning id as "id: Id"
"#,
- token,
+ token.id,
)
- .fetch_one(&mut *self.0)
+ .execute(&mut *self.0)
.await?;
Ok(())
@@ -127,24 +113,31 @@ impl Tokens<'_> {
&mut self,
secret: &Secret,
used_at: &DateTime,
- ) -> Result<(Id, History), LoadError> {
+ ) -> Result<(Token, History), LoadError> {
// I would use `update … returning` to do this in one query, but
// sqlite3, as of this writing, does not allow an update's `returning`
// clause to reference columns from tables joined into the update. Two
// queries is fine, but it feels untidy.
- let (token, user) = sqlx::query!(
+ let token = sqlx::query!(
r#"
update token
set last_used_at = $1
where secret = $2
returning
- id as "token: Id",
- login as "login: user::Id"
+ id as "id: Id",
+ login as "login: user::Id",
+ issued_at as "issued_at: DateTime",
+ last_used_at as "last_used_at: DateTime"
"#,
used_at,
secret,
)
- .map(|row| (row.token, row.login))
+ .map(|row| Token {
+ id: row.id,
+ user: row.login,
+ issued_at: row.issued_at,
+ last_used_at: row.last_used_at,
+ })
.fetch_one(&mut *self.0)
.await?;
@@ -160,7 +153,7 @@ impl Tokens<'_> {
join login using (id)
where id = $1
"#,
- user,
+ token.user,
)
.map(|row| {
Ok::<_, name::Error>(History {
diff --git a/src/user/history.rs b/src/user/history.rs
index 4f99130..72e0aee 100644
--- a/src/user/history.rs
+++ b/src/user/history.rs
@@ -20,7 +20,6 @@ impl History {
// if this returns a redacted or modified version of the user. If we implement
// renames by redacting the original name, then this should return the edited
// user, not the original, even if that's not how it was "as created.")
- #[cfg(test)]
pub fn as_created(&self) -> User {
self.user.clone()
}