This commit is contained in:
Aditya Pagaria 2026-08-05 13:07:58 +00:00 committed by GitHub
commit 3fee78a437
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 545 additions and 63 deletions

94
.env.example Normal file
View File

@ -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

1
.gitignore vendored
View File

@ -25,6 +25,7 @@ yarn-debug.log*
yarn-error.log*
# local env files
.env
.env.local
.env.development.local
.env.test.local

View File

@ -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

View File

@ -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=="],

View File

@ -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

17
docker-compose.prod.yml Normal file
View File

@ -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

View File

@ -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"
},

View File

@ -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;");

View File

@ -20,4 +20,6 @@ export class User {
id!: number;
email!: string;
password!: string;
oidc_sub!: string | null;
oidc_issuer!: string | null;
}

View File

@ -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";

35
src/helpers/oidcClient.ts Normal file
View File

@ -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;
}

View File

@ -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)

207
src/pages/oidc.tsx Normal file
View File

@ -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()),
}),
},
);

View File

@ -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

View File

@ -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