1pub const MODULE_NAME: &str = "auth";
40
41pub const MIGRATION_VERSION: i32 = 4;
58
59pub const PG_DDL_V1: &str = r#"
72CREATE SCHEMA IF NOT EXISTS auth;
73
74CREATE TABLE IF NOT EXISTS auth.users (
75 id TEXT PRIMARY KEY,
76 email TEXT UNIQUE,
77 email_verified BOOLEAN NOT NULL DEFAULT FALSE,
78 display_name TEXT,
79 password_hash TEXT,
80 created_at DOUBLE PRECISION NOT NULL
81);
82
83CREATE TABLE IF NOT EXISTS auth.user_upstream (
84 provider TEXT NOT NULL,
85 subject TEXT NOT NULL,
86 user_id TEXT NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
87 PRIMARY KEY (provider, subject)
88);
89CREATE INDEX IF NOT EXISTS idx_auth_user_upstream_user
90 ON auth.user_upstream (user_id);
91
92CREATE TABLE IF NOT EXISTS auth.passkeys (
93 credential_id BYTEA PRIMARY KEY,
94 user_id TEXT NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
95 public_key BYTEA NOT NULL,
96 sign_count INTEGER NOT NULL DEFAULT 0,
97 transports TEXT NOT NULL,
98 created_at DOUBLE PRECISION NOT NULL
99);
100CREATE INDEX IF NOT EXISTS idx_auth_passkeys_user
101 ON auth.passkeys (user_id);
102
103CREATE TABLE IF NOT EXISTS auth.sessions (
104 id TEXT PRIMARY KEY,
105 user_id TEXT NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
106 csrf_token TEXT NOT NULL,
107 created_at DOUBLE PRECISION NOT NULL,
108 expires_at DOUBLE PRECISION NOT NULL,
109 ip_hash TEXT,
110 user_agent_hash TEXT
111);
112CREATE INDEX IF NOT EXISTS idx_auth_sessions_user
113 ON auth.sessions (user_id);
114CREATE INDEX IF NOT EXISTS idx_auth_sessions_expires
115 ON auth.sessions (expires_at);
116
117CREATE TABLE IF NOT EXISTS auth.jwks_keys (
118 kid TEXT PRIMARY KEY,
119 alg TEXT NOT NULL,
120 public_jwk JSONB NOT NULL,
121 private_pem_encrypted BYTEA,
122 created_at DOUBLE PRECISION NOT NULL,
123 rotated_at DOUBLE PRECISION,
124 expires_at DOUBLE PRECISION
125);
126CREATE INDEX IF NOT EXISTS idx_auth_jwks_keys_active
127 ON auth.jwks_keys (rotated_at) WHERE rotated_at IS NULL;
128"#;
129
130pub const PG_DDL_V2: &str = r#"
136CREATE TABLE IF NOT EXISTS auth.biscuit_root_keys (
137 kid TEXT PRIMARY KEY,
138 private_pem BYTEA NOT NULL,
139 public_pem TEXT NOT NULL,
140 created_at DOUBLE PRECISION NOT NULL,
141 rotated_at DOUBLE PRECISION
142);
143CREATE INDEX IF NOT EXISTS idx_auth_biscuit_root_keys_active
144 ON auth.biscuit_root_keys (rotated_at) WHERE rotated_at IS NULL;
145"#;
146
147pub const PG_DDL_V3: &str = r#"
172CREATE TABLE IF NOT EXISTS auth.zanzibar_namespaces (
173 name TEXT PRIMARY KEY,
174 schema_json JSONB NOT NULL,
175 updated_at DOUBLE PRECISION NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())
176);
177
178CREATE TABLE IF NOT EXISTS auth.zanzibar_tuples (
179 object_type TEXT NOT NULL,
180 object_id TEXT NOT NULL,
181 relation TEXT NOT NULL,
182 subject_type TEXT NOT NULL,
183 subject_id TEXT NOT NULL,
184 subject_rel TEXT NOT NULL DEFAULT '',
185 created_at DOUBLE PRECISION NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW()),
186 PRIMARY KEY (object_type, object_id, relation, subject_type, subject_id, subject_rel)
187);
188CREATE INDEX IF NOT EXISTS idx_auth_zanzibar_tuples_rev
189 ON auth.zanzibar_tuples (subject_type, subject_id, relation);
190"#;
191
192pub const PG_DDL_V4: &str = r#"
217CREATE TABLE IF NOT EXISTS auth.oidc_clients (
218 client_id TEXT PRIMARY KEY,
219 client_secret_hash TEXT,
220 redirect_uris TEXT NOT NULL,
221 name TEXT NOT NULL,
222 logo_url TEXT,
223 token_endpoint_auth_method TEXT NOT NULL,
224 default_scopes TEXT NOT NULL,
225 require_consent BOOLEAN NOT NULL DEFAULT TRUE,
226 grant_types TEXT NOT NULL DEFAULT '["authorization_code","refresh_token"]',
227 response_types TEXT NOT NULL DEFAULT '["code"]',
228 pkce_required BOOLEAN NOT NULL DEFAULT TRUE,
229 backchannel_logout_uri TEXT,
230 created_at DOUBLE PRECISION NOT NULL
231);
232
233CREATE TABLE IF NOT EXISTS auth.upstream_providers (
234 slug TEXT PRIMARY KEY,
235 issuer TEXT NOT NULL,
236 client_id TEXT NOT NULL,
237 client_secret TEXT NOT NULL,
238 display_name TEXT NOT NULL,
239 icon_url TEXT,
240 enabled BOOLEAN NOT NULL DEFAULT TRUE
241);
242
243CREATE TABLE IF NOT EXISTS auth.oidc_authorization_codes (
244 code TEXT PRIMARY KEY,
245 client_id TEXT NOT NULL,
246 user_id TEXT NOT NULL,
247 redirect_uri TEXT NOT NULL,
248 scopes TEXT NOT NULL,
249 code_challenge TEXT NOT NULL,
250 code_challenge_method TEXT NOT NULL,
251 nonce TEXT,
252 state TEXT,
253 issued_at DOUBLE PRECISION NOT NULL,
254 expires_at DOUBLE PRECISION NOT NULL,
255 consumed BOOLEAN NOT NULL DEFAULT FALSE
256);
257
258CREATE TABLE IF NOT EXISTS auth.oidc_refresh_tokens (
259 token_hash TEXT PRIMARY KEY,
260 client_id TEXT NOT NULL,
261 user_id TEXT NOT NULL,
262 scopes TEXT NOT NULL,
263 issued_at DOUBLE PRECISION NOT NULL,
264 expires_at DOUBLE PRECISION NOT NULL,
265 revoked BOOLEAN NOT NULL DEFAULT FALSE
266);
267CREATE INDEX IF NOT EXISTS idx_auth_oidc_refresh_user
268 ON auth.oidc_refresh_tokens (user_id);
269
270CREATE TABLE IF NOT EXISTS auth.oidc_sessions (
271 sid TEXT PRIMARY KEY,
272 user_id TEXT NOT NULL,
273 client_id TEXT NOT NULL,
274 assay_session_id TEXT,
275 issued_at DOUBLE PRECISION NOT NULL,
276 backchannel_logout_uri TEXT
277);
278CREATE INDEX IF NOT EXISTS idx_auth_oidc_sessions_user
279 ON auth.oidc_sessions (user_id);
280CREATE INDEX IF NOT EXISTS idx_auth_oidc_sessions_assay
281 ON auth.oidc_sessions (assay_session_id);
282
283CREATE TABLE IF NOT EXISTS auth.oidc_consents (
284 user_id TEXT NOT NULL,
285 client_id TEXT NOT NULL,
286 scopes TEXT NOT NULL,
287 granted_at DOUBLE PRECISION NOT NULL,
288 PRIMARY KEY (user_id, client_id)
289);
290
291CREATE TABLE IF NOT EXISTS auth.oidc_upstream_states (
292 state TEXT PRIMARY KEY,
293 provider_slug TEXT NOT NULL,
294 nonce TEXT NOT NULL,
295 pkce_verifier TEXT NOT NULL,
296 return_to TEXT,
297 created_at DOUBLE PRECISION NOT NULL,
298 expires_at DOUBLE PRECISION NOT NULL
299);
300"#;
301
302pub const SQLITE_DDL_V1: &[(&str, &str)] = &[
315 (
316 "users",
317 "CREATE TABLE IF NOT EXISTS auth.users (
318 id TEXT PRIMARY KEY,
319 email TEXT UNIQUE,
320 email_verified INTEGER NOT NULL DEFAULT 0,
321 display_name TEXT,
322 password_hash TEXT,
323 created_at REAL NOT NULL
324 )",
325 ),
326 (
327 "user_upstream",
328 "CREATE TABLE IF NOT EXISTS auth.user_upstream (
329 provider TEXT NOT NULL,
330 subject TEXT NOT NULL,
331 user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
332 PRIMARY KEY (provider, subject)
333 )",
334 ),
335 (
336 "idx_user_upstream_user",
337 "CREATE INDEX IF NOT EXISTS auth.idx_auth_user_upstream_user \
338 ON user_upstream (user_id)",
339 ),
340 (
341 "passkeys",
342 "CREATE TABLE IF NOT EXISTS auth.passkeys (
343 credential_id BLOB PRIMARY KEY,
344 user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
345 public_key BLOB NOT NULL,
346 sign_count INTEGER NOT NULL DEFAULT 0,
347 transports TEXT NOT NULL,
348 created_at REAL NOT NULL
349 )",
350 ),
351 (
352 "idx_passkeys_user",
353 "CREATE INDEX IF NOT EXISTS auth.idx_auth_passkeys_user ON passkeys (user_id)",
354 ),
355 (
356 "sessions",
357 "CREATE TABLE IF NOT EXISTS auth.sessions (
358 id TEXT PRIMARY KEY,
359 user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
360 csrf_token TEXT NOT NULL,
361 created_at REAL NOT NULL,
362 expires_at REAL NOT NULL,
363 ip_hash TEXT,
364 user_agent_hash TEXT
365 )",
366 ),
367 (
368 "idx_sessions_user",
369 "CREATE INDEX IF NOT EXISTS auth.idx_auth_sessions_user ON sessions (user_id)",
370 ),
371 (
372 "idx_sessions_expires",
373 "CREATE INDEX IF NOT EXISTS auth.idx_auth_sessions_expires ON sessions (expires_at)",
374 ),
375 (
376 "jwks_keys",
377 "CREATE TABLE IF NOT EXISTS auth.jwks_keys (
378 kid TEXT PRIMARY KEY,
379 alg TEXT NOT NULL,
380 public_jwk TEXT NOT NULL,
381 private_pem_encrypted BLOB,
382 created_at REAL NOT NULL,
383 rotated_at REAL,
384 expires_at REAL
385 )",
386 ),
387 (
388 "idx_jwks_keys_active",
389 "CREATE INDEX IF NOT EXISTS auth.idx_auth_jwks_keys_active \
390 ON jwks_keys (rotated_at) WHERE rotated_at IS NULL",
391 ),
392];
393
394pub const SQLITE_DDL_V2: &[(&str, &str)] = &[
398 (
399 "biscuit_root_keys",
400 "CREATE TABLE IF NOT EXISTS auth.biscuit_root_keys (
401 kid TEXT PRIMARY KEY,
402 private_pem BLOB NOT NULL,
403 public_pem TEXT NOT NULL,
404 created_at REAL NOT NULL,
405 rotated_at REAL
406 )",
407 ),
408 (
409 "idx_biscuit_root_keys_active",
410 "CREATE INDEX IF NOT EXISTS auth.idx_auth_biscuit_root_keys_active \
411 ON biscuit_root_keys (rotated_at) WHERE rotated_at IS NULL",
412 ),
413];
414
415pub const SQLITE_DDL_V3: &[(&str, &str)] = &[
428 (
429 "zanzibar_namespaces",
430 "CREATE TABLE IF NOT EXISTS auth.zanzibar_namespaces (
431 name TEXT PRIMARY KEY,
432 schema_json TEXT NOT NULL,
433 updated_at REAL NOT NULL
434 )",
435 ),
436 (
437 "zanzibar_tuples",
438 "CREATE TABLE IF NOT EXISTS auth.zanzibar_tuples (
439 object_type TEXT NOT NULL,
440 object_id TEXT NOT NULL,
441 relation TEXT NOT NULL,
442 subject_type TEXT NOT NULL,
443 subject_id TEXT NOT NULL,
444 subject_rel TEXT NOT NULL DEFAULT '',
445 created_at REAL NOT NULL,
446 PRIMARY KEY (object_type, object_id, relation, subject_type, subject_id, subject_rel)
447 )",
448 ),
449 (
450 "idx_zanzibar_tuples_rev",
451 "CREATE INDEX IF NOT EXISTS auth.idx_auth_zanzibar_tuples_rev \
452 ON zanzibar_tuples (subject_type, subject_id, relation)",
453 ),
454];
455
456pub const SQLITE_DDL_V4: &[(&str, &str)] = &[
463 (
464 "oidc_clients",
465 "CREATE TABLE IF NOT EXISTS auth.oidc_clients (
466 client_id TEXT PRIMARY KEY,
467 client_secret_hash TEXT,
468 redirect_uris TEXT NOT NULL,
469 name TEXT NOT NULL,
470 logo_url TEXT,
471 token_endpoint_auth_method TEXT NOT NULL,
472 default_scopes TEXT NOT NULL,
473 require_consent INTEGER NOT NULL DEFAULT 1,
474 grant_types TEXT NOT NULL DEFAULT '[\"authorization_code\",\"refresh_token\"]',
475 response_types TEXT NOT NULL DEFAULT '[\"code\"]',
476 pkce_required INTEGER NOT NULL DEFAULT 1,
477 backchannel_logout_uri TEXT,
478 created_at REAL NOT NULL
479 )",
480 ),
481 (
482 "upstream_providers",
483 "CREATE TABLE IF NOT EXISTS auth.upstream_providers (
484 slug TEXT PRIMARY KEY,
485 issuer TEXT NOT NULL,
486 client_id TEXT NOT NULL,
487 client_secret TEXT NOT NULL,
488 display_name TEXT NOT NULL,
489 icon_url TEXT,
490 enabled INTEGER NOT NULL DEFAULT 1
491 )",
492 ),
493 (
494 "oidc_authorization_codes",
495 "CREATE TABLE IF NOT EXISTS auth.oidc_authorization_codes (
496 code TEXT PRIMARY KEY,
497 client_id TEXT NOT NULL,
498 user_id TEXT NOT NULL,
499 redirect_uri TEXT NOT NULL,
500 scopes TEXT NOT NULL,
501 code_challenge TEXT NOT NULL,
502 code_challenge_method TEXT NOT NULL,
503 nonce TEXT,
504 state TEXT,
505 issued_at REAL NOT NULL,
506 expires_at REAL NOT NULL,
507 consumed INTEGER NOT NULL DEFAULT 0
508 )",
509 ),
510 (
511 "oidc_refresh_tokens",
512 "CREATE TABLE IF NOT EXISTS auth.oidc_refresh_tokens (
513 token_hash TEXT PRIMARY KEY,
514 client_id TEXT NOT NULL,
515 user_id TEXT NOT NULL,
516 scopes TEXT NOT NULL,
517 issued_at REAL NOT NULL,
518 expires_at REAL NOT NULL,
519 revoked INTEGER NOT NULL DEFAULT 0
520 )",
521 ),
522 (
523 "idx_oidc_refresh_user",
524 "CREATE INDEX IF NOT EXISTS auth.idx_auth_oidc_refresh_user \
525 ON oidc_refresh_tokens (user_id)",
526 ),
527 (
528 "oidc_sessions",
529 "CREATE TABLE IF NOT EXISTS auth.oidc_sessions (
530 sid TEXT PRIMARY KEY,
531 user_id TEXT NOT NULL,
532 client_id TEXT NOT NULL,
533 assay_session_id TEXT,
534 issued_at REAL NOT NULL,
535 backchannel_logout_uri TEXT
536 )",
537 ),
538 (
539 "idx_oidc_sessions_user",
540 "CREATE INDEX IF NOT EXISTS auth.idx_auth_oidc_sessions_user \
541 ON oidc_sessions (user_id)",
542 ),
543 (
544 "idx_oidc_sessions_assay",
545 "CREATE INDEX IF NOT EXISTS auth.idx_auth_oidc_sessions_assay \
546 ON oidc_sessions (assay_session_id)",
547 ),
548 (
549 "oidc_consents",
550 "CREATE TABLE IF NOT EXISTS auth.oidc_consents (
551 user_id TEXT NOT NULL,
552 client_id TEXT NOT NULL,
553 scopes TEXT NOT NULL,
554 granted_at REAL NOT NULL,
555 PRIMARY KEY (user_id, client_id)
556 )",
557 ),
558 (
559 "oidc_upstream_states",
560 "CREATE TABLE IF NOT EXISTS auth.oidc_upstream_states (
561 state TEXT PRIMARY KEY,
562 provider_slug TEXT NOT NULL,
563 nonce TEXT NOT NULL,
564 pkce_verifier TEXT NOT NULL,
565 return_to TEXT,
566 created_at REAL NOT NULL,
567 expires_at REAL NOT NULL
568 )",
569 ),
570];
571
572#[cfg(feature = "backend-postgres")]
580pub async fn migrate_postgres(pool: &sqlx::PgPool) -> anyhow::Result<()> {
581 use anyhow::Context;
582 for ddl in [PG_DDL_V1, PG_DDL_V2, PG_DDL_V3, PG_DDL_V4] {
583 for stmt in split_pg_statements(ddl) {
584 sqlx::query(&stmt)
585 .execute(pool)
586 .await
587 .with_context(|| format!("auth pg migrate: {}", first_line(&stmt)))?;
588 }
589 }
590 sqlx::query(
591 "INSERT INTO engine.migrations (module, version) VALUES ($1, $2) \
592 ON CONFLICT DO NOTHING",
593 )
594 .bind(MODULE_NAME)
595 .bind(MIGRATION_VERSION)
596 .execute(pool)
597 .await
598 .context("record auth migration in engine.migrations")?;
599 Ok(())
600}
601
602#[cfg(feature = "backend-sqlite")]
610pub async fn migrate_sqlite(pool: &sqlx::SqlitePool) -> anyhow::Result<()> {
611 use anyhow::Context;
612 for pack in [SQLITE_DDL_V1, SQLITE_DDL_V2, SQLITE_DDL_V3, SQLITE_DDL_V4] {
613 for (label, stmt) in pack {
614 sqlx::query(stmt)
615 .execute(pool)
616 .await
617 .with_context(|| format!("auth sqlite migrate: {label}"))?;
618 }
619 }
620 sqlx::query(
621 "INSERT OR IGNORE INTO engine.migrations (module, version) VALUES (?, ?)",
622 )
623 .bind(MODULE_NAME)
624 .bind(MIGRATION_VERSION)
625 .execute(pool)
626 .await
627 .context("record auth migration in engine.migrations")?;
628 Ok(())
629}
630
631#[cfg(feature = "backend-postgres")]
636fn split_pg_statements(schema: &str) -> Vec<String> {
637 let cleaned: String = schema
638 .lines()
639 .filter(|line| !line.trim_start().starts_with("--"))
640 .collect::<Vec<_>>()
641 .join("\n");
642 cleaned
643 .split(';')
644 .map(|s| s.trim().to_string())
645 .filter(|s| !s.is_empty())
646 .collect()
647}
648
649#[cfg(feature = "backend-postgres")]
650fn first_line(stmt: &str) -> String {
651 stmt.lines()
652 .next()
653 .map(|s| s.trim().to_string())
654 .unwrap_or_default()
655}
656
657#[cfg(test)]
658mod tests {
659 use super::*;
660
661 #[test]
662 fn module_name_is_stable() {
663 assert_eq!(MODULE_NAME, "auth");
667 }
668
669 #[cfg(feature = "backend-postgres")]
670 #[test]
671 fn pg_split_drops_pure_comment_lines_and_empty_fragments() {
672 let sql = "-- top\nCREATE TABLE a(x INT);\n-- mid\nCREATE INDEX i ON a(x);\n";
673 let stmts = split_pg_statements(sql);
674 assert_eq!(stmts.len(), 2);
675 assert!(stmts[0].starts_with("CREATE TABLE"));
676 assert!(stmts[1].starts_with("CREATE INDEX"));
677 }
678
679 #[cfg(feature = "backend-postgres")]
680 #[test]
681 fn pg_ddl_v1_split_is_nonempty() {
682 let stmts = split_pg_statements(PG_DDL_V1);
683 assert!(stmts.len() >= 6, "got {} statements", stmts.len());
685 assert!(stmts.iter().any(|s| s.starts_with("CREATE SCHEMA")));
686 assert!(stmts.iter().any(|s| s.contains("auth.users")));
687 assert!(stmts.iter().any(|s| s.contains("auth.sessions")));
688 assert!(stmts.iter().any(|s| s.contains("auth.jwks_keys")));
689 }
690}