use rustlavel_core::Result;
use rustlavel_db::{Database, Row, Schema, Value};
use crate::client::{Client, ClientStore};
use crate::code::{AuthorizationCode, CodeStore, Consumption};
use crate::consent::{ConsentStore, Grant};
use crate::store::StoreFuture;
use crate::token::{AccessToken, RefreshToken, TokenStore};
use rustlavel_oauth::{ChallengeMethod, Scopes};
pub async fn schema(schema: &Schema<'_>) -> Result<()> {
schema
.create("oauth_clients", |t| {
t.id();
t.string("client_id").unique();
t.string("secret_hash").nullable();
t.string("name");
t.text("redirect_uris");
t.text("scopes");
t.boolean("first_party").default_bool(false);
t.timestamps();
})
.await?;
schema
.create("oauth_codes", |t| {
t.id();
t.string("code_hash").unique();
t.string("client_id");
t.string("user_id");
t.text("scopes");
t.string("redirect_uri");
t.string("challenge");
t.string("challenge_method");
t.big_integer("issued_at");
t.big_integer("expires_at");
t.big_integer("consumed_at").nullable();
t.string("family").nullable();
})
.await?;
schema
.create("oauth_access_tokens", |t| {
t.id();
t.string("token_id").unique();
t.string("token_hash").unique();
t.string("client_id");
t.string("user_id").nullable();
t.text("scopes");
t.string("family");
t.big_integer("issued_at");
t.big_integer("expires_at");
t.boolean("revoked").default_bool(false);
})
.await?;
schema
.create("oauth_refresh_tokens", |t| {
t.id();
t.string("token_id").unique();
t.string("token_hash").unique();
t.string("client_id");
t.string("user_id").nullable();
t.text("scopes");
t.string("family");
t.string("access_token_id");
t.big_integer("issued_at");
t.big_integer("expires_at");
t.boolean("revoked").default_bool(false);
t.boolean("rotated").default_bool(false);
})
.await?;
schema
.create("oauth_consent", |t| {
t.id();
t.string("client_id");
t.string("user_id");
t.text("scopes");
t.big_integer("granted_at");
})
.await
}
pub async fn drop_schema(schema: &Schema<'_>) -> Result<()> {
schema.drop("oauth_consent").await?;
schema.drop("oauth_refresh_tokens").await?;
schema.drop("oauth_access_tokens").await?;
schema.drop("oauth_codes").await?;
schema.drop("oauth_clients").await
}
fn text(row: &Row, column: &str) -> String {
row.get::<String>(column).unwrap_or_default()
}
fn number(row: &Row, column: &str) -> u64 {
row.get::<i64>(column).unwrap_or_default().max(0) as u64
}
fn flag(row: &Row, column: &str) -> bool {
row.get::<bool>(column).unwrap_or(false)
}
fn optional(row: &Row, column: &str) -> Option<String> {
row.get::<String>(column).ok().filter(|value| !value.is_empty())
}
#[derive(Clone)]
pub struct DatabaseClientStore {
db: Database,
}
impl DatabaseClientStore {
pub fn new(db: Database) -> DatabaseClientStore {
DatabaseClientStore { db }
}
pub async fn create(&self, client: &Client, secret: Option<&str>) -> Result<()> {
let hash = match secret {
Some(secret) => Value::from(crate::store::digest(secret)),
None => Value::Null,
};
self.db
.table("oauth_clients")
.insert_without_id(
&self.db,
&[
("client_id", client.id.as_str().into()),
("secret_hash", hash),
("name", client.name.as_str().into()),
("redirect_uris", client.redirect_uris.join(" ").into()),
("scopes", client.scopes.to_string().into()),
("first_party", client.first_party.into()),
],
)
.await?;
Ok(())
}
}
impl ClientStore for DatabaseClientStore {
fn find<'a>(&'a self, id: &'a str) -> StoreFuture<'a, Option<Client>> {
Box::pin(async move {
let Some(row) =
self.db.table("oauth_clients").filter("client_id", id).first(&self.db).await?
else {
return Ok(None);
};
let secret_hash = text(&row, "secret_hash");
let mut client = match secret_hash.is_empty() {
true => Client::public(text(&row, "client_id")),
false => Client::from_secret_hash(text(&row, "client_id"), secret_hash),
};
client = client.named(text(&row, "name")).scopes(Scopes::parse(&text(&row, "scopes")));
for uri in text(&row, "redirect_uris").split_whitespace() {
client = client.redirect_uri(uri);
}
if flag(&row, "first_party") {
client = client.first_party();
}
Ok(Some(client))
})
}
}
#[derive(Clone)]
pub struct DatabaseCodeStore {
db: Database,
}
impl DatabaseCodeStore {
pub fn new(db: Database) -> DatabaseCodeStore {
DatabaseCodeStore { db }
}
fn read(row: &Row) -> AuthorizationCode {
let mut code = AuthorizationCode::from_hash(text(row, "code_hash"));
code.client_id = text(row, "client_id");
code.user_id = text(row, "user_id");
code.redirect_uri = text(row, "redirect_uri");
code.scopes = Scopes::parse(&text(row, "scopes"));
code.challenge = text(row, "challenge");
code.challenge_method = match text(row, "challenge_method").as_str() {
"S256" => ChallengeMethod::S256,
_ => ChallengeMethod::Plain,
};
code.issued_at = number(row, "issued_at");
code.expires_at = number(row, "expires_at");
code.family = optional(row, "family");
code
}
}
impl CodeStore for DatabaseCodeStore {
fn store<'a>(&'a self, code: AuthorizationCode) -> StoreFuture<'a, ()> {
Box::pin(async move {
self.db
.table("oauth_codes")
.insert_without_id(
&self.db,
&[
("code_hash", code.hash().into()),
("client_id", code.client_id.as_str().into()),
("user_id", code.user_id.as_str().into()),
("scopes", code.scopes.to_string().into()),
("redirect_uri", code.redirect_uri.as_str().into()),
("challenge", code.challenge.as_str().into()),
("challenge_method", code.challenge_method.as_str().into()),
("issued_at", (code.issued_at as i64).into()),
("expires_at", (code.expires_at as i64).into()),
],
)
.await?;
Ok(())
})
}
fn consume<'a>(
&'a self,
hash: &'a str,
family: &'a str,
now: u64,
) -> StoreFuture<'a, Consumption> {
Box::pin(async move {
let claimed = self
.db
.table("oauth_codes")
.filter("code_hash", hash)
.filter_null("consumed_at")
.update(
&self.db,
&[("consumed_at", (now as i64).into()), ("family", family.into())],
)
.await?;
let Some(row) =
self.db.table("oauth_codes").filter("code_hash", hash).first(&self.db).await?
else {
return Ok(Consumption::Unknown);
};
if claimed == 0 {
return Ok(Consumption::Replayed { family: optional(&row, "family") });
}
let code = Self::read(&row);
if code.expires_at <= now {
return Ok(Consumption::Expired);
}
Ok(Consumption::Fresh(Box::new(code)))
})
}
fn purge<'a>(&'a self, before: u64) -> StoreFuture<'a, usize> {
Box::pin(async move {
let removed = self
.db
.table("oauth_codes")
.filter_op("expires_at", "<", before as i64)
.delete(&self.db)
.await?;
Ok(removed as usize)
})
}
}
#[derive(Clone)]
pub struct DatabaseTokenStore {
db: Database,
}
impl DatabaseTokenStore {
pub fn new(db: Database) -> DatabaseTokenStore {
DatabaseTokenStore { db }
}
fn read_access(row: &Row) -> AccessToken {
let mut token = AccessToken::from_hash(text(row, "token_id"), text(row, "token_hash"));
token.client_id = text(row, "client_id");
token.user_id = optional(row, "user_id");
token.scopes = Scopes::parse(&text(row, "scopes"));
token.family = text(row, "family");
token.issued_at = number(row, "issued_at");
token.expires_at = number(row, "expires_at");
token.revoked = flag(row, "revoked");
token
}
fn read_refresh(row: &Row) -> RefreshToken {
let mut token = RefreshToken::from_hash(text(row, "token_id"), text(row, "token_hash"));
token.client_id = text(row, "client_id");
token.user_id = optional(row, "user_id");
token.scopes = Scopes::parse(&text(row, "scopes"));
token.family = text(row, "family");
token.access_token_id = text(row, "access_token_id");
token.issued_at = number(row, "issued_at");
token.expires_at = number(row, "expires_at");
token.revoked = flag(row, "revoked");
token.rotated = flag(row, "rotated");
token
}
}
impl TokenStore for DatabaseTokenStore {
fn store_access<'a>(&'a self, token: AccessToken) -> StoreFuture<'a, ()> {
Box::pin(async move {
self.db
.table("oauth_access_tokens")
.insert_without_id(
&self.db,
&[
("token_id", token.id.as_str().into()),
("token_hash", token.hash().into()),
("client_id", token.client_id.as_str().into()),
(
"user_id",
token.user_id.as_deref().map_or(Value::Null, Value::from),
),
("scopes", token.scopes.to_string().into()),
("family", token.family.as_str().into()),
("issued_at", (token.issued_at as i64).into()),
("expires_at", (token.expires_at as i64).into()),
("revoked", token.revoked.into()),
],
)
.await?;
Ok(())
})
}
fn store_refresh<'a>(&'a self, token: RefreshToken) -> StoreFuture<'a, ()> {
Box::pin(async move {
self.db
.table("oauth_refresh_tokens")
.insert_without_id(
&self.db,
&[
("token_id", token.id.as_str().into()),
("token_hash", token.hash().into()),
("client_id", token.client_id.as_str().into()),
(
"user_id",
token.user_id.as_deref().map_or(Value::Null, Value::from),
),
("scopes", token.scopes.to_string().into()),
("family", token.family.as_str().into()),
("access_token_id", token.access_token_id.as_str().into()),
("issued_at", (token.issued_at as i64).into()),
("expires_at", (token.expires_at as i64).into()),
("revoked", token.revoked.into()),
("rotated", token.rotated.into()),
],
)
.await?;
Ok(())
})
}
fn find_access<'a>(&'a self, hash: &'a str) -> StoreFuture<'a, Option<AccessToken>> {
Box::pin(async move {
let row = self
.db
.table("oauth_access_tokens")
.filter("token_hash", hash)
.first(&self.db)
.await?;
Ok(row.as_ref().map(Self::read_access))
})
}
fn find_refresh<'a>(&'a self, hash: &'a str) -> StoreFuture<'a, Option<RefreshToken>> {
Box::pin(async move {
let row = self
.db
.table("oauth_refresh_tokens")
.filter("token_hash", hash)
.first(&self.db)
.await?;
Ok(row.as_ref().map(Self::read_refresh))
})
}
fn rotate<'a>(&'a self, id: &'a str) -> StoreFuture<'a, bool> {
Box::pin(async move {
let rotated = self
.db
.table("oauth_refresh_tokens")
.filter("token_id", id)
.filter("rotated", false)
.update(&self.db, &[("rotated", true.into())])
.await?;
Ok(rotated == 1)
})
}
fn revoke_access<'a>(&'a self, id: &'a str) -> StoreFuture<'a, ()> {
Box::pin(async move {
self.db
.table("oauth_access_tokens")
.filter("token_id", id)
.update(&self.db, &[("revoked", true.into())])
.await?;
Ok(())
})
}
fn revoke_refresh<'a>(&'a self, id: &'a str) -> StoreFuture<'a, ()> {
Box::pin(async move {
self.db
.table("oauth_refresh_tokens")
.filter("token_id", id)
.update(&self.db, &[("revoked", true.into())])
.await?;
Ok(())
})
}
fn revoke_family<'a>(&'a self, family: &'a str) -> StoreFuture<'a, usize> {
Box::pin(async move {
let access = self
.db
.table("oauth_access_tokens")
.filter("family", family)
.filter("revoked", false)
.update(&self.db, &[("revoked", true.into())])
.await?;
let refresh = self
.db
.table("oauth_refresh_tokens")
.filter("family", family)
.filter("revoked", false)
.update(&self.db, &[("revoked", true.into())])
.await?;
Ok((access + refresh) as usize)
})
}
fn purge<'a>(&'a self, now: u64) -> StoreFuture<'a, usize> {
Box::pin(async move {
let access = self
.db
.table("oauth_access_tokens")
.filter_op("expires_at", "<", now as i64)
.delete(&self.db)
.await?;
let refresh = self
.db
.table("oauth_refresh_tokens")
.filter_op("expires_at", "<", now as i64)
.delete(&self.db)
.await?;
Ok((access + refresh) as usize)
})
}
}
#[derive(Clone)]
pub struct DatabaseConsentStore {
db: Database,
}
impl DatabaseConsentStore {
pub fn new(db: Database) -> DatabaseConsentStore {
DatabaseConsentStore { db }
}
}
impl ConsentStore for DatabaseConsentStore {
fn find<'a>(&'a self, client_id: &'a str, user_id: &'a str) -> StoreFuture<'a, Option<Grant>> {
Box::pin(async move {
let row = self
.db
.table("oauth_consent")
.filter("client_id", client_id)
.filter("user_id", user_id)
.first(&self.db)
.await?;
Ok(row.map(|row| Grant {
client_id: text(&row, "client_id"),
user_id: text(&row, "user_id"),
scopes: Scopes::parse(&text(&row, "scopes")),
granted_at: number(&row, "granted_at"),
}))
})
}
fn record<'a>(&'a self, grant: Grant) -> StoreFuture<'a, ()> {
Box::pin(async move {
self.db
.table("oauth_consent")
.filter("client_id", grant.client_id.as_str())
.filter("user_id", grant.user_id.as_str())
.delete(&self.db)
.await?;
self.db
.table("oauth_consent")
.insert_without_id(
&self.db,
&[
("client_id", grant.client_id.as_str().into()),
("user_id", grant.user_id.as_str().into()),
("scopes", grant.scopes.to_string().into()),
("granted_at", (grant.granted_at as i64).into()),
],
)
.await?;
Ok(())
})
}
fn forget<'a>(&'a self, client_id: &'a str, user_id: &'a str) -> StoreFuture<'a, ()> {
Box::pin(async move {
self.db
.table("oauth_consent")
.filter("client_id", client_id)
.filter("user_id", user_id)
.delete(&self.db)
.await?;
Ok(())
})
}
}