pub const MODULE_NAME: &str = "auth";
pub const MIGRATION_VERSION: i32 = 5;
pub const PG_DDL_V1: &str = r#"
CREATE SCHEMA IF NOT EXISTS auth;
CREATE TABLE IF NOT EXISTS auth.users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE,
email_verified BOOLEAN NOT NULL DEFAULT FALSE,
display_name TEXT,
password_hash TEXT,
created_at DOUBLE PRECISION NOT NULL
);
CREATE TABLE IF NOT EXISTS auth.user_upstream (
provider TEXT NOT NULL,
subject TEXT NOT NULL,
user_id TEXT NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
PRIMARY KEY (provider, subject)
);
CREATE INDEX IF NOT EXISTS idx_auth_user_upstream_user
ON auth.user_upstream (user_id);
CREATE TABLE IF NOT EXISTS auth.passkeys (
credential_id BYTEA PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
public_key BYTEA NOT NULL,
sign_count INTEGER NOT NULL DEFAULT 0,
transports TEXT NOT NULL,
created_at DOUBLE PRECISION NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_auth_passkeys_user
ON auth.passkeys (user_id);
CREATE TABLE IF NOT EXISTS auth.sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
csrf_token TEXT NOT NULL,
created_at DOUBLE PRECISION NOT NULL,
expires_at DOUBLE PRECISION NOT NULL,
ip_hash TEXT,
user_agent_hash TEXT
);
CREATE INDEX IF NOT EXISTS idx_auth_sessions_user
ON auth.sessions (user_id);
CREATE INDEX IF NOT EXISTS idx_auth_sessions_expires
ON auth.sessions (expires_at);
CREATE TABLE IF NOT EXISTS auth.jwks_keys (
kid TEXT PRIMARY KEY,
alg TEXT NOT NULL,
public_jwk JSONB NOT NULL,
private_pem_encrypted BYTEA,
created_at DOUBLE PRECISION NOT NULL,
rotated_at DOUBLE PRECISION,
expires_at DOUBLE PRECISION
);
CREATE INDEX IF NOT EXISTS idx_auth_jwks_keys_active
ON auth.jwks_keys (rotated_at) WHERE rotated_at IS NULL;
"#;
pub const PG_DDL_V2: &str = r#"
CREATE TABLE IF NOT EXISTS auth.biscuit_root_keys (
kid TEXT PRIMARY KEY,
private_pem BYTEA NOT NULL,
public_pem TEXT NOT NULL,
created_at DOUBLE PRECISION NOT NULL,
rotated_at DOUBLE PRECISION
);
CREATE INDEX IF NOT EXISTS idx_auth_biscuit_root_keys_active
ON auth.biscuit_root_keys (rotated_at) WHERE rotated_at IS NULL;
"#;
pub const PG_DDL_V3: &str = r#"
CREATE TABLE IF NOT EXISTS auth.zanzibar_namespaces (
name TEXT PRIMARY KEY,
schema_json JSONB NOT NULL,
updated_at DOUBLE PRECISION NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())
);
CREATE TABLE IF NOT EXISTS auth.zanzibar_tuples (
object_type TEXT NOT NULL,
object_id TEXT NOT NULL,
relation TEXT NOT NULL,
subject_type TEXT NOT NULL,
subject_id TEXT NOT NULL,
subject_rel TEXT NOT NULL DEFAULT '',
created_at DOUBLE PRECISION NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW()),
PRIMARY KEY (object_type, object_id, relation, subject_type, subject_id, subject_rel)
);
CREATE INDEX IF NOT EXISTS idx_auth_zanzibar_tuples_rev
ON auth.zanzibar_tuples (subject_type, subject_id, relation);
"#;
pub const PG_DDL_V4: &str = r#"
CREATE TABLE IF NOT EXISTS auth.oidc_clients (
client_id TEXT PRIMARY KEY,
client_secret_hash TEXT,
redirect_uris TEXT NOT NULL,
name TEXT NOT NULL,
logo_url TEXT,
token_endpoint_auth_method TEXT NOT NULL,
default_scopes TEXT NOT NULL,
require_consent BOOLEAN NOT NULL DEFAULT TRUE,
grant_types TEXT NOT NULL DEFAULT '["authorization_code","refresh_token"]',
response_types TEXT NOT NULL DEFAULT '["code"]',
pkce_required BOOLEAN NOT NULL DEFAULT TRUE,
backchannel_logout_uri TEXT,
created_at DOUBLE PRECISION NOT NULL
);
CREATE TABLE IF NOT EXISTS auth.upstream_providers (
slug TEXT PRIMARY KEY,
issuer TEXT NOT NULL,
client_id TEXT NOT NULL,
client_secret TEXT NOT NULL,
display_name TEXT NOT NULL,
icon_url TEXT,
enabled BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE TABLE IF NOT EXISTS auth.oidc_authorization_codes (
code TEXT PRIMARY KEY,
client_id TEXT NOT NULL,
user_id TEXT NOT NULL,
redirect_uri TEXT NOT NULL,
scopes TEXT NOT NULL,
code_challenge TEXT NOT NULL,
code_challenge_method TEXT NOT NULL,
nonce TEXT,
state TEXT,
issued_at DOUBLE PRECISION NOT NULL,
expires_at DOUBLE PRECISION NOT NULL,
consumed BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS auth.oidc_refresh_tokens (
token_hash TEXT PRIMARY KEY,
client_id TEXT NOT NULL,
user_id TEXT NOT NULL,
scopes TEXT NOT NULL,
issued_at DOUBLE PRECISION NOT NULL,
expires_at DOUBLE PRECISION NOT NULL,
revoked BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE INDEX IF NOT EXISTS idx_auth_oidc_refresh_user
ON auth.oidc_refresh_tokens (user_id);
CREATE TABLE IF NOT EXISTS auth.oidc_sessions (
sid TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
client_id TEXT NOT NULL,
assay_session_id TEXT,
issued_at DOUBLE PRECISION NOT NULL,
backchannel_logout_uri TEXT
);
CREATE INDEX IF NOT EXISTS idx_auth_oidc_sessions_user
ON auth.oidc_sessions (user_id);
CREATE INDEX IF NOT EXISTS idx_auth_oidc_sessions_assay
ON auth.oidc_sessions (assay_session_id);
CREATE TABLE IF NOT EXISTS auth.oidc_consents (
user_id TEXT NOT NULL,
client_id TEXT NOT NULL,
scopes TEXT NOT NULL,
granted_at DOUBLE PRECISION NOT NULL,
PRIMARY KEY (user_id, client_id)
);
CREATE TABLE IF NOT EXISTS auth.oidc_upstream_states (
state TEXT PRIMARY KEY,
provider_slug TEXT NOT NULL,
nonce TEXT NOT NULL,
pkce_verifier TEXT NOT NULL,
return_to TEXT,
created_at DOUBLE PRECISION NOT NULL,
expires_at DOUBLE PRECISION NOT NULL
);
"#;
pub const PG_DDL_V5: &str = r#"
ALTER TABLE auth.upstream_providers
ADD COLUMN IF NOT EXISTS scopes TEXT NOT NULL DEFAULT '["openid","email","profile"]';
ALTER TABLE auth.upstream_providers
ADD COLUMN IF NOT EXISTS auth_params TEXT NOT NULL DEFAULT '{}';
ALTER TABLE auth.oidc_upstream_states
ADD COLUMN IF NOT EXISTS binding_hash TEXT NOT NULL DEFAULT '';
"#;
pub const SQLITE_DDL_V1: &[(&str, &str)] = &[
(
"users",
"CREATE TABLE IF NOT EXISTS auth.users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE,
email_verified INTEGER NOT NULL DEFAULT 0,
display_name TEXT,
password_hash TEXT,
created_at REAL NOT NULL
)",
),
(
"user_upstream",
"CREATE TABLE IF NOT EXISTS auth.user_upstream (
provider TEXT NOT NULL,
subject TEXT NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY (provider, subject)
)",
),
(
"idx_user_upstream_user",
"CREATE INDEX IF NOT EXISTS auth.idx_auth_user_upstream_user \
ON user_upstream (user_id)",
),
(
"passkeys",
"CREATE TABLE IF NOT EXISTS auth.passkeys (
credential_id BLOB PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
public_key BLOB NOT NULL,
sign_count INTEGER NOT NULL DEFAULT 0,
transports TEXT NOT NULL,
created_at REAL NOT NULL
)",
),
(
"idx_passkeys_user",
"CREATE INDEX IF NOT EXISTS auth.idx_auth_passkeys_user ON passkeys (user_id)",
),
(
"sessions",
"CREATE TABLE IF NOT EXISTS auth.sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
csrf_token TEXT NOT NULL,
created_at REAL NOT NULL,
expires_at REAL NOT NULL,
ip_hash TEXT,
user_agent_hash TEXT
)",
),
(
"idx_sessions_user",
"CREATE INDEX IF NOT EXISTS auth.idx_auth_sessions_user ON sessions (user_id)",
),
(
"idx_sessions_expires",
"CREATE INDEX IF NOT EXISTS auth.idx_auth_sessions_expires ON sessions (expires_at)",
),
(
"jwks_keys",
"CREATE TABLE IF NOT EXISTS auth.jwks_keys (
kid TEXT PRIMARY KEY,
alg TEXT NOT NULL,
public_jwk TEXT NOT NULL,
private_pem_encrypted BLOB,
created_at REAL NOT NULL,
rotated_at REAL,
expires_at REAL
)",
),
(
"idx_jwks_keys_active",
"CREATE INDEX IF NOT EXISTS auth.idx_auth_jwks_keys_active \
ON jwks_keys (rotated_at) WHERE rotated_at IS NULL",
),
];
pub const SQLITE_DDL_V2: &[(&str, &str)] = &[
(
"biscuit_root_keys",
"CREATE TABLE IF NOT EXISTS auth.biscuit_root_keys (
kid TEXT PRIMARY KEY,
private_pem BLOB NOT NULL,
public_pem TEXT NOT NULL,
created_at REAL NOT NULL,
rotated_at REAL
)",
),
(
"idx_biscuit_root_keys_active",
"CREATE INDEX IF NOT EXISTS auth.idx_auth_biscuit_root_keys_active \
ON biscuit_root_keys (rotated_at) WHERE rotated_at IS NULL",
),
];
pub const SQLITE_DDL_V3: &[(&str, &str)] = &[
(
"zanzibar_namespaces",
"CREATE TABLE IF NOT EXISTS auth.zanzibar_namespaces (
name TEXT PRIMARY KEY,
schema_json TEXT NOT NULL,
updated_at REAL NOT NULL
)",
),
(
"zanzibar_tuples",
"CREATE TABLE IF NOT EXISTS auth.zanzibar_tuples (
object_type TEXT NOT NULL,
object_id TEXT NOT NULL,
relation TEXT NOT NULL,
subject_type TEXT NOT NULL,
subject_id TEXT NOT NULL,
subject_rel TEXT NOT NULL DEFAULT '',
created_at REAL NOT NULL,
PRIMARY KEY (object_type, object_id, relation, subject_type, subject_id, subject_rel)
)",
),
(
"idx_zanzibar_tuples_rev",
"CREATE INDEX IF NOT EXISTS auth.idx_auth_zanzibar_tuples_rev \
ON zanzibar_tuples (subject_type, subject_id, relation)",
),
];
pub const SQLITE_DDL_V4: &[(&str, &str)] = &[
(
"oidc_clients",
"CREATE TABLE IF NOT EXISTS auth.oidc_clients (
client_id TEXT PRIMARY KEY,
client_secret_hash TEXT,
redirect_uris TEXT NOT NULL,
name TEXT NOT NULL,
logo_url TEXT,
token_endpoint_auth_method TEXT NOT NULL,
default_scopes TEXT NOT NULL,
require_consent INTEGER NOT NULL DEFAULT 1,
grant_types TEXT NOT NULL DEFAULT '[\"authorization_code\",\"refresh_token\"]',
response_types TEXT NOT NULL DEFAULT '[\"code\"]',
pkce_required INTEGER NOT NULL DEFAULT 1,
backchannel_logout_uri TEXT,
created_at REAL NOT NULL
)",
),
(
"upstream_providers",
"CREATE TABLE IF NOT EXISTS auth.upstream_providers (
slug TEXT PRIMARY KEY,
issuer TEXT NOT NULL,
client_id TEXT NOT NULL,
client_secret TEXT NOT NULL,
display_name TEXT NOT NULL,
icon_url TEXT,
enabled INTEGER NOT NULL DEFAULT 1
)",
),
(
"oidc_authorization_codes",
"CREATE TABLE IF NOT EXISTS auth.oidc_authorization_codes (
code TEXT PRIMARY KEY,
client_id TEXT NOT NULL,
user_id TEXT NOT NULL,
redirect_uri TEXT NOT NULL,
scopes TEXT NOT NULL,
code_challenge TEXT NOT NULL,
code_challenge_method TEXT NOT NULL,
nonce TEXT,
state TEXT,
issued_at REAL NOT NULL,
expires_at REAL NOT NULL,
consumed INTEGER NOT NULL DEFAULT 0
)",
),
(
"oidc_refresh_tokens",
"CREATE TABLE IF NOT EXISTS auth.oidc_refresh_tokens (
token_hash TEXT PRIMARY KEY,
client_id TEXT NOT NULL,
user_id TEXT NOT NULL,
scopes TEXT NOT NULL,
issued_at REAL NOT NULL,
expires_at REAL NOT NULL,
revoked INTEGER NOT NULL DEFAULT 0
)",
),
(
"idx_oidc_refresh_user",
"CREATE INDEX IF NOT EXISTS auth.idx_auth_oidc_refresh_user \
ON oidc_refresh_tokens (user_id)",
),
(
"oidc_sessions",
"CREATE TABLE IF NOT EXISTS auth.oidc_sessions (
sid TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
client_id TEXT NOT NULL,
assay_session_id TEXT,
issued_at REAL NOT NULL,
backchannel_logout_uri TEXT
)",
),
(
"idx_oidc_sessions_user",
"CREATE INDEX IF NOT EXISTS auth.idx_auth_oidc_sessions_user \
ON oidc_sessions (user_id)",
),
(
"idx_oidc_sessions_assay",
"CREATE INDEX IF NOT EXISTS auth.idx_auth_oidc_sessions_assay \
ON oidc_sessions (assay_session_id)",
),
(
"oidc_consents",
"CREATE TABLE IF NOT EXISTS auth.oidc_consents (
user_id TEXT NOT NULL,
client_id TEXT NOT NULL,
scopes TEXT NOT NULL,
granted_at REAL NOT NULL,
PRIMARY KEY (user_id, client_id)
)",
),
(
"oidc_upstream_states",
"CREATE TABLE IF NOT EXISTS auth.oidc_upstream_states (
state TEXT PRIMARY KEY,
provider_slug TEXT NOT NULL,
nonce TEXT NOT NULL,
pkce_verifier TEXT NOT NULL,
return_to TEXT,
created_at REAL NOT NULL,
expires_at REAL NOT NULL
)",
),
];
pub const SQLITE_DDL_V5: &[(&str, &str)] = &[
(
"upstream_providers.scopes",
"ALTER TABLE auth.upstream_providers \
ADD COLUMN scopes TEXT NOT NULL DEFAULT '[\"openid\",\"email\",\"profile\"]'",
),
(
"upstream_providers.auth_params",
"ALTER TABLE auth.upstream_providers \
ADD COLUMN auth_params TEXT NOT NULL DEFAULT '{}'",
),
(
"oidc_upstream_states.binding_hash",
"ALTER TABLE auth.oidc_upstream_states \
ADD COLUMN binding_hash TEXT NOT NULL DEFAULT ''",
),
];
#[cfg(feature = "backend-postgres")]
pub async fn migrate_postgres(pool: &sqlx::PgPool) -> anyhow::Result<()> {
use anyhow::Context;
for ddl in [PG_DDL_V1, PG_DDL_V2, PG_DDL_V3, PG_DDL_V4, PG_DDL_V5] {
for stmt in split_pg_statements(ddl) {
sqlx::query(&stmt)
.execute(pool)
.await
.with_context(|| format!("auth pg migrate: {}", first_line(&stmt)))?;
}
}
sqlx::query(
"INSERT INTO engine.migrations (module, version) VALUES ($1, $2) \
ON CONFLICT DO NOTHING",
)
.bind(MODULE_NAME)
.bind(MIGRATION_VERSION)
.execute(pool)
.await
.context("record auth migration in engine.migrations")?;
Ok(())
}
#[cfg(feature = "backend-sqlite")]
pub async fn migrate_sqlite(pool: &sqlx::SqlitePool) -> anyhow::Result<()> {
use anyhow::Context;
for pack in [SQLITE_DDL_V1, SQLITE_DDL_V2, SQLITE_DDL_V3, SQLITE_DDL_V4] {
for (label, stmt) in pack {
sqlx::query(stmt)
.execute(pool)
.await
.with_context(|| format!("auth sqlite migrate: {label}"))?;
}
}
for (label, stmt) in SQLITE_DDL_V5 {
if let Err(e) = sqlx::query(stmt).execute(pool).await {
let msg = format!("{e}");
if msg.contains("duplicate column name") {
continue;
}
return Err(anyhow::anyhow!("auth sqlite migrate: {label}: {e}"));
}
}
sqlx::query("INSERT OR IGNORE INTO engine.migrations (module, version) VALUES (?, ?)")
.bind(MODULE_NAME)
.bind(MIGRATION_VERSION)
.execute(pool)
.await
.context("record auth migration in engine.migrations")?;
Ok(())
}
#[cfg(feature = "backend-postgres")]
fn split_pg_statements(schema: &str) -> Vec<String> {
let cleaned: String = schema
.lines()
.filter(|line| !line.trim_start().starts_with("--"))
.collect::<Vec<_>>()
.join("\n");
cleaned
.split(';')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
#[cfg(feature = "backend-postgres")]
fn first_line(stmt: &str) -> String {
stmt.lines()
.next()
.map(|s| s.trim().to_string())
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn module_name_is_stable() {
assert_eq!(MODULE_NAME, "auth");
}
#[cfg(feature = "backend-postgres")]
#[test]
fn pg_split_drops_pure_comment_lines_and_empty_fragments() {
let sql = "-- top\nCREATE TABLE a(x INT);\n-- mid\nCREATE INDEX i ON a(x);\n";
let stmts = split_pg_statements(sql);
assert_eq!(stmts.len(), 2);
assert!(stmts[0].starts_with("CREATE TABLE"));
assert!(stmts[1].starts_with("CREATE INDEX"));
}
#[cfg(feature = "backend-postgres")]
#[test]
fn pg_ddl_v1_split_is_nonempty() {
let stmts = split_pg_statements(PG_DDL_V1);
assert!(stmts.len() >= 6, "got {} statements", stmts.len());
assert!(stmts.iter().any(|s| s.starts_with("CREATE SCHEMA")));
assert!(stmts.iter().any(|s| s.contains("auth.users")));
assert!(stmts.iter().any(|s| s.contains("auth.sessions")));
assert!(stmts.iter().any(|s| s.contains("auth.jwks_keys")));
}
}