feat(auth): add OIDC/SSO login support (e.g. Authentik)

Adds a generic OpenID Connect authorization-code + PKCE login flow
(via openid-client) alongside the existing local email/password auth,
so ConvertX can be deployed behind an identity provider like Authentik,
Keycloak, or any standards-compliant OIDC issuer.

- New GET /login/oidc and /login/oidc/callback routes handle the
  authorization redirect and token exchange, then mint the same JWT
  session cookie issued by password login, so every existing
  authenticated route works unchanged.
- Users are matched by OIDC subject, auto-linked to an existing local
  account by email, or auto-provisioned on first login.
- New OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET,
  OIDC_REDIRECT_URI, OIDC_SCOPES, OIDC_NAME and OIDC_ONLY env vars
  configure and optionally gate out local login entirely.
- README documents the new env vars and a step-by-step Authentik
  provider/application setup.
This commit is contained in:
KeplerAeroIT 2026-07-31 14:31:48 +05:30
parent dc61643912
commit d7d9aae148
12 changed files with 327 additions and 45 deletions

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

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

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

View File

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

View File

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

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)

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

@ -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<ReturnType<typeof client.discovery>> | 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<ReturnType<typeof client.authorizationCodeGrant>>;
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()),
}),
},
);

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

View File

@ -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()
`}
>
<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}
{OIDC_ENABLED && !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}
{OIDC_ENABLED ? (
<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) {