mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: add OIDC/SSO authentication (#3)
Add OpenID Connect (OIDC) authentication alongside existing username/password login. Users can log in via any standards-compliant OIDC provider (Keycloak, Authentik, Authelia, Google, Azure AD, Okta) while preserving full backward compatibility. - OIDC Fastify plugin with lazy discovery, PKCE, cookie-based sessions - Login page OIDC button, auth hook updates, settings dialog badges - 28 integration tests, OIDC setup guide with provider examples - Fix pre-existing test failures (content-aware-crop, watermark, SVGZ) - WAL checkpoint fix for SQLite test stability Closes #3 # Conflicts: # apps/api/src/lib/env.ts # apps/api/src/routes/tools/watermark-image.ts # pnpm-lock.yaml # tests/integration/color-palette.test.ts # tests/integration/compare.test.ts # tests/integration/watermark-image.test.ts
This commit is contained in:
@@ -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;
|
||||
@@ -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`);
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()),
|
||||
|
||||
+38
-3
@@ -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<string, unknown>).COOKIE_SECRET = existing.value;
|
||||
} else {
|
||||
const generated = randomUUID() + randomUUID();
|
||||
db.insert(schema.settings).values({ key: "cookie_secret", value: generated }).run();
|
||||
(env as Record<string, unknown>).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<string, unknown> = {
|
||||
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") {
|
||||
|
||||
@@ -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";
|
||||
|
||||
+111
-56
@@ -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<typeof envSchema>;
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
.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<void> {
|
||||
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<string, unknown>) => 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
// ── Token extraction ───────────────────────────────────────────────
|
||||
|
||||
function extractToken(request: FastifyRequest): string | null {
|
||||
// Check Authorization header: "Bearer <token>"
|
||||
const authHeader = request.headers.authorization;
|
||||
if (authHeader?.startsWith("Bearer ")) {
|
||||
return authHeader.slice(7);
|
||||
}
|
||||
const cookies = (request as FastifyRequest & { cookies?: Record<string, string> }).cookies;
|
||||
if (cookies?.["snapotter-session"]) {
|
||||
return cookies["snapotter-session"];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<oidc.Configuration> {
|
||||
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, unknown>): 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<void> {
|
||||
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<string, string>;
|
||||
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<ReturnType<typeof oidc.authorizationCodeGrant>>;
|
||||
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<string, unknown>);
|
||||
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<number>`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("/");
|
||||
});
|
||||
}
|
||||
@@ -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}` });
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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
|
||||
@@ -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()}
|
||||
</div>
|
||||
<span className="text-sm font-medium text-foreground truncate">{u.username}</span>
|
||||
{u.hasOidcLink && u.hasLocalPassword !== false && (
|
||||
<span className="ml-1.5 text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
||||
Local + OIDC
|
||||
</span>
|
||||
)}
|
||||
{u.hasOidcLink && u.hasLocalPassword === false && (
|
||||
<span className="ml-1.5 text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
||||
OIDC
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Role badge */}
|
||||
@@ -1130,18 +1159,20 @@ function PeopleSection() {
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
Edit Role / Team
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setResetPasswordUser(u);
|
||||
setResetPassword("");
|
||||
setOpenMenuId(null);
|
||||
}}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
Reset Password
|
||||
</button>
|
||||
{u.hasLocalPassword !== false && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setResetPasswordUser(u);
|
||||
setResetPassword("");
|
||||
setOpenMenuId(null);
|
||||
}}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
Reset Password
|
||||
</button>
|
||||
)}
|
||||
<div className="border-t border-border my-1" />
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -12,6 +12,10 @@ interface AuthState {
|
||||
analyticsEnabled: boolean | null;
|
||||
analyticsConsentShownAt: number | null;
|
||||
analyticsConsentRemindAt: number | null;
|
||||
oidcEnabled: boolean;
|
||||
oidcProviderName: string | null;
|
||||
loginMethod: string | null;
|
||||
hasLocalPassword: boolean;
|
||||
}
|
||||
|
||||
const USER_PERMISSIONS = [
|
||||
@@ -33,6 +37,10 @@ export function useAuth() {
|
||||
analyticsEnabled: null,
|
||||
analyticsConsentShownAt: null,
|
||||
analyticsConsentRemindAt: null,
|
||||
oidcEnabled: false,
|
||||
oidcProviderName: null,
|
||||
loginMethod: null,
|
||||
hasLocalPassword: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -55,27 +63,16 @@ export function useAuth() {
|
||||
analyticsEnabled: null,
|
||||
analyticsConsentShownAt: null,
|
||||
analyticsConsentRemindAt: null,
|
||||
oidcEnabled: false,
|
||||
oidcProviderName: null,
|
||||
loginMethod: null,
|
||||
hasLocalPassword: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const token = localStorage.getItem("snapotter-token");
|
||||
if (!token) {
|
||||
if (!cancelled)
|
||||
setState({
|
||||
loading: false,
|
||||
authEnabled: true,
|
||||
isAuthenticated: false,
|
||||
mustChangePassword: false,
|
||||
role: null,
|
||||
permissions: [],
|
||||
analyticsEnabled: null,
|
||||
analyticsConsentShownAt: null,
|
||||
analyticsConsentRemindAt: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Always call /api/auth/session -- OIDC users have a session cookie
|
||||
// (not a localStorage token), so we cannot skip based on token absence.
|
||||
const sessionRes = await fetch("/api/auth/session", {
|
||||
headers: formatHeaders(),
|
||||
});
|
||||
@@ -94,6 +91,10 @@ export function useAuth() {
|
||||
analyticsEnabled: session.user?.analyticsEnabled ?? null,
|
||||
analyticsConsentShownAt: session.user?.analyticsConsentShownAt ?? null,
|
||||
analyticsConsentRemindAt: session.user?.analyticsConsentRemindAt ?? null,
|
||||
oidcEnabled: config.oidcEnabled ?? false,
|
||||
oidcProviderName: config.oidcProviderName ?? null,
|
||||
loginMethod: session.user?.loginMethod ?? null,
|
||||
hasLocalPassword: session.user?.hasLocalPassword ?? false,
|
||||
});
|
||||
} else {
|
||||
localStorage.removeItem("snapotter-token");
|
||||
@@ -108,6 +109,10 @@ export function useAuth() {
|
||||
analyticsEnabled: null,
|
||||
analyticsConsentShownAt: null,
|
||||
analyticsConsentRemindAt: null,
|
||||
oidcEnabled: config.oidcEnabled ?? false,
|
||||
oidcProviderName: config.oidcProviderName ?? null,
|
||||
loginMethod: null,
|
||||
hasLocalPassword: false,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { type FormEvent, useCallback, useEffect, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { setToken } from "@/lib/api";
|
||||
|
||||
const phrases = [
|
||||
@@ -60,11 +62,28 @@ function RotatingPhrase() {
|
||||
}
|
||||
|
||||
export function LoginPage() {
|
||||
const { oidcEnabled, oidcProviderName } = useAuth();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const oidcError = searchParams.get("error");
|
||||
if (oidcError) {
|
||||
const errorMessages: Record<string, string> = {
|
||||
oidc_auth_failed: "Authentication failed. Please try again.",
|
||||
oidc_provider_unreachable: "Could not reach the identity provider. Please try again later.",
|
||||
oidc_session_expired: "Login session expired. Please try again.",
|
||||
oidc_user_not_authorized:
|
||||
"Your account is not authorized to access this application. Contact your administrator.",
|
||||
oidc_user_limit_reached: "User limit reached. Contact your administrator.",
|
||||
};
|
||||
setError(errorMessages[oidcError] || "Authentication error. Please try again.");
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
@@ -149,6 +168,21 @@ export function LoginPage() {
|
||||
{loading ? "Logging in..." : "Login"}
|
||||
</button>
|
||||
</form>
|
||||
{oidcEnabled && (
|
||||
<>
|
||||
<div className="flex items-center gap-3 my-4">
|
||||
<div className="flex-1 border-t border-border" />
|
||||
<span className="text-sm text-muted-foreground">or</span>
|
||||
<div className="flex-1 border-t border-border" />
|
||||
</div>
|
||||
<a
|
||||
href="/api/auth/oidc/login"
|
||||
className="w-full py-3 rounded-lg bg-secondary text-secondary-foreground font-medium hover:bg-secondary/80 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
Sign in with {oidcProviderName || "SSO"}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden lg:flex flex-1 bg-primary/90 items-center justify-center p-12 text-white rounded-l-3xl">
|
||||
|
||||
+4
-1
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+36
-19
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 ─────────────────────
|
||||
|
||||
@@ -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<void>((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<void>((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<void>((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<void>((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=");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<TestApp> {
|
||||
// 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<TestApp> {
|
||||
});
|
||||
|
||||
// Public config endpoint
|
||||
app.get("/api/v1/config/auth", async () => ({
|
||||
authEnabled: env.AUTH_ENABLED,
|
||||
}));
|
||||
app.get("/api/v1/config/auth", async () => {
|
||||
const config: Record<string, unknown> = { 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 };
|
||||
|
||||
@@ -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 ──────────────────────────────────────────
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-4
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user