mirror of
https://github.com/Drop-OSS/drop.git
synced 2026-01-31 15:37:09 +01:00
Add ODIC Back-Channel Logout (#304)
* prevent returning expired sessions * add issuer to ODIC creds * get id token in ODIC * make session signin return session * working backchannel logout? * require https for ODIC provider * handle wellknown not being https * find session api progress * fix windows build * return session token on session * switch OIDC to #searchSessions * update pnpm * switch to using message on error obj * move odic callback * fix type errors * redirect old oidc callback * make redirect url a URL * remove scheduled task downloadCleanup * fix session search for oidc * fix signin result * cleanup code * ignore data dir * fix lint error
This commit is contained in:
@@ -3,3 +3,5 @@ drop-base/
|
|||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
|
|
||||||
torrential/
|
torrential/
|
||||||
|
.data/**
|
||||||
|
**/.data/**
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
|
import { globalIgnores } from "eslint/config";
|
||||||
import withNuxt from "./.nuxt/eslint.config.mjs";
|
import withNuxt from "./.nuxt/eslint.config.mjs";
|
||||||
import eslintConfigPrettier from "eslint-config-prettier/flat";
|
import eslintConfigPrettier from "eslint-config-prettier/flat";
|
||||||
import vueI18n from "@intlify/eslint-plugin-vue-i18n";
|
import vueI18n from "@intlify/eslint-plugin-vue-i18n";
|
||||||
import noPrismaDelete from "./rules/no-prisma-delete.mts";
|
import noPrismaDelete from "./rules/no-prisma-delete.mts";
|
||||||
|
|
||||||
export default withNuxt([
|
export default withNuxt([
|
||||||
|
globalIgnores([".data/*"]),
|
||||||
|
|
||||||
eslintConfigPrettier,
|
eslintConfigPrettier,
|
||||||
|
|
||||||
// vue-i18n plugin
|
// vue-i18n plugin
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import tailwindcss from "@tailwindcss/vite";
|
|||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import { readFileSync, existsSync } from "node:fs";
|
import { readFileSync, existsSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import module from "module";
|
import module from "node:module";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
|
|
||||||
const packageJsonSchema = type({
|
const packageJsonSchema = type({
|
||||||
@@ -91,6 +92,11 @@ export default defineNuxtConfig({
|
|||||||
|
|
||||||
routeRules: {
|
routeRules: {
|
||||||
"/api/**": { cors: true },
|
"/api/**": { cors: true },
|
||||||
|
|
||||||
|
// redirect old OIDC callback route
|
||||||
|
"/auth/callback/oidc": {
|
||||||
|
redirect: "/api/v1/auth/odic/callback",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
nitro: {
|
nitro: {
|
||||||
@@ -116,7 +122,6 @@ export default defineNuxtConfig({
|
|||||||
|
|
||||||
scheduledTasks: {
|
scheduledTasks: {
|
||||||
"0 * * * *": ["dailyTasks"],
|
"0 * * * *": ["dailyTasks"],
|
||||||
"*/30 * * * *": ["downloadCleanup"],
|
|
||||||
},
|
},
|
||||||
|
|
||||||
storage: {
|
storage: {
|
||||||
@@ -266,11 +271,7 @@ function getDropVersion(): string {
|
|||||||
// example nightly: "v0.3.0-nightly.2025.05.28"
|
// example nightly: "v0.3.0-nightly.2025.05.28"
|
||||||
const defaultVersion = "v0.0.0-alpha.0";
|
const defaultVersion = "v0.0.0-alpha.0";
|
||||||
|
|
||||||
// get path
|
const packageJsonPath = fileURLToPath(import.meta.resolve("./package.json"));
|
||||||
const packageJsonPath = path.join(
|
|
||||||
path.dirname(import.meta.url.replace("file://", "")),
|
|
||||||
"package.json",
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!existsSync(packageJsonPath)) {
|
if (!existsSync(packageJsonPath)) {
|
||||||
console.error("Could not find package.json, using default version.");
|
console.error("Could not find package.json, using default version.");
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
"file-type-mime": "^0.4.3",
|
"file-type-mime": "^0.4.3",
|
||||||
"jdenticon": "^3.3.0",
|
"jdenticon": "^3.3.0",
|
||||||
"kjua": "^0.10.0",
|
"kjua": "^0.10.0",
|
||||||
|
"jose": "^6.1.3",
|
||||||
"luxon": "^3.6.1",
|
"luxon": "^3.6.1",
|
||||||
"micromark": "^4.0.1",
|
"micromark": "^4.0.1",
|
||||||
"normalize-url": "^8.0.2",
|
"normalize-url": "^8.0.2",
|
||||||
@@ -93,5 +94,5 @@
|
|||||||
"vue3-carousel": "^0.16.0"
|
"vue3-carousel": "^0.16.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.15.0+sha512.486ebc259d3e999a4e8691ce03b5cac4a71cbeca39372a9b762cb500cfdf0873e2cb16abe3d951b1ee2cf012503f027b98b6584e4df22524e0c7450d9ec7aa7b"
|
"packageManager": "pnpm@10.27.0+sha512.72d699da16b1179c14ba9e64dc71c9a40988cbdc65c264cb0e489db7de917f20dcf4d64d8723625f2969ba52d4b7e2a1170682d9ac2a5dcaeaab732b7e16f04a"
|
||||||
}
|
}
|
||||||
|
|||||||
11
pnpm-lock.yaml
generated
11
pnpm-lock.yaml
generated
@@ -4,9 +4,6 @@ settings:
|
|||||||
autoInstallPeers: true
|
autoInstallPeers: true
|
||||||
excludeLinksFromLockfile: false
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
overrides:
|
|
||||||
droplet: link:../../.local/share/pnpm/global/5/node_modules/@drop-oss/droplet
|
|
||||||
|
|
||||||
importers:
|
importers:
|
||||||
|
|
||||||
.:
|
.:
|
||||||
@@ -80,6 +77,9 @@ importers:
|
|||||||
jdenticon:
|
jdenticon:
|
||||||
specifier: ^3.3.0
|
specifier: ^3.3.0
|
||||||
version: 3.3.0
|
version: 3.3.0
|
||||||
|
jose:
|
||||||
|
specifier: ^6.1.3
|
||||||
|
version: 6.1.3
|
||||||
kjua:
|
kjua:
|
||||||
specifier: ^0.10.0
|
specifier: ^0.10.0
|
||||||
version: 0.10.0
|
version: 0.10.0
|
||||||
@@ -4461,6 +4461,9 @@ packages:
|
|||||||
jose@4.15.9:
|
jose@4.15.9:
|
||||||
resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==}
|
resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==}
|
||||||
|
|
||||||
|
jose@6.1.3:
|
||||||
|
resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==}
|
||||||
|
|
||||||
joycon@3.1.1:
|
joycon@3.1.1:
|
||||||
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
|
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -11577,6 +11580,8 @@ snapshots:
|
|||||||
|
|
||||||
jose@4.15.9: {}
|
jose@4.15.9: {}
|
||||||
|
|
||||||
|
jose@6.1.3: {}
|
||||||
|
|
||||||
joycon@3.1.1: {}
|
joycon@3.1.1: {}
|
||||||
|
|
||||||
js-base64@3.7.7: {}
|
js-base64@3.7.7: {}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ onlyBuiltDependencies:
|
|||||||
- sharp
|
- sharp
|
||||||
- unrs-resolver
|
- unrs-resolver
|
||||||
|
|
||||||
overrides:
|
# overrides:
|
||||||
droplet: link:../../.local/share/pnpm/global/5/node_modules/@drop-oss/droplet
|
# droplet: link:../../.local/share/pnpm/global/5/node_modules/@drop-oss/droplet
|
||||||
|
|
||||||
shamefullyHoist: true
|
shamefullyHoist: true
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
import sessionHandler from "~/server/internal/session";
|
import sessionHandler from "~/server/internal/session";
|
||||||
import authManager from "~/server/internal/auth";
|
import authManager from "~/server/internal/auth";
|
||||||
|
import type { Session } from "~/server/internal/session/types";
|
||||||
|
|
||||||
defineRouteMeta({
|
defineRouteMeta({
|
||||||
openAPI: {
|
openAPI: {
|
||||||
tags: ["Auth"],
|
tags: ["Auth", "OIDC"],
|
||||||
description: "OIDC Signin callback",
|
description: "OIDC Signin callback",
|
||||||
parameters: [],
|
parameters: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default defineEventHandler(async (h3) => {
|
export default defineEventHandler(async (h3) => {
|
||||||
|
// dont cache login responses
|
||||||
|
setHeader(h3, "Cache-Control", "no-store");
|
||||||
|
|
||||||
const enabledAuthManagers = authManager.getAuthProviders();
|
const enabledAuthManagers = authManager.getAuthProviders();
|
||||||
if (!enabledAuthManagers.OpenID) return sendRedirect(h3, "/auth/signin");
|
if (!enabledAuthManagers.OpenID) return sendRedirect(h3, "/auth/signin");
|
||||||
|
|
||||||
@@ -38,11 +42,20 @@ export default defineEventHandler(async (h3) => {
|
|||||||
statusMessage: `Failed to sign in: "${result}". Please try again.`,
|
statusMessage: `Failed to sign in: "${result}". Please try again.`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const sessionResult = await sessionHandler.signin(h3, result.user.id, true);
|
// Attach OIDC session data
|
||||||
|
const oidcData: Session["oidc"] = {
|
||||||
|
iss: result.claims.iss,
|
||||||
|
};
|
||||||
|
if (result.claims.sub) oidcData.sub = result.claims.sub;
|
||||||
|
if (result.claims.sid) oidcData.sid = result.claims.sid;
|
||||||
|
|
||||||
|
const sessionResult = await sessionHandler.signin(h3, result.user.id, {
|
||||||
|
rememberMe: true,
|
||||||
|
oidc: oidcData,
|
||||||
|
});
|
||||||
if (sessionResult == "fail")
|
if (sessionResult == "fail")
|
||||||
throw createError({ statusCode: 500, message: "Failed to set session" });
|
throw createError({ statusCode: 500, message: "Failed to set session" });
|
||||||
|
else if (sessionResult == "2fa") {
|
||||||
if (sessionResult == "2fa") {
|
|
||||||
return sendRedirect(
|
return sendRedirect(
|
||||||
h3,
|
h3,
|
||||||
`/auth/mfa?redirect=${result.options.redirect ? encodeURIComponent(result.options.redirect) : "/"}`,
|
`/auth/mfa?redirect=${result.options.redirect ? encodeURIComponent(result.options.redirect) : "/"}`,
|
||||||
46
server/api/v1/auth/odic/logout.post.ts
Normal file
46
server/api/v1/auth/odic/logout.post.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
// import sessionHandler from "~/server/internal/session";
|
||||||
|
import authManager from "~/server/internal/auth";
|
||||||
|
|
||||||
|
defineRouteMeta({
|
||||||
|
openAPI: {
|
||||||
|
tags: ["Auth", "OIDC"],
|
||||||
|
description: "OIDC logout back-channel",
|
||||||
|
parameters: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default defineEventHandler(async (h3) => {
|
||||||
|
// dont cache logout responses
|
||||||
|
setHeader(h3, "Cache-Control", "no-store");
|
||||||
|
|
||||||
|
const enabledAuthManagers = authManager.getAuthProviders();
|
||||||
|
if (!enabledAuthManagers.OpenID)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: "OIDC not enabled.",
|
||||||
|
});
|
||||||
|
|
||||||
|
const logout_token = (await readFormData(h3)).get("logout_token");
|
||||||
|
if (typeof logout_token !== "string")
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: "Invalid OIDC logout notification.",
|
||||||
|
});
|
||||||
|
const okay = await enabledAuthManagers.OpenID.handleLogout(logout_token);
|
||||||
|
if (!okay) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: "Invalid OIDC logout notification.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// const result = OIDCLogoutTokenV1(logout_token);
|
||||||
|
|
||||||
|
// const manager = enabledAuthManagers.OpenID;
|
||||||
|
|
||||||
|
// const query = getQuery(h3);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -98,7 +98,9 @@ export default defineEventHandler(async (h3) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await sessionHandler.signin(h3, mfaMec.userId, true);
|
await sessionHandler.signin(h3, mfaMec.userId, {
|
||||||
|
rememberMe: true,
|
||||||
|
});
|
||||||
await sessionHandler.mfa(h3, 10);
|
await sessionHandler.mfa(h3, 10);
|
||||||
|
|
||||||
return {};
|
return {};
|
||||||
|
|||||||
@@ -84,17 +84,16 @@ export default defineEventHandler<{
|
|||||||
});
|
});
|
||||||
|
|
||||||
// TODO: send user to forgot password screen or something to force them to change their password to new system
|
// TODO: send user to forgot password screen or something to force them to change their password to new system
|
||||||
const result = await sessionHandler.signin(
|
const result = await sessionHandler.signin(h3, authMek.userId, {
|
||||||
h3,
|
rememberMe: body.rememberMe ?? false,
|
||||||
authMek.userId,
|
});
|
||||||
body.rememberMe,
|
|
||||||
);
|
|
||||||
if (result === "fail")
|
if (result === "fail")
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 500,
|
statusCode: 500,
|
||||||
message: "Failed to create session",
|
message: "Failed to create session",
|
||||||
});
|
});
|
||||||
return { userId: authMek.userId, result };
|
|
||||||
|
return { result: result, userId: authMek.userId };
|
||||||
}
|
}
|
||||||
|
|
||||||
// V2: argon2
|
// V2: argon2
|
||||||
@@ -111,11 +110,9 @@ export default defineEventHandler<{
|
|||||||
statusMessage: t("errors.auth.invalidUserOrPass"),
|
statusMessage: t("errors.auth.invalidUserOrPass"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await sessionHandler.signin(
|
const result = await sessionHandler.signin(h3, authMek.userId, {
|
||||||
h3,
|
rememberMe: body.rememberMe ?? false,
|
||||||
authMek.userId,
|
});
|
||||||
body.rememberMe,
|
|
||||||
);
|
|
||||||
if (result == "fail")
|
if (result == "fail")
|
||||||
throw createError({ statusCode: 500, message: "Failed to create session" });
|
throw createError({ statusCode: 500, message: "Failed to create session" });
|
||||||
return { userId: authMek.userId, result };
|
return { userId: authMek.userId, result };
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class AuthManager {
|
|||||||
private initFuncs: {
|
private initFuncs: {
|
||||||
[K in keyof typeof this.authProviders]: () => Promise<unknown>;
|
[K in keyof typeof this.authProviders]: () => Promise<unknown>;
|
||||||
} = {
|
} = {
|
||||||
[AuthMec.OpenID]: OIDCManager.prototype.create,
|
[AuthMec.OpenID]: OIDCManager.create,
|
||||||
[AuthMec.Simple]: async () => {
|
[AuthMec.Simple]: async () => {
|
||||||
const disabled = process.env.DISABLE_SIMPLE_AUTH as string | undefined;
|
const disabled = process.env.DISABLE_SIMPLE_AUTH as string | undefined;
|
||||||
return !disabled;
|
return !disabled;
|
||||||
|
|||||||
@@ -7,11 +7,31 @@ import type { Readable } from "stream";
|
|||||||
import * as jdenticon from "jdenticon";
|
import * as jdenticon from "jdenticon";
|
||||||
import { systemConfig } from "../../config/sys-conf";
|
import { systemConfig } from "../../config/sys-conf";
|
||||||
import { logger } from "~/server/internal/logging";
|
import { logger } from "~/server/internal/logging";
|
||||||
|
import { type } from "arktype";
|
||||||
|
import * as jose from "jose";
|
||||||
|
// import { inspect } from "util";
|
||||||
|
import sessionHandler from "../../session";
|
||||||
|
import type { SessionSearchTerms } from "../../session/types";
|
||||||
|
|
||||||
interface OIDCWellKnown {
|
// TODO: monitor https://github.com/goauthentik/authentik/issues/8751 for easier?? OIDC setup by end users
|
||||||
authorization_endpoint: string;
|
|
||||||
token_endpoint: string;
|
// Schema for OIDC well-known configuration
|
||||||
userinfo_endpoint: string;
|
const OIDCWellKnownV1 = type({
|
||||||
|
issuer: "string.url.parse",
|
||||||
|
authorization_endpoint: "string.url.parse",
|
||||||
|
token_endpoint: "string.url.parse",
|
||||||
|
userinfo_endpoint: "string.url.parse?",
|
||||||
|
jwks_uri: "string.url.parse",
|
||||||
|
scopes_supported: "string[]?",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Represents required OIDC configuration
|
||||||
|
interface OIDCConfiguration {
|
||||||
|
issuer: URL;
|
||||||
|
authorization_endpoint: URL;
|
||||||
|
token_endpoint: URL;
|
||||||
|
userinfo_endpoint: URL;
|
||||||
|
jwks_uri: URL;
|
||||||
scopes_supported: string[];
|
scopes_supported: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,11 +39,18 @@ interface OIDCAuthSessionOptions {
|
|||||||
redirect: string | undefined;
|
redirect: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface OIDCAuthSessionClaims {
|
||||||
|
iss: string;
|
||||||
|
sub?: string;
|
||||||
|
sid?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface OIDCAuthSession {
|
interface OIDCAuthSession {
|
||||||
redirectUrl: string;
|
redirectUrl: string;
|
||||||
callbackUrl: string;
|
callbackUrl: string;
|
||||||
state: string;
|
state: string;
|
||||||
options: OIDCAuthSessionOptions;
|
options: OIDCAuthSessionOptions;
|
||||||
|
claims: OIDCAuthSessionClaims;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OIDCUserInfo {
|
interface OIDCUserInfo {
|
||||||
@@ -35,15 +62,69 @@ interface OIDCUserInfo {
|
|||||||
groups?: Array<string>;
|
groups?: Array<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OIDCUrlKey = Exclude<keyof OIDCConfiguration, "scopes_supported">;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @see https://openid.net/specs/openid-connect-core-1_0.html#TokenResponse
|
||||||
|
* @see https://www.rfc-editor.org/rfc/rfc6749.html#section-5.1
|
||||||
|
*/
|
||||||
|
const OIDCTokenResponseV1 = type({
|
||||||
|
access_token: "string",
|
||||||
|
token_type: "string",
|
||||||
|
expires_in: "number?",
|
||||||
|
refresh_token: "string?",
|
||||||
|
scope: "string?",
|
||||||
|
id_token: "string",
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @see https://openid.net/specs/openid-connect-core-1_0.html#IDToken
|
||||||
|
*/
|
||||||
|
const OIDCIDTokenV1 = type({
|
||||||
|
iss: "string",
|
||||||
|
sub: "string",
|
||||||
|
aud: "string | string[]",
|
||||||
|
exp: "number",
|
||||||
|
iat: "number",
|
||||||
|
|
||||||
|
auth_time: "number?",
|
||||||
|
nonce: "string?",
|
||||||
|
acr: "string?",
|
||||||
|
amr: "string[]?",
|
||||||
|
azp: "string?",
|
||||||
|
|
||||||
|
// see: https://openid.net/specs/openid-connect-backchannel-1_0.html#BCSupport
|
||||||
|
// and: https://openid.net/specs/openid-connect-rpinitiated-1_0.html#RPLogout
|
||||||
|
sid: "string?", // session ID
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @see https://openid.net/specs/openid-connect-backchannel-1_0-final.html#LogoutToken
|
||||||
|
*/
|
||||||
|
const OIDCLogoutTokenV1 = type({
|
||||||
|
iss: "string",
|
||||||
|
sub: "string?",
|
||||||
|
aud: "string | string[]",
|
||||||
|
iat: "number",
|
||||||
|
jti: "string",
|
||||||
|
events: type({
|
||||||
|
"http://schemas.openid.net/event/backchannel-logout": "object",
|
||||||
|
}),
|
||||||
|
sid: "string?", // session ID
|
||||||
|
});
|
||||||
|
|
||||||
export interface OIDCAuthMekCredentialsV1 {
|
export interface OIDCAuthMekCredentialsV1 {
|
||||||
|
// only optional for compatibility with older versions
|
||||||
|
iss?: string;
|
||||||
sub: string;
|
sub: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class OIDCManager {
|
export class OIDCManager {
|
||||||
private oidcConfiguration: OIDCWellKnown;
|
private oidcConfiguration: OIDCConfiguration;
|
||||||
private clientId: string;
|
private clientId: string;
|
||||||
private clientSecret: string;
|
private clientSecret: string;
|
||||||
private externalUrl: string;
|
private externalUrl: URL;
|
||||||
|
private redirectUrl: URL;
|
||||||
|
|
||||||
private userGroup?: string = process.env.OIDC_USER_GROUP;
|
private userGroup?: string = process.env.OIDC_USER_GROUP;
|
||||||
private adminGroup?: string = process.env.OIDC_ADMIN_GROUP;
|
private adminGroup?: string = process.env.OIDC_ADMIN_GROUP;
|
||||||
@@ -53,55 +134,99 @@ export class OIDCManager {
|
|||||||
|
|
||||||
private signinStateTable: { [key: string]: OIDCAuthSession } = {};
|
private signinStateTable: { [key: string]: OIDCAuthSession } = {};
|
||||||
|
|
||||||
constructor(
|
/**
|
||||||
oidcConfiguration: OIDCWellKnown,
|
* Util to fetch JWKS for verifying tokens
|
||||||
|
* @see https://github.com/panva/jose/blob/main/docs/jwks/remote/functions/createRemoteJWKSet.md
|
||||||
|
*/
|
||||||
|
private JWKS: ReturnType<typeof jose.createRemoteJWKSet>;
|
||||||
|
|
||||||
|
private constructor(
|
||||||
|
oidcConfiguration: OIDCConfiguration,
|
||||||
clientId: string,
|
clientId: string,
|
||||||
clientSecret: string,
|
clientSecret: string,
|
||||||
externalUrl: string,
|
externalUrl: URL,
|
||||||
) {
|
) {
|
||||||
this.oidcConfiguration = oidcConfiguration;
|
this.oidcConfiguration = oidcConfiguration;
|
||||||
this.clientId = clientId;
|
this.clientId = clientId;
|
||||||
this.clientSecret = clientSecret;
|
this.clientSecret = clientSecret;
|
||||||
this.externalUrl = externalUrl;
|
this.externalUrl = externalUrl;
|
||||||
|
|
||||||
|
this.JWKS = jose.createRemoteJWKSet(this.oidcConfiguration.jwks_uri);
|
||||||
|
this.redirectUrl = new URL(
|
||||||
|
`${this.externalUrl.toString()}api/v1/auth/odic/callback`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async create() {
|
static async create() {
|
||||||
const wellKnownUrl = process.env.OIDC_WELLKNOWN as string | undefined;
|
if (!systemConfig.shouldOidcRequireHttps()) {
|
||||||
|
console.warn(
|
||||||
|
"Disabling HTTPS requirement for ODIC provider, not recommened in production enviroments",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const wellKnownUrlString = process.env.OIDC_WELLKNOWN as string | undefined;
|
||||||
const scopes = process.env.OIDC_SCOPES as string | undefined;
|
const scopes = process.env.OIDC_SCOPES as string | undefined;
|
||||||
let configuration: OIDCWellKnown;
|
let configuration: OIDCConfiguration;
|
||||||
if (wellKnownUrl) {
|
if (wellKnownUrlString) {
|
||||||
const response: OIDCWellKnown = await $fetch<OIDCWellKnown>(wellKnownUrl);
|
const wellKnownUrl = new URL(wellKnownUrlString);
|
||||||
if (
|
if (systemConfig.shouldOidcRequireHttps() && !isHttps(wellKnownUrl)) {
|
||||||
!response.authorization_endpoint ||
|
throw new Error("OIDC_WELLKNOWN URL must use HTTPS");
|
||||||
!response.scopes_supported ||
|
|
||||||
!response.token_endpoint ||
|
|
||||||
!response.userinfo_endpoint
|
|
||||||
) {
|
|
||||||
throw new Error("Well known response was invalid");
|
|
||||||
}
|
|
||||||
if (scopes) {
|
|
||||||
response.scopes_supported = scopes.split(",");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
configuration = response;
|
const response = await $fetch<unknown>(wellKnownUrl.toString());
|
||||||
|
const wellKnown = OIDCWellKnownV1(response);
|
||||||
|
if (wellKnown instanceof type.errors) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to parse OIDC well-known configuration: ${wellKnown.summary}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scopes) {
|
||||||
|
wellKnown.scopes_supported = scopes.split(",");
|
||||||
|
} else if (!wellKnown.scopes_supported) {
|
||||||
|
throw new Error(
|
||||||
|
"OIDC_SCOPES environment variable required if not provided by well-known configuration",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!wellKnown.userinfo_endpoint) {
|
||||||
|
throw new Error(
|
||||||
|
"OIDC_USERINFO environment variable required if not provided by well-known configuration",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
configuration = {
|
||||||
|
authorization_endpoint: wellKnown.authorization_endpoint,
|
||||||
|
token_endpoint: wellKnown.token_endpoint,
|
||||||
|
userinfo_endpoint: wellKnown.userinfo_endpoint,
|
||||||
|
scopes_supported: wellKnown.scopes_supported,
|
||||||
|
issuer: wellKnown.issuer,
|
||||||
|
jwks_uri: wellKnown.jwks_uri,
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
const authorizationEndpoint = process.env.OIDC_AUTHORIZATION as
|
const authorizationEndpoint = process.env.OIDC_AUTHORIZATION as
|
||||||
| string
|
| string
|
||||||
| undefined;
|
| undefined;
|
||||||
const tokenEndpoint = process.env.OIDC_TOKEN as string | undefined;
|
const tokenEndpoint = process.env.OIDC_TOKEN as string | undefined;
|
||||||
const userinfoEndpoint = process.env.OIDC_USERINFO as string | undefined;
|
const userinfoEndpoint = process.env.OIDC_USERINFO as string | undefined;
|
||||||
|
const issuer = process.env.OIDC_ISSUER as string | undefined;
|
||||||
|
const jwksEndpoint = process.env.OIDC_JWKS as string | undefined;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!authorizationEndpoint ||
|
!authorizationEndpoint ||
|
||||||
!tokenEndpoint ||
|
!tokenEndpoint ||
|
||||||
!userinfoEndpoint ||
|
!userinfoEndpoint ||
|
||||||
!scopes
|
!scopes ||
|
||||||
|
!issuer ||
|
||||||
|
!jwksEndpoint
|
||||||
) {
|
) {
|
||||||
const debugObject = {
|
const debugObject = {
|
||||||
OIDC_AUTHORIZATION: authorizationEndpoint,
|
OIDC_AUTHORIZATION: authorizationEndpoint,
|
||||||
OIDC_TOKEN: tokenEndpoint,
|
OIDC_TOKEN: tokenEndpoint,
|
||||||
OIDC_USERINFO: userinfoEndpoint,
|
OIDC_USERINFO: userinfoEndpoint,
|
||||||
OIDC_SCOPES: scopes,
|
OIDC_SCOPES: scopes,
|
||||||
|
OIDC_ISSUER: issuer,
|
||||||
|
OIDC_JWKS: jwksEndpoint,
|
||||||
};
|
};
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Missing all necessary OIDC configuration: \n" +
|
"Missing all necessary OIDC configuration: \n" +
|
||||||
@@ -112,19 +237,37 @@ export class OIDCManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
configuration = {
|
configuration = {
|
||||||
authorization_endpoint: authorizationEndpoint,
|
authorization_endpoint: new URL(authorizationEndpoint),
|
||||||
token_endpoint: tokenEndpoint,
|
token_endpoint: new URL(tokenEndpoint),
|
||||||
userinfo_endpoint: userinfoEndpoint,
|
userinfo_endpoint: new URL(userinfoEndpoint),
|
||||||
scopes_supported: scopes.split(","),
|
scopes_supported: scopes.split(","),
|
||||||
|
issuer: new URL(issuer),
|
||||||
|
jwks_uri: new URL(jwksEndpoint),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!configuration)
|
if (!configuration)
|
||||||
throw new Error("OIDC try to init without configuration");
|
throw new Error("OIDC try to init without configuration");
|
||||||
|
|
||||||
|
if (systemConfig.shouldOidcRequireHttps()) {
|
||||||
|
const endpoints: OIDCUrlKey[] = [
|
||||||
|
"authorization_endpoint",
|
||||||
|
"token_endpoint",
|
||||||
|
"userinfo_endpoint",
|
||||||
|
"issuer",
|
||||||
|
"jwks_uri",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const endpoint of endpoints) {
|
||||||
|
if (!isHttps(configuration[endpoint])) {
|
||||||
|
throw new Error(`OIDC ${endpoint} is not using HTTPS`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const clientId = process.env.OIDC_CLIENT_ID as string | undefined;
|
const clientId = process.env.OIDC_CLIENT_ID as string | undefined;
|
||||||
const clientSecret = process.env.OIDC_CLIENT_SECRET as string | undefined;
|
const clientSecret = process.env.OIDC_CLIENT_SECRET as string | undefined;
|
||||||
const externalUrl = systemConfig.getExternalUrl();
|
const externalUrl = new URL(systemConfig.getExternalUrl());
|
||||||
|
|
||||||
if (!clientId || !clientSecret)
|
if (!clientId || !clientSecret)
|
||||||
throw new Error("Missing client ID or secret for OIDC");
|
throw new Error("Missing client ID or secret for OIDC");
|
||||||
@@ -150,17 +293,17 @@ export class OIDCManager {
|
|||||||
const normalisedUrl = new URL(
|
const normalisedUrl = new URL(
|
||||||
this.oidcConfiguration.authorization_endpoint,
|
this.oidcConfiguration.authorization_endpoint,
|
||||||
).toString();
|
).toString();
|
||||||
const redirectNormalisedUrl = new URL(this.externalUrl).toString();
|
|
||||||
|
|
||||||
const redirectUrl = `${redirectNormalisedUrl}auth/callback/oidc`;
|
const finalUrl = `${normalisedUrl}?client_id=${this.clientId}&redirect_uri=${encodeURIComponent(this.redirectUrl.toString())}&state=${stateKey}&response_type=code&scope=${encodeURIComponent(this.oidcConfiguration.scopes_supported.join(" "))}`;
|
||||||
|
|
||||||
const finalUrl = `${normalisedUrl}?client_id=${this.clientId}&redirect_uri=${encodeURIComponent(redirectUrl)}&state=${stateKey}&response_type=code&scope=${encodeURIComponent(this.oidcConfiguration.scopes_supported.join(" "))}`;
|
|
||||||
|
|
||||||
const session: OIDCAuthSession = {
|
const session: OIDCAuthSession = {
|
||||||
redirectUrl: finalUrl,
|
redirectUrl: finalUrl,
|
||||||
callbackUrl: redirectUrl,
|
callbackUrl: this.redirectUrl.toString(),
|
||||||
state: stateKey,
|
state: stateKey,
|
||||||
options: options ?? { redirect: undefined },
|
options: options ?? { redirect: undefined },
|
||||||
|
claims: {
|
||||||
|
iss: this.oidcConfiguration.issuer.toString(),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
this.signinStateTable[stateKey] = session;
|
this.signinStateTable[stateKey] = session;
|
||||||
return session;
|
return session;
|
||||||
@@ -169,16 +312,20 @@ export class OIDCManager {
|
|||||||
async authorize(
|
async authorize(
|
||||||
code: string,
|
code: string,
|
||||||
state: string,
|
state: string,
|
||||||
): Promise<{ user: UserModel; options: OIDCAuthSessionOptions } | string> {
|
): Promise<
|
||||||
|
| {
|
||||||
|
user: UserModel;
|
||||||
|
options: OIDCAuthSessionOptions;
|
||||||
|
claims: OIDCAuthSessionClaims;
|
||||||
|
}
|
||||||
|
| string
|
||||||
|
> {
|
||||||
const session = this.signinStateTable[state];
|
const session = this.signinStateTable[state];
|
||||||
if (!session) return "Invalid state parameter";
|
if (!session) return "Invalid state parameter";
|
||||||
|
|
||||||
const tokenEndpoint = new URL(
|
const tokenEndpoint = this.oidcConfiguration.token_endpoint.toString();
|
||||||
this.oidcConfiguration.token_endpoint,
|
const userinfoEndpoint =
|
||||||
).toString();
|
this.oidcConfiguration.userinfo_endpoint.toString();
|
||||||
const userinfoEndpoint = new URL(
|
|
||||||
this.oidcConfiguration.userinfo_endpoint,
|
|
||||||
).toString();
|
|
||||||
|
|
||||||
const requestBody = new URLSearchParams({
|
const requestBody = new URLSearchParams({
|
||||||
client_id: this.clientId,
|
client_id: this.clientId,
|
||||||
@@ -190,18 +337,35 @@ export class OIDCManager {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { access_token, token_type } = await $fetch<{
|
const rawTokenResponse = await $fetch<unknown>(tokenEndpoint, {
|
||||||
access_token: string;
|
|
||||||
token_type: string;
|
|
||||||
id_token: string;
|
|
||||||
}>(tokenEndpoint, {
|
|
||||||
body: requestBody,
|
body: requestBody,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
});
|
});
|
||||||
|
const tokenResponse = OIDCTokenResponseV1(rawTokenResponse);
|
||||||
|
if (tokenResponse instanceof type.errors) {
|
||||||
|
logger.error(`Invalid OIDC token response: ${tokenResponse.summary}`);
|
||||||
|
return "Invalid token response from identity provider.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: handle refresh tokens?
|
||||||
|
|
||||||
|
const idTokenRaw = await jose.jwtVerify(
|
||||||
|
tokenResponse.id_token,
|
||||||
|
this.JWKS,
|
||||||
|
{
|
||||||
|
audience: this.clientId,
|
||||||
|
issuer: this.oidcConfiguration.issuer.toString(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const idToken = OIDCIDTokenV1(idTokenRaw.payload);
|
||||||
|
if (idToken instanceof type.errors) {
|
||||||
|
logger.error(`Invalid OIDC ID token: ${idToken.summary}`);
|
||||||
|
return "Invalid ID token from identity provider.";
|
||||||
|
}
|
||||||
|
|
||||||
const userinfo = await $fetch<OIDCUserInfo>(userinfoEndpoint, {
|
const userinfo = await $fetch<OIDCUserInfo>(userinfoEndpoint, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `${token_type} ${access_token}`,
|
Authorization: `${tokenResponse.token_type} ${tokenResponse.access_token}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -209,7 +373,17 @@ export class OIDCManager {
|
|||||||
|
|
||||||
if (typeof userOrError === "string") return userOrError;
|
if (typeof userOrError === "string") return userOrError;
|
||||||
|
|
||||||
return { user: userOrError, options: session.options };
|
const claims: OIDCAuthSessionClaims = {
|
||||||
|
iss: idToken.iss,
|
||||||
|
};
|
||||||
|
if (idToken.sub) claims.sub = idToken.sub;
|
||||||
|
if (idToken.sid) claims.sid = idToken.sid;
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: userOrError,
|
||||||
|
options: session.options,
|
||||||
|
claims,
|
||||||
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(e);
|
logger.error(e);
|
||||||
return `Request to identity provider failed: ${e}`;
|
return `Request to identity provider failed: ${e}`;
|
||||||
@@ -262,6 +436,7 @@ export class OIDCManager {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const creds: OIDCAuthMekCredentialsV1 = {
|
const creds: OIDCAuthMekCredentialsV1 = {
|
||||||
|
iss: this.oidcConfiguration.issuer.toString(),
|
||||||
sub: userinfo.sub,
|
sub: userinfo.sub,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -317,4 +492,64 @@ export class OIDCManager {
|
|||||||
|
|
||||||
return created.user;
|
return created.user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle OIDC backchannel logout token
|
||||||
|
* @param logout_token
|
||||||
|
* @returns
|
||||||
|
*
|
||||||
|
* @see https://openid.net/specs/openid-connect-backchannel-1_0-final.html#Validation
|
||||||
|
*/
|
||||||
|
async handleLogout(logout_token: string): Promise<boolean> {
|
||||||
|
let jwt: jose.JWTVerifyResult<jose.JWTPayload> & jose.ResolvedKey;
|
||||||
|
try {
|
||||||
|
jwt = await jose.jwtVerify(logout_token, this.JWKS, {
|
||||||
|
audience: this.clientId,
|
||||||
|
issuer: this.oidcConfiguration.issuer.toString(),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to verify OIDC logout token:", e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = OIDCLogoutTokenV1(jwt.payload);
|
||||||
|
if (token instanceof type.errors) {
|
||||||
|
console.error("Invalid OIDC logout token structure:", token.summary);
|
||||||
|
return false;
|
||||||
|
} else if (!token.sid && !token.sub) {
|
||||||
|
console.error(
|
||||||
|
"Invalid OIDC logout token: missing both 'sid' and 'sub' claims",
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchTerm: SessionSearchTerms = {
|
||||||
|
oidc: {
|
||||||
|
iss: token.iss,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (searchTerm.oidc) {
|
||||||
|
if (token.sub) {
|
||||||
|
searchTerm.oidc.sub = token.sub;
|
||||||
|
}
|
||||||
|
if (token.sid) {
|
||||||
|
searchTerm.oidc.sid = token.sid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions = await sessionHandler.searchSessions(searchTerm);
|
||||||
|
|
||||||
|
const taskQueue = [];
|
||||||
|
for (const session of sessions) {
|
||||||
|
taskQueue.push(sessionHandler.signoutByToken(session.token));
|
||||||
|
}
|
||||||
|
await Promise.all(taskQueue);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHttps(url: URL): boolean {
|
||||||
|
if (url.protocol === "https:") return true;
|
||||||
|
else return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ class SystemConfig {
|
|||||||
process.env.EXTERNAL_URL ?? "http://localhost:3000",
|
process.env.EXTERNAL_URL ?? "http://localhost:3000",
|
||||||
{ stripWWW: false },
|
{ stripWWW: false },
|
||||||
);
|
);
|
||||||
private dropVersion;
|
private dropVersion: string;
|
||||||
private gitRef;
|
private gitRef: string;
|
||||||
|
private odicRequireHttps;
|
||||||
|
|
||||||
private checkForUpdates = getUpdateCheckConfig();
|
private checkForUpdates = getUpdateCheckConfig();
|
||||||
|
|
||||||
@@ -20,6 +21,17 @@ class SystemConfig {
|
|||||||
const config = useRuntimeConfig();
|
const config = useRuntimeConfig();
|
||||||
this.dropVersion = config.dropVersion;
|
this.dropVersion = config.dropVersion;
|
||||||
this.gitRef = config.gitRef;
|
this.gitRef = config.gitRef;
|
||||||
|
|
||||||
|
const odicRequireHttps = process.env.OIDC_REQUIRE_HTTPS as
|
||||||
|
| string
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
// default to true if not set
|
||||||
|
this.odicRequireHttps =
|
||||||
|
odicRequireHttps !== undefined &&
|
||||||
|
odicRequireHttps.toLocaleLowerCase() === "false"
|
||||||
|
? false
|
||||||
|
: true;
|
||||||
}
|
}
|
||||||
|
|
||||||
getLibraryFolder() {
|
getLibraryFolder() {
|
||||||
@@ -49,6 +61,11 @@ class SystemConfig {
|
|||||||
getExternalUrl() {
|
getExternalUrl() {
|
||||||
return this.externalUrl;
|
return this.externalUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// if oidc should require https for endpoints
|
||||||
|
shouldOidcRequireHttps() {
|
||||||
|
return this.odicRequireHttps;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const systemConfig = new SystemConfig();
|
export const systemConfig = new SystemConfig();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import cacheHandler from "../cache";
|
import cacheHandler from "../cache";
|
||||||
import type { Session, SessionProvider } from "./types";
|
import type { SessionProvider, SessionWithToken } from "./types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DO NOT USE THIS. THE CACHE EVICTS SESSIONS.
|
* DO NOT USE THIS. THE CACHE EVICTS SESSIONS.
|
||||||
@@ -7,19 +7,24 @@ import type { Session, SessionProvider } from "./types";
|
|||||||
* This needs work. TODO.
|
* This needs work. TODO.
|
||||||
*/
|
*/
|
||||||
export default function createCacheSessionProvider() {
|
export default function createCacheSessionProvider() {
|
||||||
const sessions = cacheHandler.createCache<Session>("cacheSessionProvider");
|
const sessions = cacheHandler.createCache<SessionWithToken>(
|
||||||
|
"cacheSessionProvider",
|
||||||
|
);
|
||||||
|
|
||||||
const memoryProvider: SessionProvider = {
|
const memoryProvider: SessionProvider = {
|
||||||
async setSession(token, data) {
|
async setSession(token, data) {
|
||||||
await sessions.set(token, data);
|
const session = { ...data, token };
|
||||||
return true;
|
await sessions.set(token, session);
|
||||||
|
return session;
|
||||||
},
|
},
|
||||||
async getSession<T extends Session>(token: string): Promise<T | undefined> {
|
async getSession<T extends SessionWithToken>(
|
||||||
|
token: string,
|
||||||
|
): Promise<T | undefined> {
|
||||||
const session = await sessions.get(token);
|
const session = await sessions.get(token);
|
||||||
return session ? (session as T) : undefined; // Ensure undefined is returned if session is not found
|
return session ? (session as T) : undefined; // Ensure undefined is returned if session is not found
|
||||||
},
|
},
|
||||||
async updateSession(token, data) {
|
async updateSession(token, data) {
|
||||||
return await this.setSession(token, data);
|
return (await this.setSession(token, data)) !== undefined;
|
||||||
},
|
},
|
||||||
async removeSession(token) {
|
async removeSession(token) {
|
||||||
await sessions.remove(token);
|
await sessions.remove(token);
|
||||||
@@ -34,6 +39,47 @@ export default function createCacheSessionProvider() {
|
|||||||
if (session.expiresAt < now) await this.removeSession(token);
|
if (session.expiresAt < now) await this.removeSession(token);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async findSessions(options) {
|
||||||
|
const results: SessionWithToken[] = [];
|
||||||
|
for (const token of await sessions.getKeys()) {
|
||||||
|
const session = await sessions.get(token);
|
||||||
|
if (!session) continue;
|
||||||
|
let match = true;
|
||||||
|
|
||||||
|
if (
|
||||||
|
options.userId &&
|
||||||
|
session.authenticated &&
|
||||||
|
session.authenticated.userId !== options.userId
|
||||||
|
) {
|
||||||
|
match = false;
|
||||||
|
}
|
||||||
|
if (options.oidc && session.oidc) {
|
||||||
|
for (const [key, value] of Object.entries(options.oidc)) {
|
||||||
|
// stringify to do deep comparison
|
||||||
|
if (
|
||||||
|
JSON.stringify(
|
||||||
|
(session.oidc as unknown as Record<string, unknown>)[key],
|
||||||
|
) !== JSON.stringify(value)
|
||||||
|
) {
|
||||||
|
match = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(options.data || {})) {
|
||||||
|
// stringify to do deep comparison
|
||||||
|
if (JSON.stringify(session.data[key]) !== JSON.stringify(value)) {
|
||||||
|
match = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (match) {
|
||||||
|
results.push(session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return memoryProvider;
|
return memoryProvider;
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import prisma from "../db/database";
|
import prisma from "../db/database";
|
||||||
import type { Session, SessionProvider } from "./types";
|
import type { SessionProvider, SessionWithToken } from "./types";
|
||||||
import cacheHandler from "../cache";
|
import cacheHandler from "../cache";
|
||||||
|
import type { SessionWhereInput, JsonFilter } from "~/prisma/client/models";
|
||||||
|
import type { InputJsonValue } from "@prisma/client/runtime/library";
|
||||||
|
|
||||||
export default function createDBSessionHandler(): SessionProvider {
|
export default function createDBSessionHandler(): SessionProvider {
|
||||||
const cache = cacheHandler.createCache<Session>("DBSession");
|
const cache = cacheHandler.createCache<SessionWithToken>("DBSession");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
async setSession(token, session) {
|
async setSession(token, session) {
|
||||||
await cache.set(token, session);
|
await cache.set(token, { ...session, token });
|
||||||
|
|
||||||
// const strData = JSON.stringify(data);
|
const result = await prisma.session.upsert({
|
||||||
await prisma.session.upsert({
|
|
||||||
where: {
|
where: {
|
||||||
token,
|
token,
|
||||||
},
|
},
|
||||||
@@ -28,12 +29,14 @@ export default function createDBSessionHandler(): SessionProvider {
|
|||||||
data: session as object,
|
data: session as object,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return true;
|
|
||||||
|
// need to cast to Session since prisma returns different json types
|
||||||
|
return result.data as unknown as SessionWithToken;
|
||||||
},
|
},
|
||||||
async updateSession(token, data) {
|
async updateSession(token, data) {
|
||||||
return await this.setSession(token, data);
|
return (await this.setSession(token, data)) !== undefined;
|
||||||
},
|
},
|
||||||
async getSession<T extends Session>(token: string) {
|
async getSession<T extends SessionWithToken>(token: string) {
|
||||||
const cached = await cache.get(token);
|
const cached = await cache.get(token);
|
||||||
if (cached !== null) return cached as T;
|
if (cached !== null) return cached as T;
|
||||||
|
|
||||||
@@ -44,6 +47,10 @@ export default function createDBSessionHandler(): SessionProvider {
|
|||||||
});
|
});
|
||||||
if (result === null) return undefined;
|
if (result === null) return undefined;
|
||||||
|
|
||||||
|
// add to cache
|
||||||
|
// need to cast to Session since prisma returns a more specific type
|
||||||
|
await cache.set(token, result as SessionWithToken);
|
||||||
|
|
||||||
// i hate casting
|
// i hate casting
|
||||||
// need to cast to unknown since result.data can be an N deep json object technically
|
// need to cast to unknown since result.data can be an N deep json object technically
|
||||||
// ts doesn't like that be cast down to the more constraining session type
|
// ts doesn't like that be cast down to the more constraining session type
|
||||||
@@ -69,5 +76,99 @@ export default function createDBSessionHandler(): SessionProvider {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
async findSessions(options) {
|
||||||
|
const search: SessionWhereInput[] = [];
|
||||||
|
if (options.userId) {
|
||||||
|
search.push({ userId: options.userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: in the DB, the entire session subject is stored in the "data" field
|
||||||
|
// so we need to search within that JSON object for the items we want
|
||||||
|
|
||||||
|
if (options.data && typeof options.data === "object") {
|
||||||
|
const entries = walkJsonPath(options.data);
|
||||||
|
for (const { path, value } of entries) {
|
||||||
|
const filter: JsonFilter<"Session"> = {
|
||||||
|
// set base path to data
|
||||||
|
path: ["data", ...path],
|
||||||
|
equals: value as InputJsonValue,
|
||||||
|
};
|
||||||
|
search.push({ data: filter });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (options.oidc && typeof options.oidc === "object") {
|
||||||
|
const entries = walkJsonPath(options.oidc);
|
||||||
|
for (const { path, value } of entries) {
|
||||||
|
const filter: JsonFilter<"Session"> = {
|
||||||
|
// set base path to oidc
|
||||||
|
path: ["oidc", ...path],
|
||||||
|
equals: value as InputJsonValue,
|
||||||
|
};
|
||||||
|
search.push({ data: filter });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// console.log("Searching sessions with:", JSON.stringify(search, null, 2));
|
||||||
|
|
||||||
|
const sessions = await prisma.session.findMany({
|
||||||
|
where: {
|
||||||
|
AND: search,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const results: SessionWithToken[] = [];
|
||||||
|
for (const session of sessions) {
|
||||||
|
// need to cast to Session since prisma returns different json types
|
||||||
|
results.push(session.data as unknown as SessionWithToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walks a JSON object and returns all paths and their corresponding values.
|
||||||
|
* @param obj The JSON object to walk.
|
||||||
|
* @param basePath The base path to start from (used for recursion).
|
||||||
|
* @returns An array of objects containing the path and value.
|
||||||
|
*/
|
||||||
|
function walkJsonPath(
|
||||||
|
obj: unknown,
|
||||||
|
basePath: string[] = [],
|
||||||
|
): Array<{ path: string[]; value: unknown }> {
|
||||||
|
const results: Array<{ path: string[]; value: unknown }> = [];
|
||||||
|
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
for (let i = 0; i < obj.length; i++) {
|
||||||
|
const v = obj[i];
|
||||||
|
if (v === undefined) continue;
|
||||||
|
if (v !== null && typeof v === "object") {
|
||||||
|
results.push(...walkJsonPath(v, [...basePath, String(i)]));
|
||||||
|
} else {
|
||||||
|
results.push({ path: [...basePath, String(i)], value: v });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (obj !== null && typeof obj === "object") {
|
||||||
|
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
|
||||||
|
if (v === undefined) continue;
|
||||||
|
if (v !== null && typeof v === "object") {
|
||||||
|
results.push(...walkJsonPath(v, [...basePath, k]));
|
||||||
|
} else {
|
||||||
|
results.push({ path: [...basePath, k], value: v });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (basePath.length > 0) {
|
||||||
|
results.push({ path: basePath, value: obj });
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import type { H3Event } from "h3";
|
import type { H3Event } from "h3";
|
||||||
import type { Session, SessionProvider } from "./types";
|
import type {
|
||||||
|
Session,
|
||||||
|
SessionSearchTerms,
|
||||||
|
SessionProvider,
|
||||||
|
SessionWithToken,
|
||||||
|
} from "./types";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { parse as parseCookies } from "cookie-es";
|
import { parse as parseCookies } from "cookie-es";
|
||||||
import type { MinimumRequestObject } from "~/server/h3";
|
import type { MinimumRequestObject } from "~/server/h3";
|
||||||
@@ -26,6 +31,16 @@ const extendedSessionLength: DurationLike = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type SigninResult = ["signin", "2fa", "fail"][number];
|
type SigninResult = ["signin", "2fa", "fail"][number];
|
||||||
|
export interface SigninOptions {
|
||||||
|
// default value: false
|
||||||
|
rememberMe?: boolean;
|
||||||
|
|
||||||
|
// set default session data
|
||||||
|
data?: Session["data"];
|
||||||
|
|
||||||
|
// set oidc session data
|
||||||
|
oidc?: Session["oidc"];
|
||||||
|
}
|
||||||
|
|
||||||
export class SessionHandler {
|
export class SessionHandler {
|
||||||
private sessionProvider: SessionProvider;
|
private sessionProvider: SessionProvider;
|
||||||
@@ -40,29 +55,38 @@ export class SessionHandler {
|
|||||||
async signin(
|
async signin(
|
||||||
h3: H3Event,
|
h3: H3Event,
|
||||||
userId: string,
|
userId: string,
|
||||||
rememberMe: boolean = false,
|
options?: SigninOptions,
|
||||||
): Promise<SigninResult> {
|
): Promise<SigninResult> {
|
||||||
const mfaCount = await prisma.linkedMFAMec.count({
|
const mfaCount = await prisma.linkedMFAMec.count({
|
||||||
where: { userId, enabled: true },
|
where: { userId, enabled: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const rememberMe = options?.rememberMe ?? false;
|
||||||
|
const data = options?.data ?? {};
|
||||||
|
const oidcData = options?.oidc;
|
||||||
|
|
||||||
const expiresAt = this.createExipreAt(rememberMe);
|
const expiresAt = this.createExipreAt(rememberMe);
|
||||||
|
|
||||||
const token =
|
const token =
|
||||||
this.getSessionToken(h3) ?? this.createSessionCookie(h3, expiresAt);
|
this.getSessionToken(h3) ?? this.createSessionCookie(h3, expiresAt);
|
||||||
const session = (await this.sessionProvider.getSession(token)) ?? {
|
const defaultSession: Session = {
|
||||||
expiresAt,
|
expiresAt,
|
||||||
data: {},
|
data,
|
||||||
};
|
};
|
||||||
|
const session =
|
||||||
|
(await this.sessionProvider.getSession(token)) ?? defaultSession;
|
||||||
const wasAuthenticated = !!session.authenticated;
|
const wasAuthenticated = !!session.authenticated;
|
||||||
|
|
||||||
|
// set authenticated session data
|
||||||
session.authenticated = {
|
session.authenticated = {
|
||||||
userId,
|
userId,
|
||||||
level: session.authenticated?.level ?? 10,
|
level: session.authenticated?.level ?? 10,
|
||||||
requiredLevel: mfaCount > 0 ? 20 : 10,
|
requiredLevel: mfaCount > 0 ? 20 : 10,
|
||||||
superleveledExpiry: undefined,
|
superleveledExpiry: undefined,
|
||||||
};
|
};
|
||||||
|
if (oidcData) session.oidc = oidcData;
|
||||||
|
|
||||||
|
// handle superlevel expiry
|
||||||
if (
|
if (
|
||||||
wasAuthenticated &&
|
wasAuthenticated &&
|
||||||
session.authenticated.level >= session.authenticated.requiredLevel
|
session.authenticated.level >= session.authenticated.requiredLevel
|
||||||
@@ -93,14 +117,21 @@ export class SessionHandler {
|
|||||||
* Get a session associated with a request
|
* Get a session associated with a request
|
||||||
* @returns session
|
* @returns session
|
||||||
*/
|
*/
|
||||||
async getSession<T extends Session>(request: MinimumRequestObject) {
|
async getSession<T extends SessionWithToken>(request: MinimumRequestObject) {
|
||||||
const token = this.getSessionToken(request);
|
const token = this.getSessionToken(request);
|
||||||
if (!token) return undefined;
|
if (!token) return undefined;
|
||||||
|
|
||||||
const data = await this.sessionProvider.getSession<T>(token);
|
const session = await this.sessionProvider.getSession<T>(token);
|
||||||
if (!data) return undefined;
|
if (!session) return undefined;
|
||||||
if (new Date(data.expiresAt).getTime() < Date.now()) return undefined; // Expired
|
|
||||||
return data;
|
// if expired session
|
||||||
|
if (new Date(session.expiresAt).getTime() < Date.now()) {
|
||||||
|
await this.sessionProvider.removeSession(token);
|
||||||
|
// TODO: should probably call signout to clear the cookie
|
||||||
|
// session expired
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSessionDataKey<T>(
|
async getSessionDataKey<T>(
|
||||||
@@ -122,10 +153,13 @@ export class SessionHandler {
|
|||||||
this.getSessionToken(request) ??
|
this.getSessionToken(request) ??
|
||||||
this.createSessionCookie(request, expiresAt);
|
this.createSessionCookie(request, expiresAt);
|
||||||
|
|
||||||
const session = (await this.sessionProvider.getSession(token)) ?? {
|
const defaultSession: Session = {
|
||||||
expiresAt,
|
expiresAt,
|
||||||
data: {},
|
data: {},
|
||||||
};
|
};
|
||||||
|
const session =
|
||||||
|
(await this.sessionProvider.getSession(token)) ?? defaultSession;
|
||||||
|
console.log(session);
|
||||||
session.data[key] = value;
|
session.data[key] = value;
|
||||||
await this.sessionProvider.setSession(token, session);
|
await this.sessionProvider.setSession(token, session);
|
||||||
return true;
|
return true;
|
||||||
@@ -151,16 +185,38 @@ export class SessionHandler {
|
|||||||
async signout(h3: H3Event) {
|
async signout(h3: H3Event) {
|
||||||
const token = this.getSessionToken(h3);
|
const token = this.getSessionToken(h3);
|
||||||
if (!token) return false;
|
if (!token) return false;
|
||||||
const res = await this.sessionProvider.removeSession(token);
|
if (!this.signoutByToken(token)) return false;
|
||||||
if (!res) return false;
|
|
||||||
deleteCookie(h3, dropTokenCookieName);
|
deleteCookie(h3, dropTokenCookieName);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signout session by token
|
||||||
|
* @Note Should only be used in special cases (eg OIDC logout)
|
||||||
|
* @param token
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
async signoutByToken(token: string) {
|
||||||
|
const res = await this.sessionProvider.removeSession(token);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up expired sessions
|
||||||
|
*/
|
||||||
async cleanupSessions() {
|
async cleanupSessions() {
|
||||||
await this.sessionProvider.cleanupSessions();
|
await this.sessionProvider.cleanupSessions();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search sessions
|
||||||
|
* @param terms search terms
|
||||||
|
* @returns found sessions
|
||||||
|
*/
|
||||||
|
async searchSessions(terms: SessionSearchTerms) {
|
||||||
|
return await this.sessionProvider.findSessions(terms);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update session info
|
* Update session info
|
||||||
* @param token session token
|
* @param token session token
|
||||||
|
|||||||
@@ -1,19 +1,22 @@
|
|||||||
import type { Session, SessionProvider } from "./types";
|
import type { SessionProvider, SessionWithToken } from "./types";
|
||||||
|
|
||||||
export default function createMemorySessionHandler() {
|
export default function createMemorySessionHandler() {
|
||||||
const sessions = new Map<string, Session>();
|
const sessions = new Map<string, SessionWithToken>();
|
||||||
|
|
||||||
const memoryProvider: SessionProvider = {
|
const memoryProvider: SessionProvider = {
|
||||||
async setSession(token, data) {
|
async setSession(token, data) {
|
||||||
sessions.set(token, data);
|
const session = { ...data, token };
|
||||||
return true;
|
sessions.set(token, session);
|
||||||
|
return session;
|
||||||
},
|
},
|
||||||
async getSession<T extends Session>(token: string): Promise<T | undefined> {
|
async getSession<T extends SessionWithToken>(
|
||||||
|
token: string,
|
||||||
|
): Promise<T | undefined> {
|
||||||
const session = sessions.get(token);
|
const session = sessions.get(token);
|
||||||
return session ? (session as T) : undefined; // Ensure undefined is returned if session is not found
|
return session ? (session as T) : undefined; // Ensure undefined is returned if session is not found
|
||||||
},
|
},
|
||||||
async updateSession(token, data) {
|
async updateSession(token, data) {
|
||||||
return this.setSession(token, data);
|
return (await this.setSession(token, data)) !== undefined;
|
||||||
},
|
},
|
||||||
async removeSession(token) {
|
async removeSession(token) {
|
||||||
sessions.delete(token);
|
sessions.delete(token);
|
||||||
@@ -26,6 +29,44 @@ export default function createMemorySessionHandler() {
|
|||||||
if (session.expiresAt < now) await this.removeSession(token);
|
if (session.expiresAt < now) await this.removeSession(token);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async findSessions(options) {
|
||||||
|
const results: SessionWithToken[] = [];
|
||||||
|
for (const session of sessions.values()) {
|
||||||
|
let match = true;
|
||||||
|
if (
|
||||||
|
options.userId &&
|
||||||
|
session.authenticated &&
|
||||||
|
session.authenticated.userId !== options.userId
|
||||||
|
) {
|
||||||
|
match = false;
|
||||||
|
}
|
||||||
|
if (options.oidc && session.oidc) {
|
||||||
|
for (const [key, value] of Object.entries(options.oidc)) {
|
||||||
|
// stringify to do deep comparison
|
||||||
|
if (
|
||||||
|
JSON.stringify(
|
||||||
|
(session.oidc as unknown as Record<string, unknown>)[key],
|
||||||
|
) !== JSON.stringify(value)
|
||||||
|
) {
|
||||||
|
match = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(options.data || {})) {
|
||||||
|
// stringify to do deep comparison
|
||||||
|
if (JSON.stringify(session.data[key]) !== JSON.stringify(value)) {
|
||||||
|
match = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (match) {
|
||||||
|
results.push(session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return memoryProvider;
|
return memoryProvider;
|
||||||
|
|||||||
33
server/internal/session/types.d.ts
vendored
33
server/internal/session/types.d.ts
vendored
@@ -1,5 +1,6 @@
|
|||||||
export type Session = {
|
export type Session = {
|
||||||
authenticated?: AuthenticatedSession;
|
authenticated?: AuthenticatedSession;
|
||||||
|
oidc?: OIDCData;
|
||||||
|
|
||||||
expiresAt: Date;
|
expiresAt: Date;
|
||||||
data: {
|
data: {
|
||||||
@@ -8,6 +9,12 @@ export type Session = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface OIDCData {
|
||||||
|
sid?: string;
|
||||||
|
sub?: string;
|
||||||
|
iss: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AuthenticatedSession {
|
export interface AuthenticatedSession {
|
||||||
userId: string;
|
userId: string;
|
||||||
level: number;
|
level: number;
|
||||||
@@ -15,10 +22,32 @@ export interface AuthenticatedSession {
|
|||||||
superleveledExpiry: number | undefined;
|
superleveledExpiry: number | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A more complete session type that includes the token to identify it
|
||||||
|
*/
|
||||||
|
export type SessionWithToken = Session & {
|
||||||
|
token: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface SessionSearchTerms {
|
||||||
|
userId?: string;
|
||||||
|
oidc?: OIDCData;
|
||||||
|
data?: {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface SessionProvider {
|
export interface SessionProvider {
|
||||||
getSession: <T extends Session>(token: string) => Promise<T | undefined>;
|
getSession: <T extends SessionWithToken>(
|
||||||
setSession: (token: string, data: Session) => Promise<boolean>;
|
token: string,
|
||||||
|
) => Promise<T | undefined>;
|
||||||
|
setSession: (
|
||||||
|
token: string,
|
||||||
|
data: Session,
|
||||||
|
) => Promise<SessionWithToken | undefined>;
|
||||||
updateSession: (token: string, data: Session) => Promise<boolean>;
|
updateSession: (token: string, data: Session) => Promise<boolean>;
|
||||||
removeSession: (token: string) => Promise<boolean>;
|
removeSession: (token: string) => Promise<boolean>;
|
||||||
cleanupSessions: () => Promise<void>;
|
cleanupSessions: () => Promise<void>;
|
||||||
|
findSessions: (options: SessionSearchTerms) => Promise<SessionWithToken[]>;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user