Merge eede2cb4b5 into 5cf7703dd4
This commit is contained in:
commit
3fee78a437
|
|
@ -0,0 +1,94 @@
|
|||
# Copy this file to .env and fill in the values you need.
|
||||
# .env is gitignored - never commit real secrets.
|
||||
# All variables are optional unless noted otherwise; ConvertX runs fine with
|
||||
# none of these set. See README.md for the full description of each.
|
||||
|
||||
### Core / auth ###
|
||||
|
||||
# A long, random, secret string used to sign session JWTs. Strongly
|
||||
# recommended to set explicitly - if unset, a new random value is generated
|
||||
# on every restart, which logs out all existing sessions. Generate one with:
|
||||
# openssl rand -hex 32
|
||||
JWT_SECRET=
|
||||
|
||||
# Allow anyone to register a new local account (true/false). Defaults to false.
|
||||
ACCOUNT_REGISTRATION=false
|
||||
|
||||
# Allow the app to run over plain HTTP (true/false). Defaults to false. Only
|
||||
# set this to true for local/non-HTTPS testing - required if you are not
|
||||
# accessing the app via localhost or HTTPS, otherwise login cookies won't be set.
|
||||
HTTP_ALLOWED=false
|
||||
|
||||
# Skip login entirely and let anyone use the service (true/false). Defaults to
|
||||
# false. Only use this locally / on a trusted network.
|
||||
ALLOW_UNAUTHENTICATED=false
|
||||
|
||||
# When ALLOW_UNAUTHENTICATED=true, share the same conversion history between
|
||||
# all unauthenticated users/devices instead of giving each a separate one
|
||||
# (true/false). Defaults to false.
|
||||
UNAUTHENTICATED_USER_SHARING=false
|
||||
|
||||
### OIDC / SSO login (e.g. Authentik) ###
|
||||
# Set the first four to enable the "Login with ..." button. All are optional.
|
||||
|
||||
# The provider's issuer URL, e.g. https://authentik.example.com/application/o/convertx/
|
||||
OIDC_ISSUER=
|
||||
|
||||
# OAuth2/OIDC client ID issued by the provider.
|
||||
OIDC_CLIENT_ID=
|
||||
|
||||
# OAuth2/OIDC client secret issued by the provider.
|
||||
OIDC_CLIENT_SECRET=
|
||||
|
||||
# The public callback URL registered with the provider, must match exactly,
|
||||
# e.g. https://convertx.example.com/login/oidc/callback
|
||||
OIDC_REDIRECT_URI=
|
||||
|
||||
# Space-separated scopes requested from the provider. Defaults to "openid profile email".
|
||||
OIDC_SCOPES=openid profile email
|
||||
|
||||
# Display name used on the "Login with ..." button. Defaults to "SSO".
|
||||
OIDC_NAME=SSO
|
||||
|
||||
# Hide local email/password login and registration entirely, only allow OIDC
|
||||
# login (true/false). Defaults to false.
|
||||
OIDC_ONLY=false
|
||||
|
||||
### App behavior ###
|
||||
|
||||
# Checks every n hours for files older than n hours and deletes them. Set to 0
|
||||
# to disable. Defaults to 24.
|
||||
AUTO_DELETE_EVERY_N_HOURS=24
|
||||
|
||||
# Hide the history page (true/false). Defaults to false.
|
||||
HIDE_HISTORY=false
|
||||
|
||||
# The root path of the web interface. E.g. setting this to "/convertx" serves
|
||||
# the app at "example.com/convertx/". Leave empty to serve at the domain root.
|
||||
WEBROOT=
|
||||
|
||||
# Language to format date strings in, as a BCP 47 language tag. Defaults to "en".
|
||||
LANGUAGE=en
|
||||
|
||||
# Maximum number of concurrent conversion processes. Set to 0 for unlimited.
|
||||
# Defaults to 0.
|
||||
MAX_CONVERT_PROCESS=0
|
||||
|
||||
# Timezone used for displaying dates, e.g. Europe/Stockholm. Defaults to UTC.
|
||||
TZ=
|
||||
|
||||
### ffmpeg tuning ###
|
||||
|
||||
# Extra arguments passed to ffmpeg before the input file, e.g. -hwaccel vaapi.
|
||||
# See https://github.com/C4illin/ConvertX/issues/190 for hardware acceleration.
|
||||
FFMPEG_ARGS=
|
||||
|
||||
# Extra arguments passed to ffmpeg for the output, e.g. -preset veryfast.
|
||||
FFMPEG_OUTPUT_ARGS=
|
||||
|
||||
### Misc ###
|
||||
|
||||
# Port the app listens on inside the container. Defaults to 3000. Usually left
|
||||
# alone - change the host-side port via your compose file's `ports:` mapping
|
||||
# instead.
|
||||
PORT=3000
|
||||
|
|
@ -25,6 +25,7 @@ yarn-debug.log*
|
|||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
|
|
|
|||
65
README.md
65
README.md
|
|
@ -79,6 +79,12 @@ or
|
|||
docker run -p 3000:3000 -v ./data:/app/data ghcr.io/c4illin/convertx
|
||||
```
|
||||
|
||||
or, to keep configuration in a single `.env` file instead of inline in the compose file, copy [`.env.example`](.env.example) to `.env`, fill in what you need, and run:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
Then visit `http://localhost:3000` in your browser and create your account. Don't leave it unconfigured and open, as anyone can register the first account.
|
||||
|
||||
If you get unable to open database file run `chown -R $USER:$USER path` on the path you choose.
|
||||
|
|
@ -87,20 +93,51 @@ If you get unable to open database file run `chown -R $USER:$USER path` on the p
|
|||
|
||||
All are optional, JWT_SECRET is recommended to be set.
|
||||
|
||||
| Name | Default | Description |
|
||||
| ---------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| JWT_SECRET | when unset it will use the value from randomUUID() | A long and secret string used to sign the JSON Web Token |
|
||||
| ACCOUNT_REGISTRATION | false | Allow users to register accounts |
|
||||
| HTTP_ALLOWED | false | Allow HTTP connections, only set this to true locally |
|
||||
| ALLOW_UNAUTHENTICATED | false | Allow unauthenticated users to use the service, only set this to true locally |
|
||||
| AUTO_DELETE_EVERY_N_HOURS | 24 | Checks every n hours for files older then n hours and deletes them, set to 0 to disable |
|
||||
| WEBROOT | | The address to the root path setting this to "/convert" will serve the website on "example.com/convert/" |
|
||||
| FFMPEG_ARGS | | Arguments to pass to the input file of ffmpeg, e.g. `-hwaccel vaapi`. See https://github.com/C4illin/ConvertX/issues/190 for more info about hw-acceleration. |
|
||||
| FFMPEG_OUTPUT_ARGS | | Arguments to pass to the output of ffmpeg, e.g. `-preset veryfast` |
|
||||
| HIDE_HISTORY | false | Hide the history page |
|
||||
| LANGUAGE | en | Language to format date strings in, specified as a [BCP 47 language tag](https://en.wikipedia.org/wiki/IETF_language_tag) |
|
||||
| UNAUTHENTICATED_USER_SHARING | false | Shares conversion history between all unauthenticated users |
|
||||
| MAX_CONVERT_PROCESS | 0 | Maximum number of concurrent conversion processes allowed. Set to 0 for unlimited. |
|
||||
| Name | Default | Description |
|
||||
| ---------------------------- | -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| JWT_SECRET | when unset it will use the value from randomUUID() | A long and secret string used to sign the JSON Web Token |
|
||||
| ACCOUNT_REGISTRATION | false | Allow users to register accounts |
|
||||
| HTTP_ALLOWED | false | Allow HTTP connections, only set this to true locally |
|
||||
| ALLOW_UNAUTHENTICATED | false | Allow unauthenticated users to use the service, only set this to true locally |
|
||||
| AUTO_DELETE_EVERY_N_HOURS | 24 | Checks every n hours for files older then n hours and deletes them, set to 0 to disable |
|
||||
| WEBROOT | | The address to the root path setting this to "/convert" will serve the website on "example.com/convert/" |
|
||||
| FFMPEG_ARGS | | Arguments to pass to the input file of ffmpeg, e.g. `-hwaccel vaapi`. See https://github.com/C4illin/ConvertX/issues/190 for more info about hw-acceleration. |
|
||||
| FFMPEG_OUTPUT_ARGS | | Arguments to pass to the output of ffmpeg, e.g. `-preset veryfast` |
|
||||
| HIDE_HISTORY | false | Hide the history page |
|
||||
| LANGUAGE | en | Language to format date strings in, specified as a [BCP 47 language tag](https://en.wikipedia.org/wiki/IETF_language_tag) |
|
||||
| UNAUTHENTICATED_USER_SHARING | false | Shares conversion history between all unauthenticated users |
|
||||
| MAX_CONVERT_PROCESS | 0 | Maximum number of concurrent conversion processes allowed. Set to 0 for unlimited. |
|
||||
| OIDC_ISSUER | | The OIDC provider's issuer URL, e.g. `https://authentik.example.com/application/o/convertx/`. Setting this along with `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` and `OIDC_REDIRECT_URI` enables "Login with SSO". |
|
||||
| OIDC_CLIENT_ID | | OAuth2/OIDC client ID issued by the provider |
|
||||
| OIDC_CLIENT_SECRET | | OAuth2/OIDC client secret issued by the provider |
|
||||
| OIDC_REDIRECT_URI | | The public callback URL registered with the provider, e.g. `https://convertx.example.com/login/oidc/callback` |
|
||||
| OIDC_SCOPES | openid profile email | Space-separated scopes requested from the provider |
|
||||
| OIDC_NAME | SSO | Display name used on the "Login with ..." button |
|
||||
| OIDC_ONLY | false | Hide the local email/password login and registration forms entirely, only allow login via OIDC |
|
||||
|
||||
### Single sign-on with Authentik (OIDC)
|
||||
|
||||
ConvertX can authenticate users against any standards-compliant OIDC provider using the authorization code flow with PKCE. To use Authentik:
|
||||
|
||||
1. In Authentik, create a new **Provider** of type "OAuth2/OpenID Provider":
|
||||
- Client type: `Confidential`
|
||||
- Redirect URI: `https://convertx.example.com/login/oidc/callback` (strict, must match `OIDC_REDIRECT_URI` exactly)
|
||||
- Note the generated **Client ID** and **Client Secret**
|
||||
2. Create an **Application** in Authentik and bind it to that provider.
|
||||
3. Note your provider's issuer URL, shown on the provider page, usually `https://authentik.example.com/application/o/<application-slug>/`.
|
||||
4. Set the following in your environment:
|
||||
|
||||
```yml
|
||||
environment:
|
||||
- OIDC_ISSUER=https://authentik.example.com/application/o/convertx/
|
||||
- OIDC_CLIENT_ID=your-client-id
|
||||
- OIDC_CLIENT_SECRET=your-client-secret
|
||||
- OIDC_REDIRECT_URI=https://convertx.example.com/login/oidc/callback
|
||||
# - OIDC_NAME=Authentik # optional, changes the button label
|
||||
# - OIDC_ONLY=true # optional, hides local password login entirely
|
||||
```
|
||||
|
||||
The first user to sign in via SSO is automatically created locally (matched/linked by email to any existing local account) and JWT sessions work exactly as with password login. If `OIDC_ONLY` is not set, both the local login form and the SSO button are shown, so existing local accounts keep working alongside SSO.
|
||||
|
||||
### Docker images
|
||||
|
||||
|
|
|
|||
7
bun.lock
7
bun.lock
|
|
@ -9,6 +9,7 @@
|
|||
"@elysiajs/static": "^1.4.10",
|
||||
"@kitajs/html": "^4.2.13",
|
||||
"elysia": "1.4.22",
|
||||
"openid-client": "^6.8.4",
|
||||
"sanitize-filename": "^1.6.4",
|
||||
"tar": "^7.5.16",
|
||||
},
|
||||
|
|
@ -449,8 +450,12 @@
|
|||
|
||||
"npm-run-all2": ["npm-run-all2@8.0.4", "", { "dependencies": { "ansi-styles": "^6.2.1", "cross-spawn": "^7.0.6", "memorystream": "^0.3.1", "picomatch": "^4.0.2", "pidtree": "^0.6.0", "read-package-json-fast": "^4.0.0", "shell-quote": "^1.7.3", "which": "^5.0.0" }, "bin": { "run-p": "bin/run-p/index.js", "run-s": "bin/run-s/index.js", "npm-run-all": "bin/npm-run-all/index.js", "npm-run-all2": "bin/npm-run-all/index.js" } }, "sha512-wdbB5My48XKp2ZfJUlhnLVihzeuA1hgBnqB2J9ahV77wLS+/YAJAlN8I+X3DIFIPZ3m5L7nplmlbhNiFDmXRDA=="],
|
||||
|
||||
"oauth4webapi": ["oauth4webapi@3.8.6", "", {}, "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ=="],
|
||||
|
||||
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
|
||||
|
||||
"openid-client": ["openid-client@6.8.4", "", { "dependencies": { "jose": "^6.2.2", "oauth4webapi": "^3.8.5" } }, "sha512-QSw0BA08piujetEwfZsHoTrDpMEha7GDZDicQqVwX4u0ChCjefvjDB++TZ8BTg76UpwhzIQgdvvfgfl3HpCSAw=="],
|
||||
|
||||
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
|
||||
|
||||
"oxc-resolver": ["oxc-resolver@11.19.1", "", { "optionalDependencies": { "@oxc-resolver/binding-android-arm-eabi": "11.19.1", "@oxc-resolver/binding-android-arm64": "11.19.1", "@oxc-resolver/binding-darwin-arm64": "11.19.1", "@oxc-resolver/binding-darwin-x64": "11.19.1", "@oxc-resolver/binding-freebsd-x64": "11.19.1", "@oxc-resolver/binding-linux-arm-gnueabihf": "11.19.1", "@oxc-resolver/binding-linux-arm-musleabihf": "11.19.1", "@oxc-resolver/binding-linux-arm64-gnu": "11.19.1", "@oxc-resolver/binding-linux-arm64-musl": "11.19.1", "@oxc-resolver/binding-linux-ppc64-gnu": "11.19.1", "@oxc-resolver/binding-linux-riscv64-gnu": "11.19.1", "@oxc-resolver/binding-linux-riscv64-musl": "11.19.1", "@oxc-resolver/binding-linux-s390x-gnu": "11.19.1", "@oxc-resolver/binding-linux-x64-gnu": "11.19.1", "@oxc-resolver/binding-linux-x64-musl": "11.19.1", "@oxc-resolver/binding-openharmony-arm64": "11.19.1", "@oxc-resolver/binding-wasm32-wasi": "11.19.1", "@oxc-resolver/binding-win32-arm64-msvc": "11.19.1", "@oxc-resolver/binding-win32-ia32-msvc": "11.19.1", "@oxc-resolver/binding-win32-x64-msvc": "11.19.1" } }, "sha512-qE/CIg/spwrTBFt5aKmwe3ifeDdLfA2NESN30E42X/lII5ClF8V7Wt6WIJhcGZjp0/Q+nQ+9vgxGk//xZNX2hg=="],
|
||||
|
|
@ -617,6 +622,8 @@
|
|||
|
||||
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"openid-client/jose": ["jose@6.2.5", "", {}, "sha512-2E5L2yRp03FnwreJLJX8/r7mHiZICCf8kG7fAsTWkSQTDAcc46NIZoQLKy+EJ8sPoJlxyS4OQR5H70LjIZZlIQ=="],
|
||||
|
||||
"tsconfig-paths-webpack-plugin/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"tsconfig-paths-webpack-plugin/enhanced-resolve": ["enhanced-resolve@5.20.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ=="],
|
||||
|
|
|
|||
11
compose.yaml
11
compose.yaml
|
|
@ -16,7 +16,16 @@ services:
|
|||
# - FFMPEG_ARGS=-hwaccel vulkan # additional arguments to pass to ffmpeg
|
||||
# - WEBROOT=/convertx # the root path of the web interface, leave empty to disable
|
||||
# - HIDE_HISTORY=true # hides the history tab in the web interface, defaults to false
|
||||
- TZ=Europe/Stockholm # set your timezone, defaults to UTC
|
||||
- TZ=Asia/Kolkata # set your timezone, defaults to UTC
|
||||
# - UNAUTHENTICATED_USER_SHARING=true # for use with ALLOW_UNAUTHENTICATED=true to share history with all unauthenticated users / devices
|
||||
# OIDC / SSO login (e.g. Authentik) - set all four of these to enable the "Login with ..." button
|
||||
# never commit real client IDs/secrets here - put them in a local, gitignored .env file instead
|
||||
# - OIDC_ISSUER=https://authentik.example.com/application/o/convertx/ # the provider's issuer URL
|
||||
# - OIDC_CLIENT_ID=your-client-id
|
||||
# - OIDC_CLIENT_SECRET=your-client-secret
|
||||
# - OIDC_REDIRECT_URI=https://convertx.example.com/login/oidc/callback # must match the redirect URI registered with the provider exactly
|
||||
# - OIDC_SCOPES=openid profile email # defaults to "openid profile email"
|
||||
# - OIDC_NAME=Authentik # button label, defaults to "SSO"
|
||||
# - OIDC_ONLY=true # hides local email/password login and registration entirely, only allow login via OIDC
|
||||
ports:
|
||||
- 3000:3000
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
# Production deployment using the published image.
|
||||
# All configuration is loaded from a local .env file - copy .env.example to
|
||||
# .env, fill in the values you need, then run:
|
||||
# docker compose -f docker-compose.prod.yml up -d
|
||||
# See README.md for full setup and environment variable documentation.
|
||||
|
||||
services:
|
||||
convertx:
|
||||
image: ghcr.io/c4illin/convertx
|
||||
container_name: convertx
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
|
|
@ -21,6 +21,7 @@
|
|||
"@elysiajs/static": "^1.4.10",
|
||||
"@kitajs/html": "^4.2.13",
|
||||
"elysia": "1.4.22",
|
||||
"openid-client": "^6.8.4",
|
||||
"sanitize-filename": "^1.6.4",
|
||||
"tar": "^7.5.16"
|
||||
},
|
||||
|
|
|
|||
22
src/db/db.ts
22
src/db/db.ts
|
|
@ -9,8 +9,11 @@ if (!db.query("SELECT * FROM sqlite_master WHERE type='table'").get()) {
|
|||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL,
|
||||
password TEXT NOT NULL
|
||||
password TEXT NOT NULL,
|
||||
oidc_sub TEXT,
|
||||
oidc_issuer TEXT
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_oidc_identity ON users(oidc_issuer, oidc_sub);
|
||||
CREATE TABLE IF NOT EXISTS file_names (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
job_id INTEGER NOT NULL,
|
||||
|
|
@ -27,7 +30,7 @@ CREATE TABLE IF NOT EXISTS jobs (
|
|||
num_files INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
PRAGMA user_version = 1;`);
|
||||
PRAGMA user_version = 3;`);
|
||||
}
|
||||
|
||||
const dbVersion = (db.query("PRAGMA user_version").get() as { user_version?: number }).user_version;
|
||||
|
|
@ -36,6 +39,21 @@ if (dbVersion === 0) {
|
|||
db.exec("PRAGMA user_version = 1;");
|
||||
console.log("Updated database to version 1.");
|
||||
}
|
||||
if ((dbVersion ?? 0) < 2) {
|
||||
db.exec("ALTER TABLE users ADD COLUMN oidc_sub TEXT;");
|
||||
db.exec("PRAGMA user_version = 2;");
|
||||
console.log("Updated database to version 2.");
|
||||
}
|
||||
if ((dbVersion ?? 0) < 3) {
|
||||
db.exec("ALTER TABLE users ADD COLUMN oidc_issuer TEXT;");
|
||||
// sub is only guaranteed unique within its issuer, so identity is the (issuer, sub) pair.
|
||||
// SQLite treats every NULL as distinct, so local-only users (both columns NULL) never collide.
|
||||
db.exec(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_users_oidc_identity ON users(oidc_issuer, oidc_sub);",
|
||||
);
|
||||
db.exec("PRAGMA user_version = 3;");
|
||||
console.log("Updated database to version 3.");
|
||||
}
|
||||
|
||||
// enable WAL mode
|
||||
db.exec("PRAGMA journal_mode = WAL;");
|
||||
|
|
|
|||
|
|
@ -20,4 +20,6 @@ export class User {
|
|||
id!: number;
|
||||
email!: string;
|
||||
password!: string;
|
||||
oidc_sub!: string | null;
|
||||
oidc_issuer!: string | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,3 +25,23 @@ export const UNAUTHENTICATED_USER_SHARING =
|
|||
process.env.UNAUTHENTICATED_USER_SHARING?.toLowerCase() === "true" || false;
|
||||
|
||||
export const TIMEZONE = process.env.TZ || undefined;
|
||||
|
||||
export const OIDC_ISSUER = process.env.OIDC_ISSUER ?? "";
|
||||
|
||||
export const OIDC_CLIENT_ID = process.env.OIDC_CLIENT_ID ?? "";
|
||||
|
||||
export const OIDC_CLIENT_SECRET = process.env.OIDC_CLIENT_SECRET ?? "";
|
||||
|
||||
export const OIDC_REDIRECT_URI = process.env.OIDC_REDIRECT_URI ?? "";
|
||||
|
||||
export const OIDC_SCOPES = process.env.OIDC_SCOPES ?? "openid profile email";
|
||||
|
||||
export const OIDC_NAME = process.env.OIDC_NAME ?? "SSO";
|
||||
|
||||
// Only enable OIDC once all required settings are present.
|
||||
export const OIDC_ENABLED = Boolean(
|
||||
OIDC_ISSUER && OIDC_CLIENT_ID && OIDC_CLIENT_SECRET && OIDC_REDIRECT_URI,
|
||||
);
|
||||
|
||||
// Hide the local email/password form entirely and only allow OIDC login.
|
||||
export const OIDC_ONLY = OIDC_ENABLED && process.env.OIDC_ONLY?.toLowerCase() === "true";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
import * as client from "openid-client";
|
||||
import { OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_ENABLED, OIDC_ISSUER } from "./env";
|
||||
|
||||
export let oidcConfig: Awaited<ReturnType<typeof client.discovery>> | undefined;
|
||||
|
||||
const RETRY_DELAY_MS = 30_000;
|
||||
|
||||
async function discoverOidc() {
|
||||
try {
|
||||
oidcConfig = await client.discovery(new URL(OIDC_ISSUER), OIDC_CLIENT_ID, OIDC_CLIENT_SECRET);
|
||||
console.log("OIDC: discovered issuer", OIDC_ISSUER);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`OIDC: failed to discover issuer, SSO login will be unavailable until this succeeds (retrying in ${RETRY_DELAY_MS / 1000}s):`,
|
||||
error,
|
||||
);
|
||||
setTimeout(discoverOidc, RETRY_DELAY_MS);
|
||||
}
|
||||
}
|
||||
|
||||
if (OIDC_ENABLED) {
|
||||
// Deliberately not awaited: a slow/unreachable issuer must not delay the
|
||||
// server binding its port (discovery's own default timeout is 30s, which
|
||||
// would otherwise stall every deployment's startup and health checks, even
|
||||
// ones not currently relying on OIDC). isOidcReady() reflects completion,
|
||||
// and the retry loop recovers automatically from transient IdP outages
|
||||
// without requiring a container restart.
|
||||
void discoverOidc();
|
||||
}
|
||||
|
||||
// OIDC_ENABLED only reflects that the env vars are set; this reflects whether
|
||||
// discovery actually succeeded, i.e. whether the login button should be usable.
|
||||
export function isOidcReady() {
|
||||
return oidcConfig !== undefined;
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ import { deleteJob } from "./pages/deleteJob";
|
|||
import { download } from "./pages/download";
|
||||
import { history } from "./pages/history";
|
||||
import { listConverters } from "./pages/listConverters";
|
||||
import { oidc } from "./pages/oidc";
|
||||
import { results } from "./pages/results";
|
||||
import { root } from "./pages/root";
|
||||
import { upload } from "./pages/upload";
|
||||
|
|
@ -39,6 +40,7 @@ const app = new Elysia({
|
|||
}),
|
||||
)
|
||||
.use(user)
|
||||
.use(oidc)
|
||||
.use(root)
|
||||
.use(upload)
|
||||
.use(history)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,207 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { Elysia, t } from "elysia";
|
||||
import * as client from "openid-client";
|
||||
import db from "../db/db";
|
||||
import { User } from "../db/types";
|
||||
import { HTTP_ALLOWED, OIDC_ISSUER, OIDC_REDIRECT_URI, OIDC_SCOPES, WEBROOT } from "../helpers/env";
|
||||
import { oidcConfig } from "../helpers/oidcClient";
|
||||
import { markFirstRunComplete, userService } from "./user";
|
||||
|
||||
const flowCookiePath = `${WEBROOT}/login/oidc`;
|
||||
|
||||
// PKCE verifier/state/nonce are all base64url (oauth4webapi's randomBytes()),
|
||||
// so "." can never appear in a value and is safe as a delimiter here. This
|
||||
// also avoids Elysia's cookie parser, which auto-JSON.parses any cookie value
|
||||
// that looks like `{...}`/`[...]` - a JSON-encoded cookie here would get
|
||||
// silently turned into an object before the route's `t.String()` schema ever
|
||||
// saw it, failing validation before the handler even runs.
|
||||
function parseFlowCookie(
|
||||
value: string,
|
||||
): { code_verifier: string; state: string; nonce: string } | null {
|
||||
const parts = value.split(".");
|
||||
if (parts.length !== 3 || parts.some((part) => part.length === 0)) {
|
||||
return null;
|
||||
}
|
||||
const [code_verifier, state, nonce] = parts as [string, string, string];
|
||||
return { code_verifier, state, nonce };
|
||||
}
|
||||
|
||||
export const oidc = new Elysia()
|
||||
.use(userService)
|
||||
.get(
|
||||
"/login/oidc",
|
||||
async ({ redirect, cookie: { oidcFlow } }) => {
|
||||
if (!oidcConfig) {
|
||||
return redirect(`${WEBROOT}/login`, 302);
|
||||
}
|
||||
|
||||
const code_verifier = client.randomPKCECodeVerifier();
|
||||
const code_challenge = await client.calculatePKCECodeChallenge(code_verifier);
|
||||
const state = client.randomState();
|
||||
const nonce = client.randomNonce();
|
||||
|
||||
oidcFlow.set({
|
||||
value: `${code_verifier}.${state}.${nonce}`,
|
||||
httpOnly: true,
|
||||
secure: !HTTP_ALLOWED,
|
||||
sameSite: "lax",
|
||||
maxAge: 60 * 10,
|
||||
path: flowCookiePath,
|
||||
});
|
||||
|
||||
const redirectTo = client.buildAuthorizationUrl(oidcConfig, {
|
||||
redirect_uri: OIDC_REDIRECT_URI,
|
||||
scope: OIDC_SCOPES,
|
||||
code_challenge,
|
||||
code_challenge_method: "S256",
|
||||
state,
|
||||
nonce,
|
||||
});
|
||||
|
||||
return redirect(redirectTo.href, 302);
|
||||
},
|
||||
{
|
||||
cookie: t.Cookie({
|
||||
oidcFlow: t.Optional(t.String()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/login/oidc/callback",
|
||||
async ({ request, redirect, jwt, cookie: { auth, oidcFlow } }) => {
|
||||
if (!oidcConfig || !oidcFlow?.value) {
|
||||
return redirect(`${WEBROOT}/login`, 302);
|
||||
}
|
||||
|
||||
const flow = parseFlowCookie(oidcFlow.value);
|
||||
oidcFlow.path = flowCookiePath;
|
||||
oidcFlow.remove();
|
||||
|
||||
if (!flow) {
|
||||
console.error("OIDC: malformed oidcFlow cookie");
|
||||
return redirect(`${WEBROOT}/login`, 302);
|
||||
}
|
||||
const { code_verifier, state, nonce } = flow;
|
||||
|
||||
// openid-client derives the redirect_uri it sends to the token endpoint from
|
||||
// this URL's origin+path (stripped of query/hash) - it must be the public,
|
||||
// admin-configured OIDC_REDIRECT_URI, not request.url's scheme/host. Behind a
|
||||
// reverse proxy or tunnel (Cloudflare Tunnel, nginx, etc.) that terminates TLS
|
||||
// and forwards to the origin over plain HTTP, request.url would otherwise
|
||||
// report "http://" even though the browser used "https://", causing a
|
||||
// redirect_uri mismatch at the provider. Only the query string (code, state)
|
||||
// from the actual request is needed here.
|
||||
const callbackUrl = new URL(OIDC_REDIRECT_URI);
|
||||
callbackUrl.search = new URL(request.url).search;
|
||||
|
||||
let tokens: Awaited<ReturnType<typeof client.authorizationCodeGrant>>;
|
||||
try {
|
||||
tokens = await client.authorizationCodeGrant(oidcConfig, callbackUrl, {
|
||||
pkceCodeVerifier: code_verifier,
|
||||
expectedState: state,
|
||||
expectedNonce: nonce,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("OIDC: callback/token exchange failed:", error);
|
||||
return redirect(`${WEBROOT}/login`, 302);
|
||||
}
|
||||
|
||||
const claims = tokens.claims();
|
||||
if (!claims?.sub) {
|
||||
console.error("OIDC: no subject claim in ID token");
|
||||
return redirect(`${WEBROOT}/login`, 302);
|
||||
}
|
||||
|
||||
let email = typeof claims.email === "string" ? claims.email : undefined;
|
||||
let emailVerified = claims.email_verified === true;
|
||||
if (!email) {
|
||||
try {
|
||||
const userinfo = await client.fetchUserInfo(oidcConfig, tokens.access_token, claims.sub);
|
||||
email = typeof userinfo.email === "string" ? userinfo.email : undefined;
|
||||
emailVerified = userinfo.email_verified === true;
|
||||
} catch (error) {
|
||||
console.error("OIDC: failed to fetch userinfo:", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!email) {
|
||||
console.error("OIDC: identity provider did not return an email claim");
|
||||
return redirect(`${WEBROOT}/login`, 302);
|
||||
}
|
||||
|
||||
// sub is only guaranteed unique within its issuer, so identity is the (issuer, sub) pair.
|
||||
let user = db
|
||||
.query("SELECT * FROM users WHERE oidc_issuer = ? AND oidc_sub = ?")
|
||||
.as(User)
|
||||
.get(OIDC_ISSUER, claims.sub);
|
||||
|
||||
if (!user) {
|
||||
// Only auto-link to an existing local account when the provider has
|
||||
// positively verified the email - otherwise an unverified/attacker-set
|
||||
// email claim could hijack an existing account.
|
||||
if (emailVerified) {
|
||||
const existingByEmail = db
|
||||
.query("SELECT * FROM users WHERE email = ?")
|
||||
.as(User)
|
||||
.get(email);
|
||||
if (existingByEmail) {
|
||||
db.query("UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?").run(
|
||||
claims.sub,
|
||||
OIDC_ISSUER,
|
||||
existingByEmail.id,
|
||||
);
|
||||
user = existingByEmail;
|
||||
}
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
const isFirstUser = db.query("SELECT * FROM users").get() === null;
|
||||
// Local password login stays disabled for SSO-provisioned accounts;
|
||||
// this hash is never revealed and the field is only NOT NULL for schema reasons.
|
||||
const unusablePassword = await Bun.password.hash(randomUUID());
|
||||
// ON CONFLICT guards against two concurrent first-logins for the same
|
||||
// new identity both passing the SELECT above and racing to insert.
|
||||
db.query(
|
||||
`INSERT INTO users (email, password, oidc_sub, oidc_issuer)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(oidc_issuer, oidc_sub) DO NOTHING`,
|
||||
).run(email, unusablePassword, claims.sub, OIDC_ISSUER);
|
||||
user = db
|
||||
.query("SELECT * FROM users WHERE oidc_issuer = ? AND oidc_sub = ?")
|
||||
.as(User)
|
||||
.get(OIDC_ISSUER, claims.sub);
|
||||
|
||||
if (isFirstUser && user) {
|
||||
markFirstRunComplete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
console.error("OIDC: failed to provision local user record");
|
||||
return redirect(`${WEBROOT}/login`, 302);
|
||||
}
|
||||
|
||||
const accessToken = await jwt.sign({ id: String(user.id) });
|
||||
|
||||
if (!auth) {
|
||||
return redirect(`${WEBROOT}/login`, 302);
|
||||
}
|
||||
|
||||
auth.set({
|
||||
value: accessToken,
|
||||
httpOnly: true,
|
||||
secure: !HTTP_ALLOWED,
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
sameSite: "strict",
|
||||
});
|
||||
|
||||
return redirect(`${WEBROOT}/`, 302);
|
||||
},
|
||||
{
|
||||
cookie: t.Cookie({
|
||||
auth: t.Optional(t.String()),
|
||||
oidcFlow: t.Optional(t.String()),
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
|
@ -11,6 +11,7 @@ import {
|
|||
ALLOW_UNAUTHENTICATED,
|
||||
HIDE_HISTORY,
|
||||
HTTP_ALLOWED,
|
||||
OIDC_ONLY,
|
||||
UNAUTHENTICATED_USER_SHARING,
|
||||
WEBROOT,
|
||||
} from "../helpers/env";
|
||||
|
|
@ -20,7 +21,7 @@ export const root = new Elysia().use(userService).get(
|
|||
"/",
|
||||
async ({ jwt, redirect, cookie: { auth, jobId } }) => {
|
||||
if (!ALLOW_UNAUTHENTICATED) {
|
||||
if (FIRST_RUN) {
|
||||
if (FIRST_RUN && !OIDC_ONLY) {
|
||||
return redirect(`${WEBROOT}/setup`, 302);
|
||||
}
|
||||
|
||||
|
|
@ -109,7 +110,7 @@ export const root = new Elysia().use(userService).get(
|
|||
<>
|
||||
<Header
|
||||
webroot={WEBROOT}
|
||||
accountRegistration={ACCOUNT_REGISTRATION}
|
||||
accountRegistration={ACCOUNT_REGISTRATION && !OIDC_ONLY}
|
||||
allowUnauthenticated={ALLOW_UNAUTHENTICATED}
|
||||
hideHistory={HIDE_HISTORY}
|
||||
loggedIn
|
||||
|
|
|
|||
|
|
@ -10,11 +10,20 @@ import {
|
|||
ALLOW_UNAUTHENTICATED,
|
||||
HIDE_HISTORY,
|
||||
HTTP_ALLOWED,
|
||||
OIDC_NAME,
|
||||
OIDC_ONLY,
|
||||
WEBROOT,
|
||||
} from "../helpers/env";
|
||||
import { isOidcReady } from "../helpers/oidcClient";
|
||||
|
||||
export let FIRST_RUN = db.query("SELECT * FROM users").get() === null || false;
|
||||
|
||||
// Called once the OIDC callback provisions the very first local user, so
|
||||
// FIRST_RUN-gated routes (e.g. GET / and GET /login) stop redirecting to /setup.
|
||||
export function markFirstRunComplete() {
|
||||
FIRST_RUN = false;
|
||||
}
|
||||
|
||||
export const userService = new Elysia({ name: "user/service" })
|
||||
.use(
|
||||
jwt({
|
||||
|
|
@ -66,7 +75,7 @@ export const userService = new Elysia({ name: "user/service" })
|
|||
export const user = new Elysia()
|
||||
.use(userService)
|
||||
.get("/setup", ({ redirect }) => {
|
||||
if (!FIRST_RUN) {
|
||||
if (!FIRST_RUN || OIDC_ONLY) {
|
||||
return redirect(`${WEBROOT}/login`, 302);
|
||||
}
|
||||
|
||||
|
|
@ -127,7 +136,7 @@ export const user = new Elysia()
|
|||
);
|
||||
})
|
||||
.get("/register", ({ redirect }) => {
|
||||
if (!ACCOUNT_REGISTRATION) {
|
||||
if (!ACCOUNT_REGISTRATION || OIDC_ONLY) {
|
||||
return redirect(`${WEBROOT}/login`, 302);
|
||||
}
|
||||
|
||||
|
|
@ -136,7 +145,7 @@ export const user = new Elysia()
|
|||
<>
|
||||
<Header
|
||||
webroot={WEBROOT}
|
||||
accountRegistration={ACCOUNT_REGISTRATION}
|
||||
accountRegistration={ACCOUNT_REGISTRATION && !OIDC_ONLY}
|
||||
allowUnauthenticated={ALLOW_UNAUTHENTICATED}
|
||||
hideHistory={HIDE_HISTORY}
|
||||
/>
|
||||
|
|
@ -183,7 +192,7 @@ export const user = new Elysia()
|
|||
.post(
|
||||
"/register",
|
||||
async ({ body: { email, password }, set, redirect, jwt, cookie: { auth } }) => {
|
||||
if (!ACCOUNT_REGISTRATION && !FIRST_RUN) {
|
||||
if (OIDC_ONLY || (!ACCOUNT_REGISTRATION && !FIRST_RUN)) {
|
||||
return redirect(`${WEBROOT}/login`, 302);
|
||||
}
|
||||
|
||||
|
|
@ -238,7 +247,7 @@ export const user = new Elysia()
|
|||
.get(
|
||||
"/login",
|
||||
async ({ jwt, redirect, cookie: { auth } }) => {
|
||||
if (FIRST_RUN) {
|
||||
if (FIRST_RUN && !OIDC_ONLY) {
|
||||
return redirect(`${WEBROOT}/setup`, 302);
|
||||
}
|
||||
|
||||
|
|
@ -258,7 +267,7 @@ export const user = new Elysia()
|
|||
<>
|
||||
<Header
|
||||
webroot={WEBROOT}
|
||||
accountRegistration={ACCOUNT_REGISTRATION}
|
||||
accountRegistration={ACCOUNT_REGISTRATION && !OIDC_ONLY}
|
||||
allowUnauthenticated={ALLOW_UNAUTHENTICATED}
|
||||
hideHistory={HIDE_HISTORY}
|
||||
/>
|
||||
|
|
@ -269,44 +278,62 @@ export const user = new Elysia()
|
|||
`}
|
||||
>
|
||||
<article class="article">
|
||||
<form method="post" class="flex flex-col gap-4">
|
||||
<fieldset class="mb-4 flex flex-col gap-4">
|
||||
<label class="flex flex-col gap-1">
|
||||
Email
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
class="rounded-sm bg-neutral-800 p-3"
|
||||
placeholder="Email"
|
||||
autocomplete="email"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
Password
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
class="rounded-sm bg-neutral-800 p-3"
|
||||
placeholder="Password"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
</fieldset>
|
||||
<div class="flex flex-row gap-4">
|
||||
{ACCOUNT_REGISTRATION ? (
|
||||
<a
|
||||
href={`${WEBROOT}/register`}
|
||||
role="button"
|
||||
class="w-full btn-secondary text-center"
|
||||
>
|
||||
Register
|
||||
</a>
|
||||
) : null}
|
||||
<input type="submit" value="Login" class="w-full btn-primary" />
|
||||
{!OIDC_ONLY ? (
|
||||
<form method="post" class="flex flex-col gap-4">
|
||||
<fieldset class="mb-4 flex flex-col gap-4">
|
||||
<label class="flex flex-col gap-1">
|
||||
Email
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
class="rounded-sm bg-neutral-800 p-3"
|
||||
placeholder="Email"
|
||||
autocomplete="email"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
Password
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
class="rounded-sm bg-neutral-800 p-3"
|
||||
placeholder="Password"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
</fieldset>
|
||||
<div class="flex flex-row gap-4">
|
||||
{ACCOUNT_REGISTRATION ? (
|
||||
<a
|
||||
href={`${WEBROOT}/register`}
|
||||
role="button"
|
||||
class="w-full btn-secondary text-center"
|
||||
>
|
||||
Register
|
||||
</a>
|
||||
) : null}
|
||||
<input type="submit" value="Login" class="w-full btn-primary" />
|
||||
</div>
|
||||
</form>
|
||||
) : null}
|
||||
{isOidcReady() && !OIDC_ONLY ? (
|
||||
<div class="my-4 flex items-center gap-4 text-sm text-neutral-500">
|
||||
<hr class="flex-1 border-neutral-700" />
|
||||
or
|
||||
<hr class="flex-1 border-neutral-700" />
|
||||
</div>
|
||||
</form>
|
||||
) : null}
|
||||
{isOidcReady() ? (
|
||||
<a
|
||||
href={`${WEBROOT}/login/oidc`}
|
||||
role="button"
|
||||
class={`block w-full btn-primary text-center`}
|
||||
>
|
||||
Login with {OIDC_NAME}
|
||||
</a>
|
||||
) : null}
|
||||
</article>
|
||||
</main>
|
||||
</>
|
||||
|
|
@ -318,6 +345,10 @@ export const user = new Elysia()
|
|||
.post(
|
||||
"/login",
|
||||
async function handler({ body, set, redirect, jwt, cookie: { auth } }) {
|
||||
if (OIDC_ONLY) {
|
||||
return redirect(`${WEBROOT}/login`, 302);
|
||||
}
|
||||
|
||||
const existingUser = db.query("SELECT * FROM users WHERE email = ?").as(User).get(body.email);
|
||||
|
||||
if (!existingUser) {
|
||||
|
|
@ -392,7 +423,7 @@ export const user = new Elysia()
|
|||
<>
|
||||
<Header
|
||||
webroot={WEBROOT}
|
||||
accountRegistration={ACCOUNT_REGISTRATION}
|
||||
accountRegistration={ACCOUNT_REGISTRATION && !OIDC_ONLY}
|
||||
allowUnauthenticated={ALLOW_UNAUTHENTICATED}
|
||||
hideHistory={HIDE_HISTORY}
|
||||
loggedIn
|
||||
|
|
|
|||
Loading…
Reference in New Issue