1pub const MODULE_NAME: &str = "auth";
40
41pub const MIGRATION_VERSION: i32 = 5;
63
64pub const PG_DDL_V1: &str = r#"
77CREATE SCHEMA IF NOT EXISTS auth;
78
79CREATE TABLE IF NOT EXISTS auth.users (
80 id TEXT PRIMARY KEY,
81 email TEXT UNIQUE,
82 email_verified BOOLEAN NOT NULL DEFAULT FALSE,
83 display_name TEXT,
84 password_hash TEXT,
85 created_at DOUBLE PRECISION NOT NULL
86);
87
88CREATE TABLE IF NOT EXISTS auth.user_upstream (
89 provider TEXT NOT NULL,
90 subject TEXT NOT NULL,
91 user_id TEXT NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
92 PRIMARY KEY (provider, subject)
93);
94CREATE INDEX IF NOT EXISTS idx_auth_user_upstream_user
95 ON auth.user_upstream (user_id);
96
97CREATE TABLE IF NOT EXISTS auth.passkeys (
98 credential_id BYTEA PRIMARY KEY,
99 user_id TEXT NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
100 public_key BYTEA NOT NULL,
101 sign_count INTEGER NOT NULL DEFAULT 0,
102 transports TEXT NOT NULL,
103 created_at DOUBLE PRECISION NOT NULL
104);
105CREATE INDEX IF NOT EXISTS idx_auth_passkeys_user
106 ON auth.passkeys (user_id);
107
108CREATE TABLE IF NOT EXISTS auth.sessions (
109 id TEXT PRIMARY KEY,
110 user_id TEXT NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
111 csrf_token TEXT NOT NULL,
112 created_at DOUBLE PRECISION NOT NULL,
113 expires_at DOUBLE PRECISION NOT NULL,
114 ip_hash TEXT,
115 user_agent_hash TEXT
116);
117CREATE INDEX IF NOT EXISTS idx_auth_sessions_user
118 ON auth.sessions (user_id);
119CREATE INDEX IF NOT EXISTS idx_auth_sessions_expires
120 ON auth.sessions (expires_at);
121
122CREATE TABLE IF NOT EXISTS auth.jwks_keys (
123 kid TEXT PRIMARY KEY,
124 alg TEXT NOT NULL,
125 public_jwk JSONB NOT NULL,
126 private_pem_encrypted BYTEA,
127 created_at DOUBLE PRECISION NOT NULL,
128 rotated_at DOUBLE PRECISION,
129 expires_at DOUBLE PRECISION
130);
131CREATE INDEX IF NOT EXISTS idx_auth_jwks_keys_active
132 ON auth.jwks_keys (rotated_at) WHERE rotated_at IS NULL;
133"#;
134
135pub const PG_DDL_V2: &str = r#"
141CREATE TABLE IF NOT EXISTS auth.biscuit_root_keys (
142 kid TEXT PRIMARY KEY,
143 private_pem BYTEA NOT NULL,
144 public_pem TEXT NOT NULL,
145 created_at DOUBLE PRECISION NOT NULL,
146 rotated_at DOUBLE PRECISION
147);
148CREATE INDEX IF NOT EXISTS idx_auth_biscuit_root_keys_active
149 ON auth.biscuit_root_keys (rotated_at) WHERE rotated_at IS NULL;
150"#;
151
152pub const PG_DDL_V3: &str = r#"
177CREATE TABLE IF NOT EXISTS auth.zanzibar_namespaces (
178 name TEXT PRIMARY KEY,
179 schema_json JSONB NOT NULL,
180 updated_at DOUBLE PRECISION NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())
181);
182
183CREATE TABLE IF NOT EXISTS auth.zanzibar_tuples (
184 object_type TEXT NOT NULL,
185 object_id TEXT NOT NULL,
186 relation TEXT NOT NULL,
187 subject_type TEXT NOT NULL,
188 subject_id TEXT NOT NULL,
189 subject_rel TEXT NOT NULL DEFAULT '',
190 created_at DOUBLE PRECISION NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW()),
191 PRIMARY KEY (object_type, object_id, relation, subject_type, subject_id, subject_rel)
192);
193CREATE INDEX IF NOT EXISTS idx_auth_zanzibar_tuples_rev
194 ON auth.zanzibar_tuples (subject_type, subject_id, relation);
195"#;
196
197pub const PG_DDL_V4: &str = r#"
222CREATE TABLE IF NOT EXISTS auth.oidc_clients (
223 client_id TEXT PRIMARY KEY,
224 client_secret_hash TEXT,
225 redirect_uris TEXT NOT NULL,
226 name TEXT NOT NULL,
227 logo_url TEXT,
228 token_endpoint_auth_method TEXT NOT NULL,
229 default_scopes TEXT NOT NULL,
230 require_consent BOOLEAN NOT NULL DEFAULT TRUE,
231 grant_types TEXT NOT NULL DEFAULT '["authorization_code","refresh_token"]',
232 response_types TEXT NOT NULL DEFAULT '["code"]',
233 pkce_required BOOLEAN NOT NULL DEFAULT TRUE,
234 backchannel_logout_uri TEXT,
235 created_at DOUBLE PRECISION NOT NULL
236);
237
238CREATE TABLE IF NOT EXISTS auth.upstream_providers (
239 slug TEXT PRIMARY KEY,
240 issuer TEXT NOT NULL,
241 client_id TEXT NOT NULL,
242 client_secret TEXT NOT NULL,
243 display_name TEXT NOT NULL,
244 icon_url TEXT,
245 enabled BOOLEAN NOT NULL DEFAULT TRUE
246);
247
248CREATE TABLE IF NOT EXISTS auth.oidc_authorization_codes (
249 code TEXT PRIMARY KEY,
250 client_id TEXT NOT NULL,
251 user_id TEXT NOT NULL,
252 redirect_uri TEXT NOT NULL,
253 scopes TEXT NOT NULL,
254 code_challenge TEXT NOT NULL,
255 code_challenge_method TEXT NOT NULL,
256 nonce TEXT,
257 state TEXT,
258 issued_at DOUBLE PRECISION NOT NULL,
259 expires_at DOUBLE PRECISION NOT NULL,
260 consumed BOOLEAN NOT NULL DEFAULT FALSE
261);
262
263CREATE TABLE IF NOT EXISTS auth.oidc_refresh_tokens (
264 token_hash TEXT PRIMARY KEY,
265 client_id TEXT NOT NULL,
266 user_id TEXT NOT NULL,
267 scopes TEXT NOT NULL,
268 issued_at DOUBLE PRECISION NOT NULL,
269 expires_at DOUBLE PRECISION NOT NULL,
270 revoked BOOLEAN NOT NULL DEFAULT FALSE
271);
272CREATE INDEX IF NOT EXISTS idx_auth_oidc_refresh_user
273 ON auth.oidc_refresh_tokens (user_id);
274
275CREATE TABLE IF NOT EXISTS auth.oidc_sessions (
276 sid TEXT PRIMARY KEY,
277 user_id TEXT NOT NULL,
278 client_id TEXT NOT NULL,
279 assay_session_id TEXT,
280 issued_at DOUBLE PRECISION NOT NULL,
281 backchannel_logout_uri TEXT
282);
283CREATE INDEX IF NOT EXISTS idx_auth_oidc_sessions_user
284 ON auth.oidc_sessions (user_id);
285CREATE INDEX IF NOT EXISTS idx_auth_oidc_sessions_assay
286 ON auth.oidc_sessions (assay_session_id);
287
288CREATE TABLE IF NOT EXISTS auth.oidc_consents (
289 user_id TEXT NOT NULL,
290 client_id TEXT NOT NULL,
291 scopes TEXT NOT NULL,
292 granted_at DOUBLE PRECISION NOT NULL,
293 PRIMARY KEY (user_id, client_id)
294);
295
296CREATE TABLE IF NOT EXISTS auth.oidc_upstream_states (
297 state TEXT PRIMARY KEY,
298 provider_slug TEXT NOT NULL,
299 nonce TEXT NOT NULL,
300 pkce_verifier TEXT NOT NULL,
301 return_to TEXT,
302 created_at DOUBLE PRECISION NOT NULL,
303 expires_at DOUBLE PRECISION NOT NULL
304);
305"#;
306
307pub const PG_DDL_V5: &str = r#"
316ALTER TABLE auth.upstream_providers
317 ADD COLUMN IF NOT EXISTS scopes TEXT NOT NULL DEFAULT '["openid","email","profile"]';
318ALTER TABLE auth.upstream_providers
319 ADD COLUMN IF NOT EXISTS auth_params TEXT NOT NULL DEFAULT '{}';
320
321ALTER TABLE auth.oidc_upstream_states
322 ADD COLUMN IF NOT EXISTS binding_hash TEXT NOT NULL DEFAULT '';
323"#;
324
325pub const SQLITE_DDL_V1: &[(&str, &str)] = &[
338 (
339 "users",
340 "CREATE TABLE IF NOT EXISTS auth.users (
341 id TEXT PRIMARY KEY,
342 email TEXT UNIQUE,
343 email_verified INTEGER NOT NULL DEFAULT 0,
344 display_name TEXT,
345 password_hash TEXT,
346 created_at REAL NOT NULL
347 )",
348 ),
349 (
350 "user_upstream",
351 "CREATE TABLE IF NOT EXISTS auth.user_upstream (
352 provider TEXT NOT NULL,
353 subject TEXT NOT NULL,
354 user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
355 PRIMARY KEY (provider, subject)
356 )",
357 ),
358 (
359 "idx_user_upstream_user",
360 "CREATE INDEX IF NOT EXISTS auth.idx_auth_user_upstream_user \
361 ON user_upstream (user_id)",
362 ),
363 (
364 "passkeys",
365 "CREATE TABLE IF NOT EXISTS auth.passkeys (
366 credential_id BLOB PRIMARY KEY,
367 user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
368 public_key BLOB NOT NULL,
369 sign_count INTEGER NOT NULL DEFAULT 0,
370 transports TEXT NOT NULL,
371 created_at REAL NOT NULL
372 )",
373 ),
374 (
375 "idx_passkeys_user",
376 "CREATE INDEX IF NOT EXISTS auth.idx_auth_passkeys_user ON passkeys (user_id)",
377 ),
378 (
379 "sessions",
380 "CREATE TABLE IF NOT EXISTS auth.sessions (
381 id TEXT PRIMARY KEY,
382 user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
383 csrf_token TEXT NOT NULL,
384 created_at REAL NOT NULL,
385 expires_at REAL NOT NULL,
386 ip_hash TEXT,
387 user_agent_hash TEXT
388 )",
389 ),
390 (
391 "idx_sessions_user",
392 "CREATE INDEX IF NOT EXISTS auth.idx_auth_sessions_user ON sessions (user_id)",
393 ),
394 (
395 "idx_sessions_expires",
396 "CREATE INDEX IF NOT EXISTS auth.idx_auth_sessions_expires ON sessions (expires_at)",
397 ),
398 (
399 "jwks_keys",
400 "CREATE TABLE IF NOT EXISTS auth.jwks_keys (
401 kid TEXT PRIMARY KEY,
402 alg TEXT NOT NULL,
403 public_jwk TEXT NOT NULL,
404 private_pem_encrypted BLOB,
405 created_at REAL NOT NULL,
406 rotated_at REAL,
407 expires_at REAL
408 )",
409 ),
410 (
411 "idx_jwks_keys_active",
412 "CREATE INDEX IF NOT EXISTS auth.idx_auth_jwks_keys_active \
413 ON jwks_keys (rotated_at) WHERE rotated_at IS NULL",
414 ),
415];
416
417pub const SQLITE_DDL_V2: &[(&str, &str)] = &[
421 (
422 "biscuit_root_keys",
423 "CREATE TABLE IF NOT EXISTS auth.biscuit_root_keys (
424 kid TEXT PRIMARY KEY,
425 private_pem BLOB NOT NULL,
426 public_pem TEXT NOT NULL,
427 created_at REAL NOT NULL,
428 rotated_at REAL
429 )",
430 ),
431 (
432 "idx_biscuit_root_keys_active",
433 "CREATE INDEX IF NOT EXISTS auth.idx_auth_biscuit_root_keys_active \
434 ON biscuit_root_keys (rotated_at) WHERE rotated_at IS NULL",
435 ),
436];
437
438pub const SQLITE_DDL_V3: &[(&str, &str)] = &[
451 (
452 "zanzibar_namespaces",
453 "CREATE TABLE IF NOT EXISTS auth.zanzibar_namespaces (
454 name TEXT PRIMARY KEY,
455 schema_json TEXT NOT NULL,
456 updated_at REAL NOT NULL
457 )",
458 ),
459 (
460 "zanzibar_tuples",
461 "CREATE TABLE IF NOT EXISTS auth.zanzibar_tuples (
462 object_type TEXT NOT NULL,
463 object_id TEXT NOT NULL,
464 relation TEXT NOT NULL,
465 subject_type TEXT NOT NULL,
466 subject_id TEXT NOT NULL,
467 subject_rel TEXT NOT NULL DEFAULT '',
468 created_at REAL NOT NULL,
469 PRIMARY KEY (object_type, object_id, relation, subject_type, subject_id, subject_rel)
470 )",
471 ),
472 (
473 "idx_zanzibar_tuples_rev",
474 "CREATE INDEX IF NOT EXISTS auth.idx_auth_zanzibar_tuples_rev \
475 ON zanzibar_tuples (subject_type, subject_id, relation)",
476 ),
477];
478
479pub const SQLITE_DDL_V4: &[(&str, &str)] = &[
486 (
487 "oidc_clients",
488 "CREATE TABLE IF NOT EXISTS auth.oidc_clients (
489 client_id TEXT PRIMARY KEY,
490 client_secret_hash TEXT,
491 redirect_uris TEXT NOT NULL,
492 name TEXT NOT NULL,
493 logo_url TEXT,
494 token_endpoint_auth_method TEXT NOT NULL,
495 default_scopes TEXT NOT NULL,
496 require_consent INTEGER NOT NULL DEFAULT 1,
497 grant_types TEXT NOT NULL DEFAULT '[\"authorization_code\",\"refresh_token\"]',
498 response_types TEXT NOT NULL DEFAULT '[\"code\"]',
499 pkce_required INTEGER NOT NULL DEFAULT 1,
500 backchannel_logout_uri TEXT,
501 created_at REAL NOT NULL
502 )",
503 ),
504 (
505 "upstream_providers",
506 "CREATE TABLE IF NOT EXISTS auth.upstream_providers (
507 slug TEXT PRIMARY KEY,
508 issuer TEXT NOT NULL,
509 client_id TEXT NOT NULL,
510 client_secret TEXT NOT NULL,
511 display_name TEXT NOT NULL,
512 icon_url TEXT,
513 enabled INTEGER NOT NULL DEFAULT 1
514 )",
515 ),
516 (
517 "oidc_authorization_codes",
518 "CREATE TABLE IF NOT EXISTS auth.oidc_authorization_codes (
519 code TEXT PRIMARY KEY,
520 client_id TEXT NOT NULL,
521 user_id TEXT NOT NULL,
522 redirect_uri TEXT NOT NULL,
523 scopes TEXT NOT NULL,
524 code_challenge TEXT NOT NULL,
525 code_challenge_method TEXT NOT NULL,
526 nonce TEXT,
527 state TEXT,
528 issued_at REAL NOT NULL,
529 expires_at REAL NOT NULL,
530 consumed INTEGER NOT NULL DEFAULT 0
531 )",
532 ),
533 (
534 "oidc_refresh_tokens",
535 "CREATE TABLE IF NOT EXISTS auth.oidc_refresh_tokens (
536 token_hash TEXT PRIMARY KEY,
537 client_id TEXT NOT NULL,
538 user_id TEXT NOT NULL,
539 scopes TEXT NOT NULL,
540 issued_at REAL NOT NULL,
541 expires_at REAL NOT NULL,
542 revoked INTEGER NOT NULL DEFAULT 0
543 )",
544 ),
545 (
546 "idx_oidc_refresh_user",
547 "CREATE INDEX IF NOT EXISTS auth.idx_auth_oidc_refresh_user \
548 ON oidc_refresh_tokens (user_id)",
549 ),
550 (
551 "oidc_sessions",
552 "CREATE TABLE IF NOT EXISTS auth.oidc_sessions (
553 sid TEXT PRIMARY KEY,
554 user_id TEXT NOT NULL,
555 client_id TEXT NOT NULL,
556 assay_session_id TEXT,
557 issued_at REAL NOT NULL,
558 backchannel_logout_uri TEXT
559 )",
560 ),
561 (
562 "idx_oidc_sessions_user",
563 "CREATE INDEX IF NOT EXISTS auth.idx_auth_oidc_sessions_user \
564 ON oidc_sessions (user_id)",
565 ),
566 (
567 "idx_oidc_sessions_assay",
568 "CREATE INDEX IF NOT EXISTS auth.idx_auth_oidc_sessions_assay \
569 ON oidc_sessions (assay_session_id)",
570 ),
571 (
572 "oidc_consents",
573 "CREATE TABLE IF NOT EXISTS auth.oidc_consents (
574 user_id TEXT NOT NULL,
575 client_id TEXT NOT NULL,
576 scopes TEXT NOT NULL,
577 granted_at REAL NOT NULL,
578 PRIMARY KEY (user_id, client_id)
579 )",
580 ),
581 (
582 "oidc_upstream_states",
583 "CREATE TABLE IF NOT EXISTS auth.oidc_upstream_states (
584 state TEXT PRIMARY KEY,
585 provider_slug TEXT NOT NULL,
586 nonce TEXT NOT NULL,
587 pkce_verifier TEXT NOT NULL,
588 return_to TEXT,
589 created_at REAL NOT NULL,
590 expires_at REAL NOT NULL
591 )",
592 ),
593];
594
595pub const SQLITE_DDL_V5: &[(&str, &str)] = &[
603 (
604 "upstream_providers.scopes",
605 "ALTER TABLE auth.upstream_providers \
606 ADD COLUMN scopes TEXT NOT NULL DEFAULT '[\"openid\",\"email\",\"profile\"]'",
607 ),
608 (
609 "upstream_providers.auth_params",
610 "ALTER TABLE auth.upstream_providers \
611 ADD COLUMN auth_params TEXT NOT NULL DEFAULT '{}'",
612 ),
613 (
614 "oidc_upstream_states.binding_hash",
615 "ALTER TABLE auth.oidc_upstream_states \
616 ADD COLUMN binding_hash TEXT NOT NULL DEFAULT ''",
617 ),
618];
619
620#[cfg(feature = "backend-postgres")]
628pub async fn migrate_postgres(pool: &sqlx::PgPool) -> anyhow::Result<()> {
629 use anyhow::Context;
630 for ddl in [PG_DDL_V1, PG_DDL_V2, PG_DDL_V3, PG_DDL_V4, PG_DDL_V5] {
631 for stmt in split_pg_statements(ddl) {
632 sqlx::query(&stmt)
633 .execute(pool)
634 .await
635 .with_context(|| format!("auth pg migrate: {}", first_line(&stmt)))?;
636 }
637 }
638 sqlx::query(
639 "INSERT INTO engine.migrations (module, version) VALUES ($1, $2) \
640 ON CONFLICT DO NOTHING",
641 )
642 .bind(MODULE_NAME)
643 .bind(MIGRATION_VERSION)
644 .execute(pool)
645 .await
646 .context("record auth migration in engine.migrations")?;
647 Ok(())
648}
649
650#[cfg(feature = "backend-sqlite")]
658pub async fn migrate_sqlite(pool: &sqlx::SqlitePool) -> anyhow::Result<()> {
659 use anyhow::Context;
660 for pack in [SQLITE_DDL_V1, SQLITE_DDL_V2, SQLITE_DDL_V3, SQLITE_DDL_V4] {
661 for (label, stmt) in pack {
662 sqlx::query(stmt)
663 .execute(pool)
664 .await
665 .with_context(|| format!("auth sqlite migrate: {label}"))?;
666 }
667 }
668 for (label, stmt) in SQLITE_DDL_V5 {
669 if let Err(e) = sqlx::query(stmt).execute(pool).await {
670 let msg = format!("{e}");
674 if msg.contains("duplicate column name") {
675 continue;
676 }
677 return Err(anyhow::anyhow!("auth sqlite migrate: {label}: {e}"));
678 }
679 }
680 sqlx::query("INSERT OR IGNORE INTO engine.migrations (module, version) VALUES (?, ?)")
681 .bind(MODULE_NAME)
682 .bind(MIGRATION_VERSION)
683 .execute(pool)
684 .await
685 .context("record auth migration in engine.migrations")?;
686 Ok(())
687}
688
689#[cfg(feature = "backend-postgres")]
694fn split_pg_statements(schema: &str) -> Vec<String> {
695 let cleaned: String = schema
696 .lines()
697 .filter(|line| !line.trim_start().starts_with("--"))
698 .collect::<Vec<_>>()
699 .join("\n");
700 cleaned
701 .split(';')
702 .map(|s| s.trim().to_string())
703 .filter(|s| !s.is_empty())
704 .collect()
705}
706
707#[cfg(feature = "backend-postgres")]
708fn first_line(stmt: &str) -> String {
709 stmt.lines()
710 .next()
711 .map(|s| s.trim().to_string())
712 .unwrap_or_default()
713}
714
715#[cfg(test)]
716mod tests {
717 use super::*;
718
719 #[test]
720 fn module_name_is_stable() {
721 assert_eq!(MODULE_NAME, "auth");
725 }
726
727 #[cfg(feature = "backend-postgres")]
728 #[test]
729 fn pg_split_drops_pure_comment_lines_and_empty_fragments() {
730 let sql = "-- top\nCREATE TABLE a(x INT);\n-- mid\nCREATE INDEX i ON a(x);\n";
731 let stmts = split_pg_statements(sql);
732 assert_eq!(stmts.len(), 2);
733 assert!(stmts[0].starts_with("CREATE TABLE"));
734 assert!(stmts[1].starts_with("CREATE INDEX"));
735 }
736
737 #[cfg(feature = "backend-postgres")]
738 #[test]
739 fn pg_ddl_v1_split_is_nonempty() {
740 let stmts = split_pg_statements(PG_DDL_V1);
741 assert!(stmts.len() >= 6, "got {} statements", stmts.len());
743 assert!(stmts.iter().any(|s| s.starts_with("CREATE SCHEMA")));
744 assert!(stmts.iter().any(|s| s.contains("auth.users")));
745 assert!(stmts.iter().any(|s| s.contains("auth.sessions")));
746 assert!(stmts.iter().any(|s| s.contains("auth.jwks_keys")));
747 }
748}