diff --git a/.gitignore b/.gitignore index 6fb8d4d..7393727 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ yarn-debug.log* yarn-error.log* # local env files +.env .env.local .env.development.local .env.test.local diff --git a/README.md b/README.md index 7822aa0..b9b1321 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,37 @@ All are optional, JWT_SECRET is recommended to be set. | 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//`. +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 diff --git a/bun.lock b/bun.lock index 7afc81e..591c25a 100644 --- a/bun.lock +++ b/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=="], diff --git a/compose.yaml b/compose.yaml index 2af5058..8ecec6d 100644 --- a/compose.yaml +++ b/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/Koltaka # 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 diff --git a/package.json b/package.json index b44d91a..0b59b40 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src/db/db.ts b/src/db/db.ts index de57268..3b7ca5c 100644 --- a/src/db/db.ts +++ b/src/db/db.ts @@ -9,7 +9,8 @@ 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 ); CREATE TABLE IF NOT EXISTS file_names ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -27,7 +28,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 = 2;`); } const dbVersion = (db.query("PRAGMA user_version").get() as { user_version?: number }).user_version; @@ -36,6 +37,11 @@ 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."); +} // enable WAL mode db.exec("PRAGMA journal_mode = WAL;"); diff --git a/src/db/types.ts b/src/db/types.ts index 4825711..d6719c1 100644 --- a/src/db/types.ts +++ b/src/db/types.ts @@ -20,4 +20,5 @@ export class User { id!: number; email!: string; password!: string; + oidc_sub!: string | null; } diff --git a/src/helpers/env.ts b/src/helpers/env.ts index 53f6a8f..1b2e1c9 100644 --- a/src/helpers/env.ts +++ b/src/helpers/env.ts @@ -25,3 +25,21 @@ 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"; diff --git a/src/index.tsx b/src/index.tsx index b48f31a..7074f1d 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -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) diff --git a/src/pages/oidc.tsx b/src/pages/oidc.tsx new file mode 100644 index 0000000..a87c323 --- /dev/null +++ b/src/pages/oidc.tsx @@ -0,0 +1,174 @@ +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_CLIENT_ID, + OIDC_CLIENT_SECRET, + OIDC_ENABLED, + OIDC_ISSUER, + OIDC_REDIRECT_URI, + OIDC_SCOPES, + WEBROOT, +} from "../helpers/env"; +import { markFirstRunComplete, userService } from "./user"; + +let oidcConfig: Awaited> | undefined; + +if (OIDC_ENABLED) { + 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:", error); + } +} + +const flowCookiePath = `${WEBROOT}/login/oidc`; + +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: JSON.stringify({ 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 { code_verifier, state, nonce } = JSON.parse(oidcFlow.value) as { + code_verifier: string; + state: string; + nonce: string; + }; + oidcFlow.path = flowCookiePath; + oidcFlow.remove(); + + let tokens: Awaited>; + try { + tokens = await client.authorizationCodeGrant(oidcConfig, new URL(request.url), { + 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; + if (!email) { + try { + const userinfo = await client.fetchUserInfo(oidcConfig, tokens.access_token, claims.sub); + email = typeof userinfo.email === "string" ? userinfo.email : undefined; + } 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); + } + + let user = db.query("SELECT * FROM users WHERE oidc_sub = ?").as(User).get(claims.sub); + + if (!user) { + const existingByEmail = db.query("SELECT * FROM users WHERE email = ?").as(User).get(email); + + if (existingByEmail) { + // Link the existing local account to this OIDC identity. + db.query("UPDATE users SET oidc_sub = ? WHERE id = ?").run(claims.sub, existingByEmail.id); + user = existingByEmail; + } else { + 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()); + db.query("INSERT INTO users (email, password, oidc_sub) VALUES (?, ?, ?)").run( + email, + unusablePassword, + claims.sub, + ); + user = db.query("SELECT * FROM users WHERE oidc_sub = ?").as(User).get(claims.sub); + + if (isFirstUser) { + 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()), + }), + }, +); diff --git a/src/pages/root.tsx b/src/pages/root.tsx index bf1a73c..29ec14f 100644 --- a/src/pages/root.tsx +++ b/src/pages/root.tsx @@ -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); } diff --git a/src/pages/user.tsx b/src/pages/user.tsx index 0fd9065..6749269 100644 --- a/src/pages/user.tsx +++ b/src/pages/user.tsx @@ -10,11 +10,20 @@ import { ALLOW_UNAUTHENTICATED, HIDE_HISTORY, HTTP_ALLOWED, + OIDC_ENABLED, + OIDC_NAME, + OIDC_ONLY, WEBROOT, } from "../helpers/env"; 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); } @@ -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); } @@ -269,44 +278,62 @@ export const user = new Elysia() `} >
-
-
- - -
-
- {ACCOUNT_REGISTRATION ? ( - - Register - - ) : null} - + {!OIDC_ONLY ? ( + +
+ + +
+
+ {ACCOUNT_REGISTRATION ? ( + + Register + + ) : null} + +
+ + ) : null} + {OIDC_ENABLED && !OIDC_ONLY ? ( +
+
+ or +
- + ) : null} + {OIDC_ENABLED ? ( + + Login with {OIDC_NAME} + + ) : null}
@@ -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) {