[WIP] feat: scoped api keys

This commit is contained in:
Iris System 2025-08-17 02:47:01 -07:00
parent e7ee593a85
commit 06cb160f95
45 changed files with 1264 additions and 154 deletions

View file

@ -4,8 +4,10 @@ version = "0.1.0"
edition = "2021"
[dependencies]
base64 = { workspace = true }
chrono = { workspace = true, features = ["serde"] }
pk_macros = { path = "../macros" }
jsonwebtoken = { workspace = true }
sea-query = "0.32.1"
serde = { workspace = true }
serde_json = { workspace = true, features = ["preserve_order"] }

View file

@ -6,9 +6,9 @@
// note: caller needs to implement From<i32> for their type
macro_rules! fake_enum_impls {
($n:ident) => {
impl Type<Postgres> for $n {
fn type_info() -> PgTypeInfo {
PgTypeInfo::with_name("INT4")
impl ::sqlx::Type<::sqlx::Postgres> for $n {
fn type_info() -> ::sqlx::postgres::PgTypeInfo {
::sqlx::postgres::PgTypeInfo::with_name("INT4")
}
}
@ -18,14 +18,14 @@ macro_rules! fake_enum_impls {
}
}
impl<'r, DB: Database> Decode<'r, DB> for $n
impl<'r, DB: ::sqlx::Database> ::sqlx::Decode<'r, DB> for $n
where
i32: Decode<'r, DB>,
i32: ::sqlx::Decode<'r, DB>,
{
fn decode(
value: <DB as Database>::ValueRef<'r>,
) -> Result<Self, Box<dyn Error + 'static + Send + Sync>> {
let value = <i32 as Decode<DB>>::decode(value)?;
value: <DB as ::sqlx::Database>::ValueRef<'r>,
) -> Result<Self, Box<dyn ::std::error::Error + 'static + Send + Sync>> {
let value = <i32 as ::sqlx::Decode<DB>>::decode(value)?;
Ok(Self::from(value))
}
}

View file

@ -0,0 +1,104 @@
use pk_macros::pk_model;
use chrono::{DateTime, Utc, NaiveDateTime};
use uuid::Uuid;
use base64::{prelude::BASE64_STANDARD, Engine};
use jsonwebtoken::{
crypto::{sign, verify},
DecodingKey, EncodingKey,
};
use crate::SystemId;
#[derive(sqlx::Type, Debug, Clone, PartialEq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
#[sqlx(rename_all = "snake_case")]
#[sqlx(type_name = "api_key_type")]
pub enum ApiKeyType {
Dashboard,
UserCreated,
ExternalApp,
}
#[pk_model]
struct ApiKey {
#[json = "id"]
id: Uuid,
system: SystemId,
#[json = "type"]
kind: ApiKeyType,
#[json = "scopes"]
scopes: Vec<String>,
#[json = "app"]
app: Option<Uuid>,
#[json = "name"]
#[patchable]
name: Option<String>,
#[json = "discord_id"]
discord_id: Option<i64>,
#[private_patchable]
discord_access_token: Option<String>,
#[private_patchable]
discord_refresh_token: Option<String>,
#[private_patchable]
discord_expires_at: Option<NaiveDateTime>,
#[json = "created"]
created: DateTime<Utc>,
}
const SIGNATURE_ALGORITHM: jsonwebtoken::Algorithm = jsonwebtoken::Algorithm::ES256;
impl PKApiKey {
pub fn to_header_str(self, system_uuid: Uuid, key: &EncodingKey) -> String {
let b64 = BASE64_STANDARD.encode(
serde_json::to_vec(&serde_json::json!({
"tid": self.id.to_string(),
"sid": system_uuid.to_string(),
"type": self.kind,
"scopes": self.scopes,
}))
.expect("should not fail"),
);
let signature = sign(b64.as_bytes(), key, SIGNATURE_ALGORITHM).expect("should not fail");
format!("pkapi:{b64}:{signature}")
}
/// Parse a header string into a token uuid
pub fn parse_header_str(token: String, key: &DecodingKey) -> Option<Uuid> {
let mut parts = token.split(":");
let pkapi = parts.next();
if pkapi.is_none_or(|v| v != "pkapi") {
return None;
}
let Some(jsonblob) = parts.next() else {
return None;
};
let Some(sig) = parts.next() else {
return None;
};
// verify signature before doing anything else
let valid = verify(sig, jsonblob.as_bytes(), key, SIGNATURE_ALGORITHM);
if valid.is_err() || matches!(valid, Ok(false)) {
return None;
}
let Ok(bytes) = BASE64_STANDARD.decode(jsonblob) else {
return None;
};
let Ok(obj) = serde_json::from_slice::<serde_json::Value>(bytes.as_slice()) else {
return None;
};
obj.get("tid")
.map(|v| v.as_str().map(|f| Uuid::parse_str(f).ok()))
.flatten()
.flatten()
}
}

View file

@ -1,14 +1,5 @@
mod _util;
macro_rules! model {
($n:ident) => {
mod $n;
pub use $n::*;
};
}
model!(system);
model!(system_config);
use _util::fake_enum_impls;
#[derive(serde::Serialize, Debug, Clone)]
#[serde(rename_all = "snake_case")]
@ -17,10 +8,7 @@ pub enum PrivacyLevel {
Private,
}
// this sucks, put it somewhere else
use sqlx::{postgres::PgTypeInfo, Database, Decode, Postgres, Type};
use std::error::Error;
_util::fake_enum_impls!(PrivacyLevel);
fake_enum_impls!(PrivacyLevel);
impl From<i32> for PrivacyLevel {
fn from(value: i32) -> Self {
@ -31,3 +19,24 @@ impl From<i32> for PrivacyLevel {
}
}
}
impl PrivacyLevel {
pub fn to_string(&self) -> String {
match self {
PrivacyLevel::Public => "public".into(),
PrivacyLevel::Private => "private".into(),
}
}
}
macro_rules! model {
($n:ident) => {
mod $n;
pub use $n::*;
};
}
model!(api_key);
model!(oauth2_app);
model!(system);
model!(system_config);

View file

@ -0,0 +1,28 @@
use pk_macros::pk_model;
use uuid::Uuid;
#[pk_model]
struct ExternalApp {
#[json = "id"]
id: Uuid,
#[json = "name"]
#[patchable]
name: String,
#[json = "homepage_url"]
#[patchable]
homepage_url: String,
#[private_patchable]
oauth2_secret: Option<String>,
#[json = "oauth2_allowed_redirects"]
#[patchable]
oauth2_allowed_redirects: Vec<String>,
#[json = "oauth2_scopes"]
#[patchable]
oauth2_scopes: Vec<String>,
#[private_patchable]
api_rl_token: Option<String>,
#[private_patchable]
api_rl_rate: Option<i32>,
}

View file

@ -1,10 +1,9 @@
use pk_macros::pk_model;
use crate::PrivacyLevel;
use chrono::NaiveDateTime;
use uuid::Uuid;
use crate::PrivacyLevel;
// todo: fix this
pub type SystemId = i32;