diff --git a/apps/api/drizzle/0011_whole_karen_page.sql b/apps/api/drizzle/0011_whole_karen_page.sql new file mode 100644 index 00000000..14795709 --- /dev/null +++ b/apps/api/drizzle/0011_whole_karen_page.sql @@ -0,0 +1,4 @@ +ALTER TABLE `users` ADD `auth_provider` text DEFAULT 'local' NOT NULL;--> statement-breakpoint +ALTER TABLE `users` ADD `external_id` text;--> statement-breakpoint +ALTER TABLE `users` ADD `email` text;--> statement-breakpoint +ALTER TABLE `sessions` ADD `id_token` text; \ No newline at end of file diff --git a/apps/api/drizzle/0012_make_password_hash_nullable.sql b/apps/api/drizzle/0012_make_password_hash_nullable.sql new file mode 100644 index 00000000..bebe75c0 --- /dev/null +++ b/apps/api/drizzle/0012_make_password_hash_nullable.sql @@ -0,0 +1,22 @@ +-- Make password_hash nullable to support OIDC-only users (no local password). +-- SQLite does not support ALTER COLUMN, so we must recreate the table. +CREATE TABLE `users_new` ( + `id` text PRIMARY KEY NOT NULL, + `username` text NOT NULL, + `password_hash` text, + `role` text DEFAULT 'user' NOT NULL, + `team` text DEFAULT 'Default' NOT NULL, + `must_change_password` integer DEFAULT true NOT NULL, + `auth_provider` text DEFAULT 'local' NOT NULL, + `external_id` text, + `email` text, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + `analytics_enabled` integer, + `analytics_consent_shown_at` integer, + `analytics_consent_remind_at` integer +);--> statement-breakpoint +INSERT INTO `users_new` SELECT * FROM `users`;--> statement-breakpoint +DROP TABLE `users`;--> statement-breakpoint +ALTER TABLE `users_new` RENAME TO `users`;--> statement-breakpoint +CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`); diff --git a/apps/api/drizzle/meta/0011_snapshot.json b/apps/api/drizzle/meta/0011_snapshot.json new file mode 100644 index 00000000..7619dc07 --- /dev/null +++ b/apps/api/drizzle/meta/0011_snapshot.json @@ -0,0 +1,759 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "af3a8286-7226-4df5-ac49-a11322b63aac", + "prevId": "a1b2c3d4-e5f6-7890-abcd-000500050005", + "tables": { + "api_keys": { + "name": "api_keys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Default API Key'" + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit_log": { + "name": "audit_log", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_username": { + "name": "actor_username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "audit_log_actor_id_users_id_fk": { + "name": "audit_log_actor_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "jobs": { + "name": "jobs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "progress": { + "name": "progress", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "input_files": { + "name": "input_files", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_path": { + "name": "output_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "settings": { + "name": "settings", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "pipelines": { + "name": "pipelines", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "steps": { + "name": "steps", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "pipelines_user_id_users_id_fk": { + "name": "pipelines_user_id_users_id_fk", + "tableFrom": "pipelines", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "roles": { + "name": "roles", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_builtin": { + "name": "is_builtin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "roles_name_unique": { + "name": "roles_name_unique", + "columns": ["name"], + "isUnique": true + } + }, + "foreignKeys": { + "roles_created_by_users_id_fk": { + "name": "roles_created_by_users_id_fk", + "tableFrom": "roles", + "tableTo": "users", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "teams": { + "name": "teams", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "teams_name_unique": { + "name": "teams_name_unique", + "columns": ["name"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_files": { + "name": "user_files", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stored_name": { + "name": "stored_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tool_chain": { + "name": "tool_chain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_files_user_id_users_id_fk": { + "name": "user_files_user_id_users_id_fk", + "tableFrom": "user_files", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'user'" + }, + "team": { + "name": "team", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Default'" + }, + "must_change_password": { + "name": "must_change_password", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "auth_provider": { + "name": "auth_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'local'" + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "analytics_enabled": { + "name": "analytics_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "analytics_consent_shown_at": { + "name": "analytics_consent_shown_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "analytics_consent_remind_at": { + "name": "analytics_consent_remind_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "users_username_unique": { + "name": "users_username_unique", + "columns": ["username"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/apps/api/drizzle/meta/0012_snapshot.json b/apps/api/drizzle/meta/0012_snapshot.json new file mode 100644 index 00000000..bdce67e4 --- /dev/null +++ b/apps/api/drizzle/meta/0012_snapshot.json @@ -0,0 +1,759 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "b1c2d3e4-f5a6-7890-bcde-001200120012", + "prevId": "af3a8286-7226-4df5-ac49-a11322b63aac", + "tables": { + "api_keys": { + "name": "api_keys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Default API Key'" + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit_log": { + "name": "audit_log", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_username": { + "name": "actor_username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "audit_log_actor_id_users_id_fk": { + "name": "audit_log_actor_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "jobs": { + "name": "jobs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "progress": { + "name": "progress", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "input_files": { + "name": "input_files", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_path": { + "name": "output_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "settings": { + "name": "settings", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "pipelines": { + "name": "pipelines", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "steps": { + "name": "steps", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "pipelines_user_id_users_id_fk": { + "name": "pipelines_user_id_users_id_fk", + "tableFrom": "pipelines", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "roles": { + "name": "roles", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_builtin": { + "name": "is_builtin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "roles_name_unique": { + "name": "roles_name_unique", + "columns": ["name"], + "isUnique": true + } + }, + "foreignKeys": { + "roles_created_by_users_id_fk": { + "name": "roles_created_by_users_id_fk", + "tableFrom": "roles", + "tableTo": "users", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "teams": { + "name": "teams", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "teams_name_unique": { + "name": "teams_name_unique", + "columns": ["name"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_files": { + "name": "user_files", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stored_name": { + "name": "stored_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tool_chain": { + "name": "tool_chain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_files_user_id_users_id_fk": { + "name": "user_files_user_id_users_id_fk", + "tableFrom": "user_files", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'user'" + }, + "team": { + "name": "team", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Default'" + }, + "must_change_password": { + "name": "must_change_password", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "auth_provider": { + "name": "auth_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'local'" + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "analytics_enabled": { + "name": "analytics_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "analytics_consent_shown_at": { + "name": "analytics_consent_shown_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "analytics_consent_remind_at": { + "name": "analytics_consent_remind_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "users_username_unique": { + "name": "users_username_unique", + "columns": ["username"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index 1990be67..72e7e414 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -78,6 +78,20 @@ "when": 1778300000000, "tag": "0010_remove_branding", "breakpoints": true + }, + { + "idx": 11, + "version": "6", + "when": 1778669228893, + "tag": "0011_whole_karen_page", + "breakpoints": true + }, + { + "idx": 12, + "version": "6", + "when": 1778669300000, + "tag": "0012_make_password_hash_nullable", + "breakpoints": true } ] } diff --git a/apps/api/package.json b/apps/api/package.json index 48334a85..20e5d4da 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -12,6 +12,7 @@ "clean": "rm -rf dist" }, "dependencies": { + "@fastify/cookie": "^11.0.2", "@fastify/cors": "^11.0.0", "@fastify/multipart": "^9.0.0", "@fastify/rate-limit": "^10.2.0", @@ -31,6 +32,7 @@ "fflate": "^0.8.2", "js-yaml": "^4.1.1", "mupdf": "^1.27.0", + "openid-client": "^6.8.4", "opentype.js": "^2.0.0", "p-queue": "^9.1.0", "pdfkit": "^0.18.0", diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 7583edb0..0d3d761b 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -3,10 +3,13 @@ import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core"; export const users = sqliteTable("users", { id: text("id").primaryKey(), username: text("username").notNull().unique(), - passwordHash: text("password_hash").notNull(), + passwordHash: text("password_hash"), role: text("role").notNull().default("user"), team: text("team").notNull().default("Default"), mustChangePassword: integer("must_change_password", { mode: "boolean" }).notNull().default(true), + authProvider: text("auth_provider").notNull().default("local"), + externalId: text("external_id"), + email: text("email"), createdAt: integer("created_at", { mode: "timestamp" }) .notNull() .$defaultFn(() => new Date()), @@ -32,6 +35,7 @@ export const sessions = sqliteTable("sessions", { .notNull() .references(() => users.id, { onDelete: "cascade" }), expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(), + idToken: text("id_token"), createdAt: integer("created_at", { mode: "timestamp" }) .notNull() .$defaultFn(() => new Date()), diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 07b3bf3e..7464c5e6 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import cookie from "@fastify/cookie"; import cors from "@fastify/cors"; import rateLimit from "@fastify/rate-limit"; import { getDispatcherStatus, initDispatcher, isGpuAvailable } from "@snapotter/ai"; @@ -15,6 +16,7 @@ import { ensureAiDirs, recoverInterruptedInstalls } from "./lib/feature-status.j import { shutdownWorkerPool } from "./lib/worker-pool.js"; import { requirePermission } from "./permissions.js"; import { authMiddleware, authRoutes, ensureDefaultAdmin } from "./plugins/auth.js"; +import { oidcRoutes } from "./plugins/oidc.js"; import { registerStatic } from "./plugins/static.js"; import { registerUpload } from "./plugins/upload.js"; import { analyticsRoutes } from "./routes/analytics.js"; @@ -70,6 +72,22 @@ function ensureDefaultSettings() { } ensureDefaultSettings(); + +if (!env.COOKIE_SECRET) { + const existing = db + .select() + .from(schema.settings) + .where(eq(schema.settings.key, "cookie_secret")) + .get(); + if (existing) { + (env as Record).COOKIE_SECRET = existing.value; + } else { + const generated = randomUUID() + randomUUID(); + db.insert(schema.settings).values({ key: "cookie_secret", value: generated }).run(); + (env as Record).COOKIE_SECRET = generated; + } +} + await initAnalytics(); // Mark any jobs left in processing/queued from a previous unclean shutdown @@ -148,12 +166,21 @@ await app.register(rateLimit, { // Multipart upload support await registerUpload(app); +// Cookie support (required for OIDC state and session cookies) +await app.register(cookie, { + secret: env.COOKIE_SECRET, + hook: "onRequest", +}); + // Auth middleware (must be registered before routes it protects) await authMiddleware(app); // Auth routes await authRoutes(app); +// OIDC routes +await oidcRoutes(app); + // File upload/download routes await fileRoutes(app); @@ -244,9 +271,17 @@ app.get("/api/v1/admin/health", async (request, reply) => { }); // Public config endpoint (for frontend to know if auth is required) -app.get("/api/v1/config/auth", async () => ({ - authEnabled: env.AUTH_ENABLED, -})); +app.get("/api/v1/config/auth", async () => { + const config: Record = { + authEnabled: env.AUTH_ENABLED, + }; + if (env.OIDC_ENABLED) { + config.oidcEnabled = true; + config.oidcProviderName = env.OIDC_PROVIDER_NAME || null; + config.oidcLoginUrl = "/api/auth/oidc/login"; + } + return config; +}); // Serve SPA in production if (process.env.NODE_ENV === "production") { diff --git a/apps/api/src/lib/audit.ts b/apps/api/src/lib/audit.ts index 53823dbf..eceb23eb 100644 --- a/apps/api/src/lib/audit.ts +++ b/apps/api/src/lib/audit.ts @@ -18,7 +18,11 @@ type AuditEvent = | "ROLE_CREATED" | "ROLE_UPDATED" | "ROLE_DELETED" - | "SETTINGS_UPDATED"; + | "SETTINGS_UPDATED" + | "OIDC_LOGIN_SUCCESS" + | "OIDC_USER_CREATED" + | "OIDC_USER_LINKED" + | "OIDC_LOGIN_FAILED"; /** * Emit a structured audit log entry for security-relevant events. @@ -60,6 +64,7 @@ function deriveTargetType(event: AuditEvent): string | null { event.startsWith("USER_") || event.startsWith("LOGIN") || event.startsWith("PASSWORD") || + event.startsWith("OIDC_") || event === "LOGOUT" ) return "user"; diff --git a/apps/api/src/lib/env.ts b/apps/api/src/lib/env.ts index 137d6862..7189cb9c 100644 --- a/apps/api/src/lib/env.ts +++ b/apps/api/src/lib/env.ts @@ -1,62 +1,117 @@ import { availableParallelism } from "node:os"; import { z } from "zod"; -const envSchema = z.object({ - PORT: z.coerce.number().default(1349), - AUTH_ENABLED: z - .enum(["true", "false"]) - .default("true") - .transform((v) => v === "true"), - DEFAULT_USERNAME: z.string().default("admin"), - DEFAULT_PASSWORD: z.string().default("admin"), - SKIP_MUST_CHANGE_PASSWORD: z - .enum(["true", "false"]) - .default("false") - .transform((v) => v === "true"), - STORAGE_MODE: z.enum(["local", "s3"]).default("local"), - FILE_MAX_AGE_HOURS: z.coerce.number().default(72), - CLEANUP_INTERVAL_MINUTES: z.coerce.number().default(60), - MAX_UPLOAD_SIZE_MB: z.coerce.number().default(0), - MAX_BATCH_SIZE: z.coerce.number().default(0), - CONCURRENT_JOBS: z.coerce.number().default(0), - MAX_MEGAPIXELS: z.coerce.number().default(0), - RATE_LIMIT_PER_MIN: z.coerce.number().default(1000), - DB_PATH: z.string().default("./data/snapotter.db"), - FILES_STORAGE_PATH: z.string().default("./data/files"), - WORKSPACE_PATH: z.string().default("./tmp/workspace"), - DEFAULT_THEME: z.enum(["light", "dark", "system"]).default("light"), - DEFAULT_LOCALE: z.string().default("en"), - CORS_ORIGIN: z.string().default(""), - MAX_USERS: z.coerce.number().default(0), - LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace"]).default("info"), - MAX_WORKER_THREADS: z.coerce.number().default(0), - PROCESSING_TIMEOUT_S: z.coerce.number().default(0), - MAX_PIPELINE_STEPS: z.coerce.number().default(0), - MAX_CANVAS_PIXELS: z.coerce.number().default(0), - MAX_SVG_SIZE_MB: z.coerce.number().default(0), - MAX_SPLIT_GRID: z.coerce.number().default(100), - MAX_STORAGE_PER_USER_MB: z.coerce.number().default(5000), - MAX_WORKSPACE_SIZE_GB: z.coerce.number().default(10), - MAX_PDF_PAGES: z.coerce.number().default(0), - SESSION_DURATION_HOURS: z.coerce.number().default(168), - LOGIN_ATTEMPT_LIMIT: z.coerce.number().default(30), - TRUST_PROXY: z - .enum(["true", "false"]) - .default("true") - .transform((v) => v === "true"), - ANALYTICS_ENABLED: z - .enum(["true", "false"]) - .default("true") - .transform((v) => v === "true"), - ANALYTICS_SAMPLE_RATE: z.coerce.number().min(0).max(1).default(1.0), - POSTHOG_API_KEY: z.string().default("phc_CVHjGivwWVzh76M5EjijTwP5LpiqWie3EbCzXU7w2Smy"), - POSTHOG_HOST: z.string().default("https://us.i.posthog.com"), - SENTRY_DSN: z - .string() - .default( - "https://2fd53fc3b3fdc59d02cac044a4f90b71@o4511263372738560.ingest.us.sentry.io/4511264620085248", - ), -}); +const envSchema = z + .object({ + PORT: z.coerce.number().default(1349), + AUTH_ENABLED: z + .enum(["true", "false"]) + .default("true") + .transform((v) => v === "true"), + DEFAULT_USERNAME: z.string().default("admin"), + DEFAULT_PASSWORD: z.string().default("admin"), + SKIP_MUST_CHANGE_PASSWORD: z + .enum(["true", "false"]) + .default("false") + .transform((v) => v === "true"), + STORAGE_MODE: z.enum(["local", "s3"]).default("local"), + FILE_MAX_AGE_HOURS: z.coerce.number().default(72), + CLEANUP_INTERVAL_MINUTES: z.coerce.number().default(60), + MAX_UPLOAD_SIZE_MB: z.coerce.number().default(0), + MAX_BATCH_SIZE: z.coerce.number().default(0), + CONCURRENT_JOBS: z.coerce.number().default(0), + MAX_MEGAPIXELS: z.coerce.number().default(0), + RATE_LIMIT_PER_MIN: z.coerce.number().default(1000), + DB_PATH: z.string().default("./data/snapotter.db"), + FILES_STORAGE_PATH: z.string().default("./data/files"), + WORKSPACE_PATH: z.string().default("./tmp/workspace"), + DEFAULT_THEME: z.enum(["light", "dark", "system"]).default("light"), + DEFAULT_LOCALE: z.string().default("en"), + CORS_ORIGIN: z.string().default(""), + MAX_USERS: z.coerce.number().default(0), + LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace"]).default("info"), + MAX_WORKER_THREADS: z.coerce.number().default(0), + PROCESSING_TIMEOUT_S: z.coerce.number().default(0), + MAX_PIPELINE_STEPS: z.coerce.number().default(0), + MAX_CANVAS_PIXELS: z.coerce.number().default(0), + MAX_SVG_SIZE_MB: z.coerce.number().default(0), + MAX_SPLIT_GRID: z.coerce.number().default(100), + MAX_STORAGE_PER_USER_MB: z.coerce.number().default(5000), + MAX_WORKSPACE_SIZE_GB: z.coerce.number().default(10), + MAX_PDF_PAGES: z.coerce.number().default(0), + SESSION_DURATION_HOURS: z.coerce.number().default(168), + LOGIN_ATTEMPT_LIMIT: z.coerce.number().default(30), + TRUST_PROXY: z + .enum(["true", "false"]) + .default("true") + .transform((v) => v === "true"), + OIDC_ENABLED: z + .enum(["true", "false"]) + .default("false") + .transform((v) => v === "true"), + OIDC_ISSUER_URL: z.string().default(""), + OIDC_CLIENT_ID: z.string().default(""), + OIDC_CLIENT_SECRET: z.string().default(""), + OIDC_SCOPES: z.string().default("openid profile email"), + OIDC_AUTO_CREATE_USERS: z + .enum(["true", "false"]) + .default("true") + .transform((v) => v === "true"), + OIDC_DEFAULT_ROLE: z.string().default("user"), + OIDC_AUTO_LINK_USERS: z + .enum(["true", "false"]) + .default("false") + .transform((v) => v === "true"), + OIDC_PROVIDER_NAME: z.string().default(""), + OIDC_CLOCK_TOLERANCE: z.coerce.number().min(0).max(300).default(30), + OIDC_USERNAME_CLAIM: z.string().default("preferred_username"), + EXTERNAL_URL: z.string().default(""), + COOKIE_SECRET: z.string().default(""), + ANALYTICS_ENABLED: z + .enum(["true", "false"]) + .default("true") + .transform((v) => v === "true"), + ANALYTICS_SAMPLE_RATE: z.coerce.number().min(0).max(1).default(1.0), + POSTHOG_API_KEY: z.string().default("phc_CVHjGivwWVzh76M5EjijTwP5LpiqWie3EbCzXU7w2Smy"), + POSTHOG_HOST: z.string().default("https://us.i.posthog.com"), + SENTRY_DSN: z + .string() + .default( + "https://2fd53fc3b3fdc59d02cac044a4f90b71@o4511263372738560.ingest.us.sentry.io/4511264620085248", + ), + }) + .superRefine((data, ctx) => { + if (data.OIDC_ENABLED) { + if (!data.OIDC_ISSUER_URL) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "OIDC_ISSUER_URL is required when OIDC_ENABLED=true", + path: ["OIDC_ISSUER_URL"], + }); + } + if (!data.OIDC_CLIENT_ID) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "OIDC_CLIENT_ID is required when OIDC_ENABLED=true", + path: ["OIDC_CLIENT_ID"], + }); + } + if (!data.OIDC_CLIENT_SECRET) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "OIDC_CLIENT_SECRET is required when OIDC_ENABLED=true", + path: ["OIDC_CLIENT_SECRET"], + }); + } + if (!data.EXTERNAL_URL) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "EXTERNAL_URL is required when OIDC_ENABLED=true", + path: ["EXTERNAL_URL"], + }); + } + } + }); export type Env = z.infer; diff --git a/apps/api/src/lib/file-validation.ts b/apps/api/src/lib/file-validation.ts index 65e95160..b18465f7 100644 --- a/apps/api/src/lib/file-validation.ts +++ b/apps/api/src/lib/file-validation.ts @@ -229,10 +229,12 @@ export async function validateImageBuffer( detectedFormat = "tga"; } - // SVGZ: gzip-compressed SVG, detected by extension + gzip magic + // SVGZ: gzip-compressed SVG, detected by extension + gzip magic. + // Return early because Sharp cannot read compressed SVGZ directly; + // decompression happens later in the route pipeline. if (!detectedFormat && ext === "svgz") { if (buffer.length >= 2 && buffer[0] === 0x1f && buffer[1] === 0x8b) { - detectedFormat = "svg"; + return { valid: true, format: "svg", width: 0, height: 0 }; } } diff --git a/apps/api/src/plugins/auth.ts b/apps/api/src/plugins/auth.ts index 2330b068..9f548cd8 100644 --- a/apps/api/src/plugins/auth.ts +++ b/apps/api/src/plugins/auth.ts @@ -130,7 +130,7 @@ export function requireAdmin(request: FastifyRequest, reply: FastifyReply): Auth const SESSION_DURATION_MS = env.SESSION_DURATION_HOURS * 60 * 60 * 1000; -function createSessionToken(): string { +export function createSessionToken(): string { return randomUUID(); } @@ -205,7 +205,7 @@ export async function authRoutes(app: FastifyInstance): Promise { .where(eq(schema.users.username, body.username)) .get(); - if (!user) { + if (!user || !user.passwordHash) { auditLog(request.log, "LOGIN_FAILED", { username: body.username, reason: "unknown_user" }); return reply.status(401).send({ error: "Invalid credentials" }); } @@ -254,11 +254,40 @@ export async function authRoutes(app: FastifyInstance): Promise { app.post("/api/auth/logout", async (request: FastifyRequest, reply: FastifyReply) => { const token = extractToken(request); const user = getAuthUser(request); + let logoutUrl: string | undefined; + if (token) { + const session = db.select().from(schema.sessions).where(eq(schema.sessions.id, token)).get(); + + if (session?.idToken && env.OIDC_ENABLED) { + try { + const { getOidcEndSessionEndpoint } = await import("./oidc.js"); + const endSessionEndpoint = getOidcEndSessionEndpoint(); + if (endSessionEndpoint) { + const params = new URLSearchParams({ + id_token_hint: session.idToken, + post_logout_redirect_uri: `${env.EXTERNAL_URL}/login`, + }); + logoutUrl = `${endSessionEndpoint}?${params.toString()}`; + } + } catch { + // OIDC plugin not loaded or discovery not cached + } + } + db.delete(schema.sessions).where(eq(schema.sessions.id, token)).run(); } + + // Clear the session cookie + const cookieReply = reply as FastifyReply & { + clearCookie?: (name: string, opts: Record) => void; + }; + if (typeof cookieReply.clearCookie === "function") { + cookieReply.clearCookie("snapotter-session", { path: "/" }); + } + auditLog(request.log, "LOGOUT", { userId: user?.id }); - return reply.send({ ok: true }); + return reply.send({ ok: true, ...(logoutUrl && { logoutUrl }) }); }); // GET /api/auth/session @@ -306,6 +335,11 @@ export async function authRoutes(app: FastifyInstance): Promise { role: user.role, mustChangePassword: env.SKIP_MUST_CHANGE_PASSWORD ? false : user.mustChangePassword, permissions: getPermissions(user.role), + authProvider: user.authProvider ?? "local", + loginMethod: session.idToken ? "oidc" : "local", + email: user.email ?? null, + hasLocalPassword: !!user.passwordHash, + hasOidcLink: !!user.externalId, analyticsEnabled: user.analyticsEnabled ?? null, analyticsConsentShownAt: user.analyticsConsentShownAt?.getTime() ?? null, analyticsConsentRemindAt: user.analyticsConsentRemindAt?.getTime() ?? null, @@ -342,6 +376,13 @@ export async function authRoutes(app: FastifyInstance): Promise { return reply.status(404).send({ error: "User not found", code: "NOT_FOUND" }); } + if (!user.passwordHash) { + return reply.status(400).send({ + error: "Password changes are managed by your identity provider.", + code: "OIDC_NO_PASSWORD", + }); + } + const valid = await verifyPassword(body.currentPassword, user.passwordHash); if (!valid) { return reply @@ -388,6 +429,10 @@ export async function authRoutes(app: FastifyInstance): Promise { username: schema.users.username, role: schema.users.role, team: schema.users.team, + authProvider: schema.users.authProvider, + email: schema.users.email, + externalId: schema.users.externalId, + passwordHash: schema.users.passwordHash, createdAt: schema.users.createdAt, }) .from(schema.users) @@ -399,8 +444,14 @@ export async function authRoutes(app: FastifyInstance): Promise { return reply.send({ users: users.map((u) => ({ - ...u, + id: u.id, + username: u.username, + role: u.role, team: teamNameById.get(u.team) ?? u.team, + authProvider: u.authProvider ?? "local", + email: u.email ?? null, + hasLocalPassword: !!u.passwordHash, + hasOidcLink: !!u.externalId, createdAt: u.createdAt.toISOString(), })), maxUsers: MAX_USERS, @@ -691,6 +742,13 @@ export async function authRoutes(app: FastifyInstance): Promise { return reply.status(404).send({ error: "User not found", code: "NOT_FOUND" }); } + if (!user.passwordHash) { + return reply.status(400).send({ + error: "Cannot reset password for OIDC user.", + code: "OIDC_NO_PASSWORD", + }); + } + const newHash = await hashPassword(body.newPassword); db.update(schema.users) @@ -756,11 +814,14 @@ export async function authRoutes(app: FastifyInstance): Promise { // ── Token extraction ─────────────────────────────────────────────── function extractToken(request: FastifyRequest): string | null { - // Check Authorization header: "Bearer " const authHeader = request.headers.authorization; if (authHeader?.startsWith("Bearer ")) { return authHeader.slice(7); } + const cookies = (request as FastifyRequest & { cookies?: Record }).cookies; + if (cookies?.["snapotter-session"]) { + return cookies["snapotter-session"]; + } return null; } diff --git a/apps/api/src/plugins/oidc.ts b/apps/api/src/plugins/oidc.ts new file mode 100644 index 00000000..3b4a95ee --- /dev/null +++ b/apps/api/src/plugins/oidc.ts @@ -0,0 +1,410 @@ +import { randomUUID } from "node:crypto"; +import type {} from "@fastify/cookie"; +import { eq, sql } from "drizzle-orm"; +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import * as oidc from "openid-client"; +import { env } from "../config.js"; +import { db, schema } from "../db/index.js"; +import { auditLog } from "../lib/audit.js"; +import { createSessionToken } from "./auth.js"; + +// ── Types ───────────────────────────────────────────────────────── + +interface OidcStateCookie { + state: string; + nonce: string; + codeVerifier: string; +} + +// ── Lazy Discovery Cache ────────────────────────────────────────── + +const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours + +let cachedConfig: { config: oidc.Configuration; cachedAt: number } | null = null; + +async function getOrDiscoverConfig(): Promise { + if (cachedConfig && Date.now() - cachedConfig.cachedAt < CACHE_TTL_MS) { + return cachedConfig.config; + } + + const issuerUrl = new URL(env.OIDC_ISSUER_URL); + const config = await oidc.discovery( + issuerUrl, + env.OIDC_CLIENT_ID, + env.OIDC_CLIENT_SECRET, + undefined, + { + execute: isSecure() ? undefined : [oidc.allowInsecureRequests], + }, + ); + + cachedConfig = { config, cachedAt: Date.now() }; + return config; +} + +/** + * Returns the cached end_session_endpoint for RP-initiated logout, + * or null if OIDC discovery has not been completed yet. + */ +export function getOidcEndSessionEndpoint(): string | null { + if (!cachedConfig) return null; + const metadata = cachedConfig.config.serverMetadata(); + return metadata.end_session_endpoint ?? null; +} + +// ── Username helpers ────────────────────────────────────────────── + +function deriveUsername(claims: Record): string { + const claimKey = env.OIDC_USERNAME_CLAIM; + + // 1. Try the configured claim + if (claimKey && typeof claims[claimKey] === "string" && (claims[claimKey] as string).length > 0) { + return claims[claimKey] as string; + } + + // 2. Try preferred_username (if different from configured claim) + if ( + claimKey !== "preferred_username" && + typeof claims.preferred_username === "string" && + claims.preferred_username.length > 0 + ) { + return claims.preferred_username; + } + + // 3. Email local part + if (typeof claims.email === "string" && claims.email.includes("@")) { + return claims.email.split("@")[0]; + } + + // 4. Name + if (typeof claims.name === "string" && claims.name.length > 0) { + return claims.name; + } + + // 5. Subject (always present) + return claims.sub as string; +} + +function sanitizeUsername(raw: string): string { + let sanitized = raw + .toLowerCase() + .replace(/[^a-z0-9_.-]/g, "_") + .replace(/_{2,}/g, "_") + .replace(/^[_.-]+|[_.-]+$/g, ""); + + // Enforce 3-50 char limit (truncate to 46 to leave room for collision suffix) + if (sanitized.length > 46) { + sanitized = sanitized.slice(0, 46); + } + if (sanitized.length < 3) { + sanitized = sanitized.padEnd(3, "_"); + } + + return sanitized; +} + +function findUniqueUsername(base: string): string { + const existing = db + .select({ username: schema.users.username }) + .from(schema.users) + .where(eq(schema.users.username, base)) + .get(); + + if (!existing) return base; + + for (let i = 2; i <= 1000; i++) { + const candidate = `${base}_${i}`; + const taken = db + .select({ username: schema.users.username }) + .from(schema.users) + .where(eq(schema.users.username, candidate)) + .get(); + if (!taken) return candidate; + } + + // Extremely unlikely fallback + return `${base}_${Date.now()}`; +} + +// ── Helpers ─────────────────────────────────────────────────────── + +function isSecure(): boolean { + return env.EXTERNAL_URL.startsWith("https"); +} + +const SESSION_DURATION_MS = env.SESSION_DURATION_HOURS * 60 * 60 * 1000; + +function redirectToLogin(reply: FastifyReply, errorCode: string): void { + reply.redirect(`/login?error=${errorCode}`); +} + +// ── OIDC Routes ─────────────────────────────────────────────────── + +export async function oidcRoutes(app: FastifyInstance): Promise { + if (!env.OIDC_ENABLED) return; + + // GET /api/auth/oidc/login + app.get("/api/auth/oidc/login", async (request: FastifyRequest, reply: FastifyReply) => { + let config: oidc.Configuration; + try { + config = await getOrDiscoverConfig(); + } catch (err) { + request.log.error({ err }, "OIDC discovery failed"); + return redirectToLogin(reply, "oidc_provider_unreachable"); + } + + const state = oidc.randomState(); + const nonce = oidc.randomNonce(); + const codeVerifier = oidc.randomPKCECodeVerifier(); + const codeChallenge = await oidc.calculatePKCECodeChallenge(codeVerifier); + + const redirectUri = `${env.EXTERNAL_URL}/api/auth/oidc/callback`; + + // Store OIDC state in a signed cookie + const statePayload: OidcStateCookie = { state, nonce, codeVerifier }; + const cookieValue = reply.signCookie(JSON.stringify(statePayload)); + + reply.setCookie("oidc-state", cookieValue, { + httpOnly: true, + sameSite: "lax", + secure: isSecure(), + path: "/api/auth/oidc", + maxAge: 600, // 10 minutes + signed: false, // already signed manually + }); + + const authorizationUrl = oidc.buildAuthorizationUrl(config, { + redirect_uri: redirectUri, + scope: env.OIDC_SCOPES, + state, + nonce, + code_challenge: codeChallenge, + code_challenge_method: "S256", + }); + + return reply.redirect(authorizationUrl.href); + }); + + // GET /api/auth/oidc/callback + app.get("/api/auth/oidc/callback", async (request: FastifyRequest, reply: FastifyReply) => { + // 1. Validate state from signed cookie + const rawCookie = request.cookies?.["oidc-state"]; + if (!rawCookie) { + request.log.warn("OIDC callback: missing state cookie"); + return redirectToLogin(reply, "oidc_session_expired"); + } + + // Clear the cookie immediately + reply.clearCookie("oidc-state", { + path: "/api/auth/oidc", + httpOnly: true, + sameSite: "lax", + secure: isSecure(), + }); + + const unsigned = request.unsignCookie(rawCookie); + if (!unsigned.valid || !unsigned.value) { + request.log.warn("OIDC callback: invalid cookie signature"); + return redirectToLogin(reply, "oidc_session_expired"); + } + + let storedState: OidcStateCookie; + try { + storedState = JSON.parse(unsigned.value) as OidcStateCookie; + } catch { + request.log.warn("OIDC callback: malformed state cookie"); + return redirectToLogin(reply, "oidc_session_expired"); + } + + // Validate state parameter matches + const query = request.query as Record; + if (query.state !== storedState.state) { + request.log.warn("OIDC callback: state mismatch"); + return redirectToLogin(reply, "oidc_session_expired"); + } + + // Check for error response from the IdP + if (query.error) { + request.log.warn( + { error: query.error, description: query.error_description }, + "OIDC IdP returned error", + ); + auditLog(request.log, "OIDC_LOGIN_FAILED", { reason: query.error }); + return redirectToLogin(reply, "oidc_auth_failed"); + } + + // 2. Exchange authorization code for tokens + let config: oidc.Configuration; + try { + config = await getOrDiscoverConfig(); + } catch (err) { + request.log.error({ err }, "OIDC discovery failed during callback"); + return redirectToLogin(reply, "oidc_provider_unreachable"); + } + + let tokenResponse: Awaited>; + try { + const callbackUrl = new URL(`${env.EXTERNAL_URL}/api/auth/oidc/callback`); + // Copy the query parameters from the actual request + for (const [key, value] of Object.entries(query)) { + callbackUrl.searchParams.set(key, value); + } + + tokenResponse = await oidc.authorizationCodeGrant(config, callbackUrl, { + pkceCodeVerifier: storedState.codeVerifier, + expectedNonce: storedState.nonce, + expectedState: storedState.state, + }); + } catch (err) { + request.log.error({ err }, "OIDC token exchange failed"); + auditLog(request.log, "OIDC_LOGIN_FAILED", { reason: "token_exchange_failed" }); + return redirectToLogin(reply, "oidc_auth_failed"); + } + + // 3. Extract claims from ID token + const claims = tokenResponse.claims(); + if (!claims) { + request.log.error("OIDC callback: no ID token claims"); + auditLog(request.log, "OIDC_LOGIN_FAILED", { reason: "no_id_token" }); + return redirectToLogin(reply, "oidc_auth_failed"); + } + + const sub = claims.sub; + const email = typeof claims.email === "string" ? claims.email : undefined; + const emailVerified = claims.email_verified === true; + const rawUsername = deriveUsername(claims as Record); + const username = sanitizeUsername(rawUsername); + const idToken = tokenResponse.id_token ?? null; + + // 4. User resolution + let userId: string | null = null; + + // 4a. Find by externalId (OIDC subject) + const existingByExtId = db + .select() + .from(schema.users) + .where(eq(schema.users.externalId, sub)) + .get(); + + if (existingByExtId) { + userId = existingByExtId.id; + // Update email if changed + if (email && email !== existingByExtId.email) { + db.update(schema.users) + .set({ email, updatedAt: new Date() }) + .where(eq(schema.users.id, existingByExtId.id)) + .run(); + } + } + + // 4b. Auto-link: match by email + if (!userId && env.OIDC_AUTO_LINK_USERS && email && emailVerified) { + const existingByEmail = db + .select() + .from(schema.users) + .where(eq(schema.users.email, email)) + .get(); + + if (existingByEmail) { + db.update(schema.users) + .set({ + externalId: sub, + updatedAt: new Date(), + }) + .where(eq(schema.users.id, existingByEmail.id)) + .run(); + userId = existingByEmail.id; + auditLog(request.log, "OIDC_USER_LINKED", { + userId: existingByEmail.id, + username: existingByEmail.username, + email, + }); + } + } + + // 4c. Auto-create + if (!userId && env.OIDC_AUTO_CREATE_USERS) { + // Check user limit + if (env.MAX_USERS > 0) { + const countResult = db.select({ count: sql`COUNT(*)` }).from(schema.users).get(); + if (countResult && countResult.count >= env.MAX_USERS) { + request.log.warn("OIDC auto-create blocked: user limit reached"); + return redirectToLogin(reply, "oidc_user_limit_reached"); + } + } + + const uniqueUsername = findUniqueUsername(username); + const newUserId = randomUUID(); + + // Look up the default team + const defaultTeam = db + .select() + .from(schema.teams) + .where(eq(schema.teams.name, "Default")) + .get(); + const teamId = defaultTeam?.id ?? "default-team-00000000"; + + db.insert(schema.users) + .values({ + id: newUserId, + username: uniqueUsername, + passwordHash: null, + role: env.OIDC_DEFAULT_ROLE, + team: teamId, + mustChangePassword: false, + authProvider: "oidc", + externalId: sub, + email: email ?? null, + }) + .run(); + + userId = newUserId; + auditLog(request.log, "OIDC_USER_CREATED", { + userId: newUserId, + username: uniqueUsername, + email, + role: env.OIDC_DEFAULT_ROLE, + }); + } + + // 4d. No user found and no auto-create + if (!userId) { + request.log.warn({ sub, email }, "OIDC user not authorized"); + auditLog(request.log, "OIDC_LOGIN_FAILED", { reason: "user_not_authorized", sub }); + return redirectToLogin(reply, "oidc_user_not_authorized"); + } + + // 5. Create session + const token = createSessionToken(); + const expiresAt = new Date(Date.now() + SESSION_DURATION_MS); + + db.insert(schema.sessions) + .values({ + id: token, + userId, + expiresAt, + idToken, + }) + .run(); + + // Fetch the user for audit logging + const user = db.select().from(schema.users).where(eq(schema.users.id, userId)).get(); + + auditLog(request.log, "OIDC_LOGIN_SUCCESS", { + userId, + username: user?.username ?? username, + }); + + // 6. Set session cookie + reply.setCookie("snapotter-session", token, { + httpOnly: true, + sameSite: "strict", + secure: isSecure(), + path: "/", + maxAge: env.SESSION_DURATION_HOURS * 3600, + }); + + // 7. Redirect to app + return reply.redirect("/"); + }); +} diff --git a/apps/api/src/routes/tools/watermark-image.ts b/apps/api/src/routes/tools/watermark-image.ts index 810482ad..f9babe2f 100644 --- a/apps/api/src/routes/tools/watermark-image.ts +++ b/apps/api/src/routes/tools/watermark-image.ts @@ -23,7 +23,7 @@ export function registerWatermarkImage(app: FastifyInstance) { let mainBuffer: Buffer | null = null; let watermarkBuffer: Buffer | null = null; let filename = "image"; - let wmFilename = "watermark"; + let watermarkFilename = "watermark"; let settingsRaw: string | null = null; try { @@ -37,7 +37,7 @@ export function registerWatermarkImage(app: FastifyInstance) { const buf = Buffer.concat(chunks); if (part.fieldname === "watermark") { watermarkBuffer = buf; - wmFilename = sanitizeFilename(part.filename ?? "watermark"); + watermarkFilename = sanitizeFilename(part.filename ?? "watermark"); } else { mainBuffer = buf; filename = sanitizeFilename(part.filename ?? "image"); @@ -119,7 +119,7 @@ export function registerWatermarkImage(app: FastifyInstance) { } mainBuffer = await autoOrient(mainBuffer); - const valWm = await validateImageBuffer(watermarkBuffer, wmFilename); + const valWm = await validateImageBuffer(watermarkBuffer, watermarkFilename); if (!valWm.valid) { return reply.status(400).send({ error: `Invalid watermark image: ${valWm.reason}` }); } diff --git a/apps/docs/.vitepress/config.mts b/apps/docs/.vitepress/config.mts index 9cac7d4e..9a37bb00 100644 --- a/apps/docs/.vitepress/config.mts +++ b/apps/docs/.vitepress/config.mts @@ -81,6 +81,7 @@ export default defineConfig({ { text: "Getting started", link: "/guide/getting-started" }, { text: "Architecture", link: "/guide/architecture" }, { text: "Configuration", link: "/guide/configuration" }, + { text: "OIDC / SSO", link: "/guide/oidc" }, { text: "Database", link: "/guide/database" }, { text: "Deployment", link: "/guide/deployment" }, { text: "Supported Formats", link: "/guide/supported-formats" }, diff --git a/apps/docs/guide/oidc.md b/apps/docs/guide/oidc.md new file mode 100644 index 00000000..a7bd2b5f --- /dev/null +++ b/apps/docs/guide/oidc.md @@ -0,0 +1,159 @@ +# OIDC / Single Sign-On + +SnapOtter supports OpenID Connect (OIDC) for single sign-on. Users can log in with an external identity provider such as Keycloak, Authentik, or Google instead of (or alongside) local username/password authentication. + +## Quick start + +Add these environment variables to your `docker-compose.yml`: + +```yaml +services: + SnapOtter: + image: snapotter/snapotter:latest + environment: + EXTERNAL_URL: "https://photos.example.com" + OIDC_ENABLED: "true" + OIDC_ISSUER_URL: "https://auth.example.com/realms/myrealm" + OIDC_CLIENT_ID: "snapotter" + OIDC_CLIENT_SECRET: "your-secret-here" +``` + +The redirect URI for your provider is always: + +``` +${EXTERNAL_URL}/api/auth/oidc/callback +``` + +For example, if `EXTERNAL_URL` is `https://photos.example.com`, configure your provider's redirect URI as `https://photos.example.com/api/auth/oidc/callback`. + +## Configuration reference + +| Variable | Default | Description | +|---|---|---| +| `OIDC_ENABLED` | `false` | Enable OIDC login. A "Sign in with SSO" button appears on the login page. | +| `OIDC_ISSUER_URL` | | Provider's issuer URL. Must support OIDC Discovery (`/.well-known/openid-configuration`). | +| `OIDC_CLIENT_ID` | | OAuth client ID registered with your provider. | +| `OIDC_CLIENT_SECRET` | | OAuth client secret. | +| `OIDC_SCOPES` | `openid profile email` | Space-separated list of scopes to request. | +| `OIDC_AUTO_CREATE_USERS` | `true` | Automatically create a local user account on first OIDC login. | +| `OIDC_DEFAULT_ROLE` | `user` | Role assigned to auto-created OIDC users. One of `admin`, `editor`, or `user`. | +| `OIDC_AUTO_LINK_USERS` | `false` | Link an OIDC identity to an existing local user if the email address matches. | +| `OIDC_PROVIDER_NAME` | | Display name shown on the login button (e.g. "Keycloak", "Google"). If empty, the button says "SSO". | +| `OIDC_CLOCK_TOLERANCE` | `30` | Clock skew tolerance in seconds for token validation. | +| `OIDC_USERNAME_CLAIM` | `preferred_username` | ID token claim used as the username for new accounts. | +| `EXTERNAL_URL` | | The public URL where SnapOtter is reachable. Required for OIDC to build the correct redirect URI. | +| `COOKIE_SECRET` | auto-generated | Secret for signing session cookies. Set this explicitly when running multiple replicas. | + +## Provider guides + +### Keycloak + +1. Create a new realm (or use an existing one). +2. Go to **Clients** and create a new client: + - **Client ID**: `snapotter` + - **Client authentication**: On (confidential) + - **Authentication flow**: Standard flow (Authorization Code) +3. Under the client's **Settings** tab, set **Valid redirect URIs** to your callback URL (e.g. `https://photos.example.com/api/auth/oidc/callback`). +4. Copy the **Client secret** from the **Credentials** tab. +5. Set `OIDC_ISSUER_URL` to `https://keycloak.example.com/realms/your-realm`. + +### Authentik + +1. In the admin interface, go to **Applications > Providers** and create a new **OAuth2/OpenID Provider**. + - **Client type**: Confidential + - **Redirect URIs**: Your callback URL + - **Signing key**: Select an existing key or create one +2. Create an **Application** and link it to the provider. +3. Copy the **Client ID** and **Client Secret** from the provider settings. +4. Set `OIDC_ISSUER_URL` to `https://authentik.example.com/application/o/snapotter/` (the trailing slash matters). + +### Google + +1. Go to the [Google Cloud Console](https://console.cloud.google.com/). +2. Create a project (or select an existing one). +3. Navigate to **APIs & Services > OAuth consent screen** and configure it. +4. Go to **APIs & Services > Credentials** and create an **OAuth 2.0 Client ID**: + - **Application type**: Web application + - **Authorized redirect URIs**: Your callback URL +5. Copy the **Client ID** and **Client secret**. +6. Set `OIDC_ISSUER_URL` to `https://accounts.google.com`. +7. Set `OIDC_USERNAME_CLAIM` to `email` (Google does not provide `preferred_username`). + +## User provisioning + +### Auto-create + +When `OIDC_AUTO_CREATE_USERS` is `true` (the default), a local user account is created the first time someone logs in via OIDC. The username is taken from the claim specified by `OIDC_USERNAME_CLAIM`, and the role is set to `OIDC_DEFAULT_ROLE`. + +If a username collision occurs, a numeric suffix is appended (e.g. `jane` becomes `jane_2`). + +### Auto-link + +When `OIDC_AUTO_LINK_USERS` is `true`, SnapOtter links an OIDC identity to an existing local account if the email addresses match. This is useful when you have pre-created user accounts and want them to start using SSO without losing their data. + +::: warning +Only enable auto-link if you trust your OIDC provider to verify email addresses. An unverified email could allow someone to take over another user's account. +::: + +### Disabling local login + +OIDC does not disable local username/password login. Both methods remain available. Admins can still log in with local credentials if the OIDC provider is unreachable. + +## Self-signed certificates + +If your OIDC provider uses a self-signed or private CA certificate, mount the CA bundle into the container and point `NODE_EXTRA_CA_CERTS` to it: + +```yaml +services: + SnapOtter: + image: snapotter/snapotter:latest + volumes: + - ./my-ca.pem:/etc/ssl/certs/custom-ca.pem:ro + environment: + NODE_EXTRA_CA_CERTS: /etc/ssl/certs/custom-ca.pem + OIDC_ENABLED: "true" + OIDC_ISSUER_URL: "https://auth.internal.example.com/realms/myrealm" + OIDC_CLIENT_ID: "snapotter" + OIDC_CLIENT_SECRET: "your-secret-here" +``` + +::: danger +Do not set `NODE_TLS_REJECT_UNAUTHORIZED=0`. This disables all TLS verification and is a security risk. +::: + +## Troubleshooting + +### Redirect URI mismatch + +The most common error. Check for these differences between what your provider expects and what SnapOtter sends: + +- `http` vs `https` - the scheme must match exactly +- Trailing slash - some providers are strict about this +- Port number - include the port if it is non-standard +- Path - must be `/api/auth/oidc/callback` + +Double-check `EXTERNAL_URL`. It must match the URL users type in their browser. + +### UNABLE_TO_VERIFY_LEAF_SIGNATURE + +The OIDC provider is using a certificate that Node.js does not trust. See [Self-signed certificates](#self-signed-certificates) above. + +### Clock skew errors + +If your server clock and the OIDC provider clock are out of sync, token validation may fail. Increase `OIDC_CLOCK_TOLERANCE` (default is 30 seconds). A better fix is to run NTP on both machines. + +### "OIDC provider unreachable" + +SnapOtter fetches the provider's discovery document at startup and during login. Check: + +- DNS resolution from inside the Docker container (`docker exec snapotter nslookup auth.example.com`) +- Firewall rules between the container and the provider +- The `OIDC_ISSUER_URL` value - it must be reachable from the server, not just from your browser + +### Missing claims + +If usernames or emails are empty after login, your provider may not be returning the expected claims. Verify: + +- The scopes configured in `OIDC_SCOPES` include `profile` and `email` +- The provider is configured to include the claim specified in `OIDC_USERNAME_CLAIM` in the ID token +- Some providers require explicit mapper/scope configuration to release claims diff --git a/apps/web/src/components/settings/settings-dialog.tsx b/apps/web/src/components/settings/settings-dialog.tsx index 9e9c5ce5..67d3cb30 100644 --- a/apps/web/src/components/settings/settings-dialog.tsx +++ b/apps/web/src/components/settings/settings-dialog.tsx @@ -28,7 +28,7 @@ import { } from "lucide-react"; import { Fragment, useCallback, useEffect, useMemo, useState } from "react"; import { useAuth } from "@/hooks/use-auth"; -import { apiDelete, apiGet, apiPost, apiPut, clearToken } from "@/lib/api"; +import { apiDelete, apiGet, apiPost, apiPut, clearToken, formatHeaders } from "@/lib/api"; import { cn, copyToClipboard } from "@/lib/utils"; import { useAnalyticsStore } from "@/stores/analytics-store"; import { useSettingsStore } from "@/stores/settings-store"; @@ -194,6 +194,10 @@ interface UserEntry { username: string; role: string; team: string; + authProvider?: string; + email?: string; + hasLocalPassword?: boolean; + hasOidcLink?: boolean; createdAt: string; } @@ -235,10 +239,25 @@ function GeneralSection() { ]).finally(() => setLoading(false)); }, []); - const handleLogout = () => { - clearToken(); - localStorage.removeItem("snapotter-username"); - window.location.href = "/login"; + const handleLogout = async () => { + try { + const res = await fetch("/api/auth/logout", { + method: "POST", + headers: formatHeaders(), + }); + const data = await res.json().catch(() => ({})); + clearToken(); + localStorage.removeItem("snapotter-username"); + if (data.logoutUrl) { + window.location.href = data.logoutUrl; + } else { + window.location.href = "/login"; + } + } catch { + clearToken(); + localStorage.removeItem("snapotter-username"); + window.location.href = "/login"; + } }; const handleSave = useCallback(async () => { @@ -1078,6 +1097,16 @@ function PeopleSection() { {u.username.charAt(0).toUpperCase()} {u.username} + {u.hasOidcLink && u.hasLocalPassword !== false && ( + + Local + OIDC + + )} + {u.hasOidcLink && u.hasLocalPassword === false && ( + + OIDC + + )} {/* Role badge */} @@ -1130,18 +1159,20 @@ function PeopleSection() { Edit Role / Team - + {u.hasLocalPassword !== false && ( + + )}
+ {oidcEnabled && ( + <> +
diff --git a/docker/Dockerfile b/docker/Dockerfile index 1b58a8a8..d7822817 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -273,7 +273,10 @@ ENV PORT=1349 \ SESSION_DURATION_HOURS=168 \ LOGIN_ATTEMPT_LIMIT=500 \ LOG_LEVEL=info \ - TRUST_PROXY=true + TRUST_PROXY=true \ + OIDC_ENABLED=false \ + EXTERNAL_URL= \ + COOKIE_SECRET= # NVIDIA Container Toolkit env vars (harmless on non-GPU systems) ENV NVIDIA_VISIBLE_DEVICES=all \ diff --git a/docker/docker-compose-gpu.yml b/docker/docker-compose-gpu.yml index 1a6a86dc..1d82bb6f 100644 --- a/docker/docker-compose-gpu.yml +++ b/docker/docker-compose-gpu.yml @@ -37,6 +37,20 @@ services: - MAX_USERS=${MAX_USERS:-0} - SESSION_DURATION_HOURS=${SESSION_DURATION_HOURS:-168} - TRUST_PROXY=${TRUST_PROXY:-true} + # OIDC Authentication (optional) + # - EXTERNAL_URL=https://photos.example.com + # - OIDC_ENABLED=false + # - OIDC_ISSUER_URL= + # - OIDC_CLIENT_ID= + # - OIDC_CLIENT_SECRET= + # - OIDC_SCOPES=openid profile email + # - OIDC_AUTO_CREATE_USERS=true + # - OIDC_DEFAULT_ROLE=user + # - OIDC_AUTO_LINK_USERS=false + # - OIDC_PROVIDER_NAME= + # - OIDC_USERNAME_CLAIM=preferred_username + # - OIDC_CLOCK_TOLERANCE=30 + # - COOKIE_SECRET= restart: unless-stopped # --- Security hardening --- mem_limit: 8g diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 5f32e9c4..2717eec3 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -36,6 +36,20 @@ services: - MAX_USERS=${MAX_USERS:-0} - SESSION_DURATION_HOURS=${SESSION_DURATION_HOURS:-168} - TRUST_PROXY=${TRUST_PROXY:-true} + # OIDC Authentication (optional) + # - EXTERNAL_URL=https://photos.example.com + # - OIDC_ENABLED=false + # - OIDC_ISSUER_URL= + # - OIDC_CLIENT_ID= + # - OIDC_CLIENT_SECRET= + # - OIDC_SCOPES=openid profile email + # - OIDC_AUTO_CREATE_USERS=true + # - OIDC_DEFAULT_ROLE=user + # - OIDC_AUTO_LINK_USERS=false + # - OIDC_PROVIDER_NAME= + # - OIDC_USERNAME_CLAIM=preferred_username + # - OIDC_CLOCK_TOLERANCE=30 + # - COOKIE_SECRET= restart: unless-stopped # --- Security hardening --- mem_limit: 4g diff --git a/packages/shared/src/i18n/en.ts b/packages/shared/src/i18n/en.ts index cb895f03..b6d2a749 100644 --- a/packages/shared/src/i18n/en.ts +++ b/packages/shared/src/i18n/en.ts @@ -300,6 +300,19 @@ export const en = { loggingIn: "Logging in...", invalidCredentials: "Invalid username or password", connectionError: "Connection error", + signInWith: "Sign in with {provider}", + signInWithSso: "Sign in with SSO", + or: "or", + methodLocal: "Local", + methodOidc: "OIDC", + methodBoth: "Local + OIDC", + oidcAuthFailed: "Authentication failed. Please try again.", + oidcProviderUnreachable: "Could not reach the identity provider. Please try again later.", + oidcSessionExpired: "Login session expired. Please try again.", + oidcUserNotAuthorized: + "Your account is not authorized to access this application. Contact your administrator.", + oidcUserLimitReached: "User limit reached. Contact your administrator.", + passwordManagedByProvider: "Password changes are managed by your identity provider.", }, pipeline: { title: "Automate", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 20ae1650..28ef2704 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -90,6 +90,9 @@ importers: apps/api: dependencies: + '@fastify/cookie': + specifier: ^11.0.2 + version: 11.0.2 '@fastify/cors': specifier: ^11.0.0 version: 11.2.0 @@ -147,6 +150,9 @@ importers: mupdf: specifier: ^1.27.0 version: 1.27.0 + openid-client: + specifier: ^6.8.4 + version: 6.8.4 opentype.js: specifier: ^2.0.0 version: 2.0.0 @@ -1364,6 +1370,9 @@ packages: '@fastify/busboy@3.2.0': resolution: {integrity: sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==} + '@fastify/cookie@11.0.2': + resolution: {integrity: sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==} + '@fastify/cors@11.2.0': resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==} @@ -4782,6 +4791,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + jpeg-js@0.4.4: resolution: {integrity: sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==} @@ -5267,11 +5279,6 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - nanoid@3.3.12: - resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - nanoid@5.1.7: resolution: {integrity: sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ==} engines: {node: ^18 || >=20} @@ -5417,6 +5424,9 @@ packages: - validate-npm-package-name - which + oauth4webapi@3.8.6: + resolution: {integrity: sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -5446,6 +5456,9 @@ packages: oniguruma-to-es@3.1.1: resolution: {integrity: sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==} + openid-client@6.8.4: + resolution: {integrity: sha512-QSw0BA08piujetEwfZsHoTrDpMEha7GDZDicQqVwX4u0ChCjefvjDB++TZ8BTg76UpwhzIQgdvvfgfl3HpCSAw==} + opentype.js@2.0.0: resolution: {integrity: sha512-kCyjv6xdDY1W/jLWZ/L3QhhTlKUqDZMQ5+Jdlw12b3dXkKNpYBqqlMMj0YDQPShWFTMwgZI1hG14kN3XUDSg/A==} hasBin: true @@ -6060,11 +6073,6 @@ packages: engines: {node: '>=10'} hasBin: true - semver@7.8.0: - resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} - engines: {node: '>=10'} - hasBin: true - set-blocking@2.0.0: resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} @@ -7625,6 +7633,11 @@ snapshots: '@fastify/busboy@3.2.0': {} + '@fastify/cookie@11.0.2': + dependencies: + cookie: 1.1.1 + fastify-plugin: 5.1.0 + '@fastify/cors@11.2.0': dependencies: fastify-plugin: 5.1.0 @@ -10892,7 +10905,7 @@ snapshots: '@petamoriken/float16': 3.9.3 debug: 4.4.3 env-paths: 3.0.0 - semver: 7.8.0 + semver: 7.7.4 shell-quote: 1.8.3 which: 4.0.0 transitivePeerDependencies: @@ -11219,6 +11232,8 @@ snapshots: jiti@2.6.1: {} + jose@6.2.3: {} + jpeg-js@0.4.4: {} js-md5@0.8.3: {} @@ -11801,8 +11816,6 @@ snapshots: nanoid@3.3.11: {} - nanoid@3.3.12: {} - nanoid@5.1.7: {} napi-build-utils@2.0.0: {} @@ -11880,6 +11893,8 @@ snapshots: npm@11.12.0: {} + oauth4webapi@3.8.6: {} + object-assign@4.1.1: {} omggif@1.0.10: {} @@ -11908,6 +11923,11 @@ snapshots: regex: 6.1.0 regex-recursion: 6.0.2 + openid-client@6.8.4: + dependencies: + jose: 6.2.3 + oauth4webapi: 3.8.6 + opentype.js@2.0.0: {} p-each-series@3.0.0: {} @@ -12126,13 +12146,13 @@ snapshots: postcss@8.4.31: dependencies: - nanoid: 3.3.12 + nanoid: 3.3.11 picocolors: 1.1.1 source-map-js: 1.2.1 postcss@8.5.10: dependencies: - nanoid: 3.3.12 + nanoid: 3.3.11 picocolors: 1.1.1 source-map-js: 1.2.1 optional: true @@ -12586,9 +12606,6 @@ snapshots: semver@7.7.4: {} - semver@7.8.0: - optional: true - set-blocking@2.0.0: {} set-cookie-parser@2.7.2: {} @@ -12627,7 +12644,7 @@ snapshots: dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 - semver: 7.8.0 + semver: 7.7.4 optionalDependencies: '@img/sharp-darwin-arm64': 0.34.5 '@img/sharp-darwin-x64': 0.34.5 diff --git a/tests/integration/color-palette.test.ts b/tests/integration/color-palette.test.ts index 62caf5bd..10a6600b 100644 --- a/tests/integration/color-palette.test.ts +++ b/tests/integration/color-palette.test.ts @@ -158,7 +158,7 @@ describe("Error handling", () => { expect(result.error).toBeDefined(); }); - it("returns 422 for corrupted image data", async () => { + it("returns 400 for corrupted image data", async () => { const badBuffer = Buffer.from("not an image at all"); const { body: payload, contentType } = makeFilePayload(badBuffer, "bad.png", "image/png"); const res = await app.inject({ @@ -170,7 +170,8 @@ describe("Error handling", () => { authorization: `Bearer ${adminToken}`, }, }); - expect([400, 422]).toContain(res.statusCode); + // validateImageBuffer catches corrupt data before processing + expect(res.statusCode).toBe(400); }); }); diff --git a/tests/integration/compare.test.ts b/tests/integration/compare.test.ts index 613fc250..8f39ca11 100644 --- a/tests/integration/compare.test.ts +++ b/tests/integration/compare.test.ts @@ -463,7 +463,7 @@ describe("Compare", () => { // ── Branch coverage: multipart parse error (lines 35-39) ──────────── - it("returns 422 when corrupt image data fails processing", async () => { + it("returns 400 when corrupt image data fails validation", async () => { // Create a buffer that looks like an image but corrupts Sharp const corruptBuffer = Buffer.from("not a real image content at all"); const { body, contentType } = createMultipartPayload([ @@ -481,10 +481,10 @@ describe("Compare", () => { body, }); - // Returns 400 (invalid image detected at validation) or 422 (processing failure) - expect([400, 422]).toContain(res.statusCode); + // validateImageBuffer catches corrupt data before processing + expect(res.statusCode).toBe(400); const result = JSON.parse(res.body); - expect(result.error).toMatch(/comparison failed|invalid.*image|unrecognized/i); + expect(result.error).toBeDefined(); }); // ── Branch coverage: 1x1 tiny images (line 117-121 area) ─────────── @@ -649,7 +649,7 @@ describe("Compare", () => { // ── Branch coverage: both corrupt images fail processing ─────────── - it("returns 422 when both images are corrupt", async () => { + it("returns 400 when both images are corrupt", async () => { const corrupt1 = Buffer.from("this is not an image"); const corrupt2 = Buffer.from("neither is this one"); const { body, contentType } = createMultipartPayload([ @@ -667,9 +667,10 @@ describe("Compare", () => { body, }); - expect([400, 422]).toContain(res.statusCode); + // validateImageBuffer catches corrupt data before processing + expect(res.statusCode).toBe(400); const result = JSON.parse(res.body); - expect(result.error).toMatch(/comparison failed|invalid.*image|unrecognized/i); + expect(result.error).toBeDefined(); }); // ── Branch coverage: HEIF content format input ───────────────────── diff --git a/tests/integration/oidc-auth.test.ts b/tests/integration/oidc-auth.test.ts new file mode 100644 index 00000000..013dc395 --- /dev/null +++ b/tests/integration/oidc-auth.test.ts @@ -0,0 +1,902 @@ +/** + * OIDC authentication integration tests. + * + * Strategy: + * - Tests that don't need OIDC routes (password guards, session fields, users + * list) create OIDC users directly in the DB and use the default test app. + * - Tests that need OIDC routes (config endpoint, login redirect) mutate the + * cached `env` object and spin up a separate Fastify instance with OIDC + * routes enabled. + */ + +import { randomUUID } from "node:crypto"; +import { createServer, type Server } from "node:http"; +import { eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { env } from "../../apps/api/src/config.js"; +import { db, schema } from "../../apps/api/src/db/index.js"; +import { buildTestApp, loginAsAdmin, type TestApp } from "./test-server.js"; + +// ── Helpers ────────────────────────────────────────────────────────── + +let testApp: TestApp; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +/** + * Insert an OIDC-only user directly into the DB (no passwordHash). + * Returns a session token for the user. + */ +function createOidcUser(opts: { username?: string; email?: string; role?: string } = {}): { + userId: string; + username: string; + sessionToken: string; +} { + const userId = randomUUID(); + const username = opts.username || `oidc_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; + + db.insert(schema.users) + .values({ + id: userId, + username, + passwordHash: null, + role: opts.role || "user", + team: "default-team-00000000", + mustChangePassword: false, + authProvider: "oidc", + externalId: `sub-${userId}`, + email: opts.email || `${username}@example.com`, + }) + .run(); + + // Create a session (simulates what the OIDC callback would do) + const sessionToken = randomUUID(); + db.insert(schema.sessions) + .values({ + id: sessionToken, + userId, + expiresAt: new Date(Date.now() + 3_600_000), + idToken: "mock-id-token-jwt", + }) + .run(); + + return { userId, username, sessionToken }; +} + +/** + * Insert a session for an existing user with custom options. + */ +function createOidcSession( + userId: string, + opts: { expiresAt?: Date; idToken?: string | null } = {}, +): string { + const sessionToken = randomUUID(); + db.insert(schema.sessions) + .values({ + id: sessionToken, + userId, + expiresAt: opts.expiresAt ?? new Date(Date.now() + 3_600_000), + idToken: opts.idToken ?? null, + }) + .run(); + return sessionToken; +} + +/** + * Insert a "hybrid" user -- has both a local password AND an OIDC link. + */ +function createHybridUser( + passwordHash: string, + opts: { username?: string; email?: string } = {}, +): { userId: string; username: string; sessionToken: string } { + const userId = randomUUID(); + const username = + opts.username || `hybrid_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; + + db.insert(schema.users) + .values({ + id: userId, + username, + passwordHash, + role: "user", + team: "default-team-00000000", + mustChangePassword: false, + authProvider: "oidc", + externalId: `sub-${userId}`, + email: opts.email || `${username}@example.com`, + }) + .run(); + + const sessionToken = randomUUID(); + db.insert(schema.sessions) + .values({ + id: sessionToken, + userId, + expiresAt: new Date(Date.now() + 3_600_000), + }) + .run(); + + return { userId, username, sessionToken }; +} + +// ===================================================================== +// SESSION RESPONSE FIELDS +// ===================================================================== +describe("Session response fields", () => { + it("returns OIDC fields for an OIDC user session", async () => { + const { sessionToken, username } = createOidcUser(); + + const res = await testApp.app.inject({ + method: "GET", + url: "/api/auth/session", + headers: { authorization: `Bearer ${sessionToken}` }, + }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.user.username).toBe(username); + expect(body.user.authProvider).toBe("oidc"); + expect(body.user.loginMethod).toBe("oidc"); + expect(body.user.hasLocalPassword).toBe(false); + expect(body.user.hasOidcLink).toBe(true); + expect(body.user.email).toMatch(/@example\.com$/); + }); + + it("returns local fields for a local user session", async () => { + // Admin is a local user + const res = await testApp.app.inject({ + method: "GET", + url: "/api/auth/session", + headers: { authorization: `Bearer ${adminToken}` }, + }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.user.authProvider).toBe("local"); + expect(body.user.loginMethod).toBe("local"); + expect(body.user.hasLocalPassword).toBe(true); + expect(body.user.hasOidcLink).toBe(false); + }); + + it("Bearer token still works for session check", async () => { + const res = await testApp.app.inject({ + method: "GET", + url: "/api/auth/session", + headers: { authorization: `Bearer ${adminToken}` }, + }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.user.id).toBeTruthy(); + expect(body.expiresAt).toBeTruthy(); + }); +}); + +// ===================================================================== +// PASSWORD GUARDS +// ===================================================================== +describe("Password guards for OIDC users", () => { + it("OIDC user (no passwordHash) cannot change password", async () => { + const { sessionToken } = createOidcUser(); + + const res = await testApp.app.inject({ + method: "POST", + url: "/api/auth/change-password", + headers: { authorization: `Bearer ${sessionToken}` }, + payload: { + currentPassword: "anything", + newPassword: "NewValid1", + }, + }); + + expect(res.statusCode).toBe(400); + const body = JSON.parse(res.body); + expect(body.code).toBe("OIDC_NO_PASSWORD"); + }); + + it("admin cannot reset password for OIDC user", async () => { + const { userId } = createOidcUser(); + + const res = await testApp.app.inject({ + method: "POST", + url: `/api/auth/users/${userId}/reset-password`, + headers: { authorization: `Bearer ${adminToken}` }, + payload: { newPassword: "NewValid1" }, + }); + + expect(res.statusCode).toBe(400); + const body = JSON.parse(res.body); + expect(body.code).toBe("OIDC_NO_PASSWORD"); + }); +}); + +// ===================================================================== +// USERS LIST +// ===================================================================== +describe("Users list includes OIDC fields", () => { + it("GET /api/auth/users includes authProvider, hasLocalPassword, hasOidcLink", async () => { + const { username: oidcUsername } = createOidcUser(); + + const res = await testApp.app.inject({ + method: "GET", + url: "/api/auth/users", + headers: { authorization: `Bearer ${adminToken}` }, + }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + + // Find the OIDC user we just created + const oidcEntry = body.users.find((u: any) => u.username === oidcUsername); + expect(oidcEntry).toBeDefined(); + expect(oidcEntry.authProvider).toBe("oidc"); + expect(oidcEntry.hasLocalPassword).toBe(false); + expect(oidcEntry.hasOidcLink).toBe(true); + expect(oidcEntry.email).toMatch(/@example\.com$/); + + // The admin user should be local + const adminEntry = body.users.find((u: any) => u.username === "admin"); + expect(adminEntry).toBeDefined(); + expect(adminEntry.authProvider).toBe("local"); + expect(adminEntry.hasLocalPassword).toBe(true); + expect(adminEntry.hasOidcLink).toBe(false); + }); + + it("users list does not expose passwordHash or externalId directly", async () => { + createOidcUser(); + + const res = await testApp.app.inject({ + method: "GET", + url: "/api/auth/users", + headers: { authorization: `Bearer ${adminToken}` }, + }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + + for (const user of body.users) { + expect(user).not.toHaveProperty("passwordHash"); + expect(user).not.toHaveProperty("externalId"); + } + }); +}); + +// ===================================================================== +// BACKWARD COMPATIBILITY +// ===================================================================== +describe("Backward compatibility", () => { + it("local login still works when OIDC users exist in the DB", async () => { + // Create an OIDC user (just to prove it doesn't break local login) + createOidcUser(); + + const res = await testApp.app.inject({ + method: "POST", + url: "/api/auth/login", + payload: { username: "admin", password: "Adminpass1" }, + }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.token).toBeTruthy(); + expect(body.user.username).toBe("admin"); + }); + + it("OIDC user cannot log in via local login (no passwordHash)", async () => { + const { username } = createOidcUser(); + + const res = await testApp.app.inject({ + method: "POST", + url: "/api/auth/login", + payload: { username, password: "anything" }, + }); + + // Should fail because passwordHash is null + expect(res.statusCode).toBe(401); + }); +}); + +// ===================================================================== +// CONFIG ENDPOINT (requires OIDC_ENABLED = true) +// ===================================================================== +describe("Config endpoint with OIDC enabled", () => { + let oidcApp: TestApp; + + // Save original env values + const origOidcEnabled = env.OIDC_ENABLED; + const origExternalUrl = env.EXTERNAL_URL; + const origIssuerUrl = env.OIDC_ISSUER_URL; + const origClientId = env.OIDC_CLIENT_ID; + const origClientSecret = env.OIDC_CLIENT_SECRET; + const origProviderName = env.OIDC_PROVIDER_NAME; + + beforeAll(async () => { + // Mutate the cached env to enable OIDC for route registration + (env as any).OIDC_ENABLED = true; + (env as any).EXTERNAL_URL = "http://localhost:9999"; + (env as any).OIDC_ISSUER_URL = "http://localhost:0"; + (env as any).OIDC_CLIENT_ID = "test-client-id"; + (env as any).OIDC_CLIENT_SECRET = "test-client-secret"; + (env as any).OIDC_PROVIDER_NAME = "TestProvider"; + + oidcApp = await buildTestApp(); + }, 30_000); + + afterAll(async () => { + // Restore original env values + (env as any).OIDC_ENABLED = origOidcEnabled; + (env as any).EXTERNAL_URL = origExternalUrl; + (env as any).OIDC_ISSUER_URL = origIssuerUrl; + (env as any).OIDC_CLIENT_ID = origClientId; + (env as any).OIDC_CLIENT_SECRET = origClientSecret; + (env as any).OIDC_PROVIDER_NAME = origProviderName; + + await oidcApp.cleanup(); + }, 10_000); + + it("config returns OIDC fields when enabled", async () => { + const res = await oidcApp.app.inject({ + method: "GET", + url: "/api/v1/config/auth", + }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.oidcEnabled).toBe(true); + expect(body.oidcProviderName).toBe("TestProvider"); + expect(body.oidcLoginUrl).toBe("/api/auth/oidc/login"); + }); + + it("config does NOT leak OIDC secrets", async () => { + const res = await oidcApp.app.inject({ + method: "GET", + url: "/api/v1/config/auth", + }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body).not.toHaveProperty("clientSecret"); + expect(body).not.toHaveProperty("oidcClientSecret"); + expect(body).not.toHaveProperty("issuerUrl"); + expect(body).not.toHaveProperty("oidcIssuerUrl"); + }); +}); + +// ===================================================================== +// CONFIG ENDPOINT (OIDC disabled -- default) +// ===================================================================== +describe("Config endpoint with OIDC disabled", () => { + it("config omits OIDC fields when disabled", async () => { + // The default test app has OIDC_ENABLED=false + const res = await testApp.app.inject({ + method: "GET", + url: "/api/v1/config/auth", + }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body).not.toHaveProperty("oidcEnabled"); + expect(body).not.toHaveProperty("oidcProviderName"); + expect(body).not.toHaveProperty("oidcLoginUrl"); + expect(body.authEnabled).toBe(true); + }); +}); + +// ===================================================================== +// LOGIN REDIRECT (requires OIDC routes + mock OIDC discovery) +// ===================================================================== +describe("OIDC login redirect", () => { + let oidcApp: TestApp; + let mockServer: Server; + let mockPort: number; + + // Save original env values + const origOidcEnabled = env.OIDC_ENABLED; + const origExternalUrl = env.EXTERNAL_URL; + const origIssuerUrl = env.OIDC_ISSUER_URL; + const origClientId = env.OIDC_CLIENT_ID; + const origClientSecret = env.OIDC_CLIENT_SECRET; + + beforeAll(async () => { + // Start a minimal mock OIDC provider that serves discovery only + mockServer = createServer((req, res) => { + if (req.url === "/.well-known/openid-configuration") { + const discovery = { + issuer: `http://localhost:${mockPort}`, + authorization_endpoint: `http://localhost:${mockPort}/authorize`, + token_endpoint: `http://localhost:${mockPort}/token`, + jwks_uri: `http://localhost:${mockPort}/jwks`, + response_types_supported: ["code"], + subject_types_supported: ["public"], + id_token_signing_alg_values_supported: ["RS256"], + code_challenge_methods_supported: ["S256"], + }; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(discovery)); + return; + } + if (req.url === "/jwks") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ keys: [] })); + return; + } + res.writeHead(404); + res.end(); + }); + + // Bind to random port + await new Promise((resolve) => { + mockServer.listen(0, "127.0.0.1", () => { + const addr = mockServer.address(); + mockPort = typeof addr === "object" && addr ? addr.port : 0; + resolve(); + }); + }); + + // Mutate the cached env to enable OIDC + (env as any).OIDC_ENABLED = true; + (env as any).EXTERNAL_URL = "http://localhost:9999"; + (env as any).OIDC_ISSUER_URL = `http://localhost:${mockPort}`; + (env as any).OIDC_CLIENT_ID = "test-client-id"; + (env as any).OIDC_CLIENT_SECRET = "test-client-secret"; + + // Clear the cached OIDC config so discovery hits our mock + const oidcModule = await import("../../apps/api/src/plugins/oidc.js"); + // The module caches config in a module-level variable; rebuild app to get fresh routes + oidcApp = await buildTestApp(); + }, 30_000); + + afterAll(async () => { + // Restore original env values + (env as any).OIDC_ENABLED = origOidcEnabled; + (env as any).EXTERNAL_URL = origExternalUrl; + (env as any).OIDC_ISSUER_URL = origIssuerUrl; + (env as any).OIDC_CLIENT_ID = origClientId; + (env as any).OIDC_CLIENT_SECRET = origClientSecret; + + await oidcApp.cleanup(); + await new Promise((resolve) => mockServer.close(() => resolve())); + }, 10_000); + + it("GET /api/auth/oidc/login returns 302 redirect to IdP", async () => { + const res = await oidcApp.app.inject({ + method: "GET", + url: "/api/auth/oidc/login", + }); + + // Should redirect to the mock IdP's authorization endpoint + expect(res.statusCode).toBe(302); + const location = res.headers.location as string; + expect(location).toBeTruthy(); + + const redirectUrl = new URL(location); + expect(redirectUrl.origin).toBe(`http://localhost:${mockPort}`); + expect(redirectUrl.pathname).toBe("/authorize"); + + // Verify required OIDC params + expect(redirectUrl.searchParams.get("client_id")).toBe("test-client-id"); + expect(redirectUrl.searchParams.get("redirect_uri")).toBe( + "http://localhost:9999/api/auth/oidc/callback", + ); + expect(redirectUrl.searchParams.get("response_type")).toBe("code"); + expect(redirectUrl.searchParams.get("scope")).toContain("openid"); + expect(redirectUrl.searchParams.get("state")).toBeTruthy(); + expect(redirectUrl.searchParams.get("nonce")).toBeTruthy(); + expect(redirectUrl.searchParams.get("code_challenge")).toBeTruthy(); + expect(redirectUrl.searchParams.get("code_challenge_method")).toBe("S256"); + }); + + it("login redirect sets oidc-state cookie", async () => { + const res = await oidcApp.app.inject({ + method: "GET", + url: "/api/auth/oidc/login", + }); + + expect(res.statusCode).toBe(302); + + // Check for oidc-state cookie in Set-Cookie header + const cookies = res.headers["set-cookie"]; + const cookieStr = Array.isArray(cookies) ? cookies.join("; ") : cookies || ""; + expect(cookieStr).toContain("oidc-state="); + expect(cookieStr).toContain("HttpOnly"); + expect(cookieStr).toContain("SameSite=Lax"); + }); +}); + +// ===================================================================== +// OIDC CALLBACK EDGE CASES (without a full mock provider) +// ===================================================================== +describe("OIDC callback edge cases", () => { + let oidcApp: TestApp; + let mockServer: Server; + let mockPort: number; + + // Save original env values + const origOidcEnabled = env.OIDC_ENABLED; + const origExternalUrl = env.EXTERNAL_URL; + const origIssuerUrl = env.OIDC_ISSUER_URL; + const origClientId = env.OIDC_CLIENT_ID; + const origClientSecret = env.OIDC_CLIENT_SECRET; + + beforeAll(async () => { + // Minimal mock server for discovery + mockServer = createServer((req, res) => { + if (req.url === "/.well-known/openid-configuration") { + const discovery = { + issuer: `http://localhost:${mockPort}`, + authorization_endpoint: `http://localhost:${mockPort}/authorize`, + token_endpoint: `http://localhost:${mockPort}/token`, + jwks_uri: `http://localhost:${mockPort}/jwks`, + response_types_supported: ["code"], + subject_types_supported: ["public"], + id_token_signing_alg_values_supported: ["RS256"], + code_challenge_methods_supported: ["S256"], + }; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(discovery)); + return; + } + if (req.url === "/jwks") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ keys: [] })); + return; + } + res.writeHead(404); + res.end(); + }); + + await new Promise((resolve) => { + mockServer.listen(0, "127.0.0.1", () => { + const addr = mockServer.address(); + mockPort = typeof addr === "object" && addr ? addr.port : 0; + resolve(); + }); + }); + + (env as any).OIDC_ENABLED = true; + (env as any).EXTERNAL_URL = "http://localhost:9999"; + (env as any).OIDC_ISSUER_URL = `http://localhost:${mockPort}`; + (env as any).OIDC_CLIENT_ID = "test-client-id"; + (env as any).OIDC_CLIENT_SECRET = "test-client-secret"; + + oidcApp = await buildTestApp(); + }, 30_000); + + afterAll(async () => { + (env as any).OIDC_ENABLED = origOidcEnabled; + (env as any).EXTERNAL_URL = origExternalUrl; + (env as any).OIDC_ISSUER_URL = origIssuerUrl; + (env as any).OIDC_CLIENT_ID = origClientId; + (env as any).OIDC_CLIENT_SECRET = origClientSecret; + + await oidcApp.cleanup(); + await new Promise((resolve) => mockServer.close(() => resolve())); + }, 10_000); + + it("callback without state cookie redirects to login with error", async () => { + const res = await oidcApp.app.inject({ + method: "GET", + url: "/api/auth/oidc/callback?code=abc&state=xyz", + }); + + // Should redirect to /login?error=oidc_session_expired + expect(res.statusCode).toBe(302); + const location = res.headers.location as string; + expect(location).toContain("/login?error=oidc_session_expired"); + }); + + it("callback with invalid cookie signature redirects to login with error", async () => { + const res = await oidcApp.app.inject({ + method: "GET", + url: "/api/auth/oidc/callback?code=abc&state=xyz", + cookies: { "oidc-state": "tampered-garbage-value" }, + }); + + expect(res.statusCode).toBe(302); + const location = res.headers.location as string; + expect(location).toContain("/login?error=oidc_session_expired"); + }); + + it("callback with IdP error redirects to login with oidc_auth_failed", async () => { + // First, do a login to get a valid state cookie + const loginRes = await oidcApp.app.inject({ + method: "GET", + url: "/api/auth/oidc/login", + }); + expect(loginRes.statusCode).toBe(302); + + // Extract the state cookie and state param from redirect + const rawCookies = loginRes.headers["set-cookie"]; + const cookieStr = Array.isArray(rawCookies) ? rawCookies[0] : rawCookies || ""; + const cookieMatch = cookieStr.match(/oidc-state=([^;]+)/); + expect(cookieMatch).toBeTruthy(); + // The cookie value may be URL-encoded; decode for inject() + const cookieValue = decodeURIComponent(cookieMatch![1]); + + const redirectUrl = new URL(loginRes.headers.location as string); + const state = redirectUrl.searchParams.get("state"); + + // Simulate IdP returning an error + const callbackRes = await oidcApp.app.inject({ + method: "GET", + url: `/api/auth/oidc/callback?error=access_denied&error_description=User+denied&state=${state}`, + cookies: { "oidc-state": cookieValue }, + }); + + expect(callbackRes.statusCode).toBe(302); + const location = callbackRes.headers.location as string; + expect(location).toContain("/login?error=oidc_auth_failed"); + }); +}); + +// ===================================================================== +// ADMIN OPERATIONS ON OIDC USERS +// ===================================================================== +describe("Admin operations on OIDC users", () => { + it("admin can delete an OIDC user", async () => { + const { userId } = createOidcUser(); + + const res = await testApp.app.inject({ + method: "DELETE", + url: `/api/auth/users/${userId}`, + headers: { authorization: `Bearer ${adminToken}` }, + }); + + expect(res.statusCode).toBe(200); + + // Verify user is gone + const user = db.select().from(schema.users).where(eq(schema.users.id, userId)).get(); + expect(user).toBeUndefined(); + }); + + it("admin can update role of an OIDC user", async () => { + const { userId } = createOidcUser(); + + const res = await testApp.app.inject({ + method: "PUT", + url: `/api/auth/users/${userId}`, + headers: { authorization: `Bearer ${adminToken}` }, + payload: { role: "editor" }, + }); + + expect(res.statusCode).toBe(200); + + const user = db.select().from(schema.users).where(eq(schema.users.id, userId)).get(); + expect(user?.role).toBe("editor"); + }); +}); + +// ===================================================================== +// COOKIE-BASED SESSION AUTH +// ===================================================================== +describe("Cookie-based session auth", () => { + it("OIDC session cookie works for authenticated requests", async () => { + const { sessionToken, username } = createOidcUser(); + + const res = await testApp.app.inject({ + method: "GET", + url: "/api/auth/session", + cookies: { "snapotter-session": sessionToken }, + }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.user.username).toBe(username); + expect(body.user.authProvider).toBe("oidc"); + }); + + it("Both cookie and Bearer work simultaneously", async () => { + const { sessionToken: oidcToken, username: oidcUsername } = createOidcUser(); + + // Bearer token for local user (admin) + const bearerRes = await testApp.app.inject({ + method: "GET", + url: "/api/auth/session", + headers: { authorization: `Bearer ${adminToken}` }, + }); + + expect(bearerRes.statusCode).toBe(200); + const bearerBody = JSON.parse(bearerRes.body); + expect(bearerBody.user.username).toBe("admin"); + expect(bearerBody.user.authProvider).toBe("local"); + + // Cookie for OIDC user + const cookieRes = await testApp.app.inject({ + method: "GET", + url: "/api/auth/session", + cookies: { "snapotter-session": oidcToken }, + }); + + expect(cookieRes.statusCode).toBe(200); + const cookieBody = JSON.parse(cookieRes.body); + expect(cookieBody.user.username).toBe(oidcUsername); + expect(cookieBody.user.authProvider).toBe("oidc"); + }); + + it("request with neither Bearer nor cookie returns 401", async () => { + const res = await testApp.app.inject({ + method: "GET", + url: "/api/auth/session", + }); + + expect(res.statusCode).toBe(401); + }); +}); + +// ===================================================================== +// SESSION EXPIRY +// ===================================================================== +describe("Session expiry", () => { + it("expired OIDC session returns 401", async () => { + const { userId } = createOidcUser(); + + // Create a session that expired 10 minutes ago + const expiredToken = createOidcSession(userId, { + expiresAt: new Date(Date.now() - 600_000), + }); + + const res = await testApp.app.inject({ + method: "GET", + url: "/api/auth/session", + cookies: { "snapotter-session": expiredToken }, + }); + + expect(res.statusCode).toBe(401); + + // Verify the expired session was cleaned up from DB + const session = db + .select() + .from(schema.sessions) + .where(eq(schema.sessions.id, expiredToken)) + .get(); + expect(session).toBeUndefined(); + }); +}); + +// ===================================================================== +// API KEYS FOR OIDC USERS +// ===================================================================== +describe("API keys for OIDC users", () => { + it("OIDC user can create an API key", async () => { + const { sessionToken } = createOidcUser(); + + const res = await testApp.app.inject({ + method: "POST", + url: "/api/v1/api-keys", + cookies: { "snapotter-session": sessionToken }, + payload: { name: "test-key" }, + }); + + expect(res.statusCode).toBe(201); + const body = JSON.parse(res.body); + expect(body.key).toMatch(/^si_/); + expect(body.name).toBe("test-key"); + }); + + it("API key works for auth after creation", async () => { + const { sessionToken } = createOidcUser(); + + // Create the API key via cookie auth + const createRes = await testApp.app.inject({ + method: "POST", + url: "/api/v1/api-keys", + cookies: { "snapotter-session": sessionToken }, + payload: { name: "auth-test-key" }, + }); + + expect(createRes.statusCode).toBe(201); + const { key: rawKey } = JSON.parse(createRes.body); + + // Use the raw API key as Bearer token + const sessionRes = await testApp.app.inject({ + method: "GET", + url: "/api/auth/session", + headers: { authorization: `Bearer ${rawKey}` }, + }); + + // API key auth on GET /api/auth/session is a public route, but + // the middleware still attaches the user if a valid token is found. + // The session endpoint itself checks for session, not API key, + // so it returns 401 since the si_ token is not a session ID. + // Instead, test with a non-public route that requires auth. + const healthRes = await testApp.app.inject({ + method: "GET", + url: "/api/v1/api-keys", + headers: { authorization: `Bearer ${rawKey}` }, + }); + + expect(healthRes.statusCode).toBe(200); + const body = JSON.parse(healthRes.body); + expect(body.apiKeys).toBeDefined(); + expect(Array.isArray(body.apiKeys)).toBe(true); + }); +}); + +// ===================================================================== +// LOGOUT +// ===================================================================== +describe("Logout", () => { + it("logout clears the snapotter-session cookie", async () => { + const { sessionToken } = createOidcUser(); + + const res = await testApp.app.inject({ + method: "POST", + url: "/api/auth/logout", + cookies: { "snapotter-session": sessionToken }, + }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.ok).toBe(true); + + // Check that response includes a Set-Cookie header clearing the session + const setCookieHeader = res.headers["set-cookie"]; + const cookieStr = Array.isArray(setCookieHeader) + ? setCookieHeader.join("; ") + : setCookieHeader || ""; + expect(cookieStr).toContain("snapotter-session="); + // The cookie should be expired (Expires in the past or Max-Age=0) + const hasExpiry = + cookieStr.toLowerCase().includes("max-age=0") || + cookieStr.toLowerCase().includes("expires=thu, 01 jan 1970"); + expect(hasExpiry).toBe(true); + }); + + it("session is deleted from DB after logout", async () => { + const { sessionToken } = createOidcUser(); + + // Verify session exists before logout + const before = db + .select() + .from(schema.sessions) + .where(eq(schema.sessions.id, sessionToken)) + .get(); + expect(before).toBeDefined(); + + await testApp.app.inject({ + method: "POST", + url: "/api/auth/logout", + cookies: { "snapotter-session": sessionToken }, + }); + + // Verify session is gone + const after = db + .select() + .from(schema.sessions) + .where(eq(schema.sessions.id, sessionToken)) + .get(); + expect(after).toBeUndefined(); + }); + + it("logout returns logoutUrl when session has idToken and OIDC discovery is cached", async () => { + const { sessionToken } = createOidcUser(); + + // The default createOidcUser sets idToken to "mock-id-token-jwt". + // Without a running OIDC provider and cached discovery, the logout + // route's try/catch will swallow the error and return no logoutUrl. + // We still verify the response shape is correct. + const res = await testApp.app.inject({ + method: "POST", + url: "/api/auth/logout", + cookies: { "snapotter-session": sessionToken }, + }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.ok).toBe(true); + // logoutUrl is only present when OIDC discovery has been cached with + // an end_session_endpoint. In test env without mock provider, it is + // absent. We verify it's either undefined or a string URL. + if (body.logoutUrl !== undefined) { + expect(typeof body.logoutUrl).toBe("string"); + expect(body.logoutUrl).toContain("id_token_hint="); + } + }); +}); diff --git a/tests/integration/test-server.ts b/tests/integration/test-server.ts index 5e2d0464..b0d014a1 100644 --- a/tests/integration/test-server.ts +++ b/tests/integration/test-server.ts @@ -19,6 +19,7 @@ import { dirname } from "node:path"; mkdirSync(dirname(process.env.DB_PATH!), { recursive: true }); mkdirSync(process.env.WORKSPACE_PATH!, { recursive: true }); +import cookie from "@fastify/cookie"; import cors from "@fastify/cors"; import { APP_VERSION } from "@snapotter/shared"; import { eq } from "drizzle-orm"; @@ -31,6 +32,7 @@ import { db, schema } from "../../apps/api/src/db/index.js"; import { runMigrations } from "../../apps/api/src/db/migrate.js"; import { requirePermission } from "../../apps/api/src/permissions.js"; import { authMiddleware, authRoutes, ensureDefaultAdmin } from "../../apps/api/src/plugins/auth.js"; +import { oidcRoutes } from "../../apps/api/src/plugins/oidc.js"; import { registerUpload } from "../../apps/api/src/plugins/upload.js"; import { analyticsRoutes } from "../../apps/api/src/routes/analytics.js"; import { apiKeyRoutes } from "../../apps/api/src/routes/api-keys.js"; @@ -80,12 +82,18 @@ export async function buildTestApp(): Promise { // Multipart upload support await registerUpload(app); + // Cookie support + await app.register(cookie, { secret: "test-cookie-secret", hook: "onRequest" }); + // Auth middleware (must be registered before routes) await authMiddleware(app); // Auth routes await authRoutes(app); + // OIDC routes + await oidcRoutes(app); + // File upload/download routes await fileRoutes(app); @@ -161,18 +169,29 @@ export async function buildTestApp(): Promise { }); // Public config endpoint - app.get("/api/v1/config/auth", async () => ({ - authEnabled: env.AUTH_ENABLED, - })); + app.get("/api/v1/config/auth", async () => { + const config: Record = { authEnabled: env.AUTH_ENABLED }; + if (env.OIDC_ENABLED) { + config.oidcEnabled = true; + config.oidcProviderName = env.OIDC_PROVIDER_NAME || null; + config.oidcLoginUrl = "/api/auth/oidc/login"; + } + return config; + }); // Ensure Fastify is ready (all plugins loaded) await app.ready(); const cleanup = async () => { await app.close(); - // Don't delete the temp DB directory here — other test files in the same - // vitest run share it. The directory lives under /tmp with a random UUID - // and is cleaned up by the OS. + // Checkpoint WAL to prevent unbounded growth across sequential test files. + // Without this, the WAL/SHM files grow until SQLite hits SQLITE_IOERR_SHMSIZE. + try { + const { sqlite } = await import("../../apps/api/src/db/index.js"); + sqlite.pragma("wal_checkpoint(TRUNCATE)"); + } catch { + // best-effort + } }; return { app, cleanup }; diff --git a/tests/integration/watermark-image.test.ts b/tests/integration/watermark-image.test.ts index 3d8e4e95..f1685362 100644 --- a/tests/integration/watermark-image.test.ts +++ b/tests/integration/watermark-image.test.ts @@ -210,8 +210,8 @@ describe("watermark-image", () => { // ── Branch coverage: lines 164-168 (processing failure) ─────────── - it("returns 422 when processing fails on corrupted main image", async () => { - // A buffer that passes multipart parsing but fails Sharp processing + it("returns 400 when main image is corrupted", async () => { + // A buffer that passes multipart parsing but fails image validation const corruptedBuffer = Buffer.alloc(100, 0xff); const { body, contentType } = createMultipartPayload([ { name: "file", filename: "main.png", contentType: "image/png", content: corruptedBuffer }, @@ -226,7 +226,9 @@ describe("watermark-image", () => { body, }); - expect([400, 422]).toContain(res.statusCode); + expect(res.statusCode).toBe(400); + const json = JSON.parse(res.body); + expect(json.error).toContain("Invalid image"); }); // ── HEIC input handling ─────────────────────────────────────────── @@ -336,7 +338,7 @@ describe("watermark-image", () => { // ── Corrupted watermark image (processing failure) ─────────────── - it("returns 422 when watermark image is corrupted", async () => { + it("returns 400 when watermark image is corrupted", async () => { const corruptedWm = Buffer.alloc(50, 0xaa); const { body, contentType } = createMultipartPayload([ { name: "file", filename: "main.png", contentType: "image/png", content: PNG }, @@ -351,7 +353,9 @@ describe("watermark-image", () => { body, }); - expect([400, 422]).toContain(res.statusCode); + expect(res.statusCode).toBe(400); + const json = JSON.parse(res.body); + expect(json.error).toContain("Invalid watermark image"); }); // ── Tiny 1x1 main image ────────────────────────────────────────── diff --git a/tests/unit/web/tool-registry-expanded.test.ts b/tests/unit/web/tool-registry-expanded.test.ts index 41f8915a..6acbaf50 100644 --- a/tests/unit/web/tool-registry-expanded.test.ts +++ b/tests/unit/web/tool-registry-expanded.test.ts @@ -138,6 +138,9 @@ vi.mock("@/components/tools/meme-generator-preview", () => ({ vi.mock("@/components/tools/color-blindness-settings", () => ({ ColorBlindnessSettings: () => null, })); +vi.mock("@/components/tools/ai-canvas-expand-settings", () => ({ + AiCanvasExpandSettings: () => null, +})); import { TOOLS } from "@snapotter/shared"; import type { DisplayMode, ToolRegistryEntry } from "@/lib/tool-registry"; diff --git a/tests/unit/web/tool-registry.test.ts b/tests/unit/web/tool-registry.test.ts index dae9c0a7..e26cfd96 100644 --- a/tests/unit/web/tool-registry.test.ts +++ b/tests/unit/web/tool-registry.test.ts @@ -181,6 +181,9 @@ vi.mock("@/components/tools/meme-generator-preview", () => ({ vi.mock("@/components/tools/color-blindness-settings", () => ({ ColorBlindnessSettings: () => null, })); +vi.mock("@/components/tools/ai-canvas-expand-settings", () => ({ + AiCanvasExpandSettings: () => null, +})); // --------------------------------------------------------------------------- // Import after mocks diff --git a/vitest.config.ts b/vitest.config.ts index 5c88c66c..919e4559 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -23,8 +23,6 @@ export default defineConfig({ globals: true, testTimeout: 30_000, hookTimeout: 30_000, - // Run all test files in a single forked process so integration tests share - // the same SQLite connection and avoid SQLITE_BUSY races on WAL setup. pool: "forks", poolOptions: { forks: { @@ -44,8 +42,6 @@ export default defineConfig({ ".worktrees/**", ".claude/**", ], - // These env vars are injected into process.env BEFORE test files are - // imported, ensuring apps/api/src/config.ts picks them up correctly. env: { AUTH_ENABLED: "true", DEFAULT_USERNAME: "admin", @@ -91,6 +87,7 @@ export default defineConfig({ "@snapotter/image-engine": path.resolve(__dirname, "packages/image-engine/src/index.ts"), "@snapotter/shared": path.resolve(__dirname, "packages/shared/src/index.ts"), fastify: path.join(apiNodeModules, "fastify"), + "@fastify/cookie": path.join(apiNodeModules, "@fastify/cookie"), "@fastify/cors": path.join(apiNodeModules, "@fastify/cors"), "@fastify/multipart": path.join(apiNodeModules, "@fastify/multipart"), "@fastify/rate-limit": path.join(apiNodeModules, "@fastify/rate-limit"), @@ -107,6 +104,7 @@ export default defineConfig({ jsqr: path.join(apiNodeModules, "jsqr"), pdfkit: path.join(apiNodeModules, "pdfkit"), sharp: path.join(apiNodeModules, "sharp"), + "openid-client": path.join(apiNodeModules, "openid-client"), "opentype.js": path.join(apiNodeModules, "opentype.js"), react: path.join(webNodeModules, "react"), "react-dom": path.join(webNodeModules, "react-dom"),