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:
parent
dc61643912
commit
d7d9aae148
|
|
@ -25,6 +25,7 @@ yarn-debug.log*
|
|||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
|
|
|
|||
31
README.md
31
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/<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/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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
10
src/db/db.ts
10
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;");
|
||||
|
|
|
|||
|
|
@ -20,4 +20,5 @@ export class User {
|
|||
id!: number;
|
||||
email!: string;
|
||||
password!: string;
|
||||
oidc_sub!: string | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,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()),
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue