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:
SnapOtter
2026-05-14 22:31:26 +08:00
32 changed files with 3503 additions and 149 deletions
@@ -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`);
+759
View File
@@ -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": {}
}
}
+759
View File
@@ -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": {}
}
}
+14
View File
@@ -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
}
]
}
+2
View File
@@ -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",
+5 -1
View File
@@ -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
View File
@@ -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") {
+6 -1
View File
@@ -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
View File
@@ -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>;
+4 -2
View File
@@ -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 };
}
}
+66 -5
View File
@@ -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;
}
+410
View File
@@ -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("/");
});
}
+3 -3
View File
@@ -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}` });
}