From f04daf0388c2081f1f660d143590391a1dd121d4 Mon Sep 17 00:00:00 2001 From: Husky <39809509+Huskydog9988@users.noreply.github.com> Date: Mon, 19 Jan 2026 17:50:04 -0500 Subject: [PATCH] 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 --- .prettierignore | 2 + eslint.config.mjs | 3 + nuxt.config.ts | 15 +- package.json | 3 +- pnpm-lock.yaml | 11 +- pnpm-workspace.yaml | 4 +- .../v1/auth/odic/callback.get.ts} | 21 +- server/api/v1/auth/odic/logout.post.ts | 46 +++ server/api/v1/auth/passkey/finish.post.ts | 4 +- server/api/v1/auth/signin/simple.post.ts | 19 +- server/internal/auth/index.ts | 2 +- server/internal/auth/oidc/index.ts | 333 +++++++++++++++--- server/internal/config/sys-conf.ts | 21 +- server/internal/session/cache.ts | 58 ++- server/internal/session/db.ts | 117 +++++- server/internal/session/index.ts | 80 ++++- server/internal/session/memory.ts | 53 ++- server/internal/session/types.d.ts | 33 +- 18 files changed, 710 insertions(+), 115 deletions(-) rename server/{routes/auth/callback/oidc.get.ts => api/v1/auth/odic/callback.get.ts} (75%) create mode 100644 server/api/v1/auth/odic/logout.post.ts diff --git a/.prettierignore b/.prettierignore index 20d033a..51b5f44 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,3 +3,5 @@ drop-base/ pnpm-lock.yaml torrential/ +.data/** +**/.data/** diff --git a/eslint.config.mjs b/eslint.config.mjs index a287ae9..ed2d514 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,10 +1,13 @@ // @ts-check +import { globalIgnores } from "eslint/config"; import withNuxt from "./.nuxt/eslint.config.mjs"; import eslintConfigPrettier from "eslint-config-prettier/flat"; import vueI18n from "@intlify/eslint-plugin-vue-i18n"; import noPrismaDelete from "./rules/no-prisma-delete.mts"; export default withNuxt([ + globalIgnores([".data/*"]), + eslintConfigPrettier, // vue-i18n plugin diff --git a/nuxt.config.ts b/nuxt.config.ts index 3e18c65..5308fc1 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -2,7 +2,8 @@ import tailwindcss from "@tailwindcss/vite"; import { execSync } from "node:child_process"; import { readFileSync, existsSync } from "node:fs"; import path from "node:path"; -import module from "module"; +import module from "node:module"; +import { fileURLToPath } from "node:url"; import { type } from "arktype"; const packageJsonSchema = type({ @@ -91,6 +92,11 @@ export default defineNuxtConfig({ routeRules: { "/api/**": { cors: true }, + + // redirect old OIDC callback route + "/auth/callback/oidc": { + redirect: "/api/v1/auth/odic/callback", + }, }, nitro: { @@ -116,7 +122,6 @@ export default defineNuxtConfig({ scheduledTasks: { "0 * * * *": ["dailyTasks"], - "*/30 * * * *": ["downloadCleanup"], }, storage: { @@ -266,11 +271,7 @@ function getDropVersion(): string { // example nightly: "v0.3.0-nightly.2025.05.28" const defaultVersion = "v0.0.0-alpha.0"; - // get path - const packageJsonPath = path.join( - path.dirname(import.meta.url.replace("file://", "")), - "package.json", - ); + const packageJsonPath = fileURLToPath(import.meta.resolve("./package.json")); if (!existsSync(packageJsonPath)) { console.error("Could not find package.json, using default version."); diff --git a/package.json b/package.json index 3790b00..7c5165b 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "file-type-mime": "^0.4.3", "jdenticon": "^3.3.0", "kjua": "^0.10.0", + "jose": "^6.1.3", "luxon": "^3.6.1", "micromark": "^4.0.1", "normalize-url": "^8.0.2", @@ -93,5 +94,5 @@ "vue3-carousel": "^0.16.0" } }, - "packageManager": "pnpm@10.15.0+sha512.486ebc259d3e999a4e8691ce03b5cac4a71cbeca39372a9b762cb500cfdf0873e2cb16abe3d951b1ee2cf012503f027b98b6584e4df22524e0c7450d9ec7aa7b" + "packageManager": "pnpm@10.27.0+sha512.72d699da16b1179c14ba9e64dc71c9a40988cbdc65c264cb0e489db7de917f20dcf4d64d8723625f2969ba52d4b7e2a1170682d9ac2a5dcaeaab732b7e16f04a" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0c1c195..0f03044 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,9 +4,6 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false -overrides: - droplet: link:../../.local/share/pnpm/global/5/node_modules/@drop-oss/droplet - importers: .: @@ -80,6 +77,9 @@ importers: jdenticon: specifier: ^3.3.0 version: 3.3.0 + jose: + specifier: ^6.1.3 + version: 6.1.3 kjua: specifier: ^0.10.0 version: 0.10.0 @@ -4461,6 +4461,9 @@ packages: jose@4.15.9: resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==} + jose@6.1.3: + resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -11577,6 +11580,8 @@ snapshots: jose@4.15.9: {} + jose@6.1.3: {} + joycon@3.1.1: {} js-base64@3.7.7: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 69884fd..f97bce8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -9,7 +9,7 @@ onlyBuiltDependencies: - sharp - unrs-resolver -overrides: - droplet: link:../../.local/share/pnpm/global/5/node_modules/@drop-oss/droplet +# overrides: +# droplet: link:../../.local/share/pnpm/global/5/node_modules/@drop-oss/droplet shamefullyHoist: true diff --git a/server/routes/auth/callback/oidc.get.ts b/server/api/v1/auth/odic/callback.get.ts similarity index 75% rename from server/routes/auth/callback/oidc.get.ts rename to server/api/v1/auth/odic/callback.get.ts index ac2bb7d..04d7a8e 100644 --- a/server/routes/auth/callback/oidc.get.ts +++ b/server/api/v1/auth/odic/callback.get.ts @@ -1,15 +1,19 @@ import sessionHandler from "~/server/internal/session"; import authManager from "~/server/internal/auth"; +import type { Session } from "~/server/internal/session/types"; defineRouteMeta({ openAPI: { - tags: ["Auth"], + tags: ["Auth", "OIDC"], description: "OIDC Signin callback", parameters: [], }, }); export default defineEventHandler(async (h3) => { + // dont cache login responses + setHeader(h3, "Cache-Control", "no-store"); + const enabledAuthManagers = authManager.getAuthProviders(); 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.`, }); - 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") throw createError({ statusCode: 500, message: "Failed to set session" }); - - if (sessionResult == "2fa") { + else if (sessionResult == "2fa") { return sendRedirect( h3, `/auth/mfa?redirect=${result.options.redirect ? encodeURIComponent(result.options.redirect) : "/"}`, diff --git a/server/api/v1/auth/odic/logout.post.ts b/server/api/v1/auth/odic/logout.post.ts new file mode 100644 index 0000000..03c4f53 --- /dev/null +++ b/server/api/v1/auth/odic/logout.post.ts @@ -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, + }; +}); diff --git a/server/api/v1/auth/passkey/finish.post.ts b/server/api/v1/auth/passkey/finish.post.ts index cefa025..92125c3 100644 --- a/server/api/v1/auth/passkey/finish.post.ts +++ b/server/api/v1/auth/passkey/finish.post.ts @@ -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); return {}; diff --git a/server/api/v1/auth/signin/simple.post.ts b/server/api/v1/auth/signin/simple.post.ts index 083fdcc..8d40085 100644 --- a/server/api/v1/auth/signin/simple.post.ts +++ b/server/api/v1/auth/signin/simple.post.ts @@ -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 - const result = await sessionHandler.signin( - h3, - authMek.userId, - body.rememberMe, - ); + const result = await sessionHandler.signin(h3, authMek.userId, { + rememberMe: body.rememberMe ?? false, + }); if (result === "fail") throw createError({ statusCode: 500, message: "Failed to create session", }); - return { userId: authMek.userId, result }; + + return { result: result, userId: authMek.userId }; } // V2: argon2 @@ -111,11 +110,9 @@ export default defineEventHandler<{ statusMessage: t("errors.auth.invalidUserOrPass"), }); - const result = await sessionHandler.signin( - h3, - authMek.userId, - body.rememberMe, - ); + const result = await sessionHandler.signin(h3, authMek.userId, { + rememberMe: body.rememberMe ?? false, + }); if (result == "fail") throw createError({ statusCode: 500, message: "Failed to create session" }); return { userId: authMek.userId, result }; diff --git a/server/internal/auth/index.ts b/server/internal/auth/index.ts index fa8a0fa..cec3b04 100644 --- a/server/internal/auth/index.ts +++ b/server/internal/auth/index.ts @@ -14,7 +14,7 @@ class AuthManager { private initFuncs: { [K in keyof typeof this.authProviders]: () => Promise; } = { - [AuthMec.OpenID]: OIDCManager.prototype.create, + [AuthMec.OpenID]: OIDCManager.create, [AuthMec.Simple]: async () => { const disabled = process.env.DISABLE_SIMPLE_AUTH as string | undefined; return !disabled; diff --git a/server/internal/auth/oidc/index.ts b/server/internal/auth/oidc/index.ts index 727c676..32750f1 100644 --- a/server/internal/auth/oidc/index.ts +++ b/server/internal/auth/oidc/index.ts @@ -7,11 +7,31 @@ import type { Readable } from "stream"; import * as jdenticon from "jdenticon"; import { systemConfig } from "../../config/sys-conf"; 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 { - authorization_endpoint: string; - token_endpoint: string; - userinfo_endpoint: string; +// TODO: monitor https://github.com/goauthentik/authentik/issues/8751 for easier?? OIDC setup by end users + +// Schema for OIDC well-known configuration +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[]; } @@ -19,11 +39,18 @@ interface OIDCAuthSessionOptions { redirect: string | undefined; } +interface OIDCAuthSessionClaims { + iss: string; + sub?: string; + sid?: string; +} + interface OIDCAuthSession { redirectUrl: string; callbackUrl: string; state: string; options: OIDCAuthSessionOptions; + claims: OIDCAuthSessionClaims; } interface OIDCUserInfo { @@ -35,15 +62,69 @@ interface OIDCUserInfo { groups?: Array; } +type OIDCUrlKey = Exclude; + +/** + * @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 { + // only optional for compatibility with older versions + iss?: string; sub: string; } export class OIDCManager { - private oidcConfiguration: OIDCWellKnown; + private oidcConfiguration: OIDCConfiguration; private clientId: string; private clientSecret: string; - private externalUrl: string; + private externalUrl: URL; + private redirectUrl: URL; private userGroup?: string = process.env.OIDC_USER_GROUP; private adminGroup?: string = process.env.OIDC_ADMIN_GROUP; @@ -53,55 +134,99 @@ export class OIDCManager { 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; + + private constructor( + oidcConfiguration: OIDCConfiguration, clientId: string, clientSecret: string, - externalUrl: string, + externalUrl: URL, ) { this.oidcConfiguration = oidcConfiguration; this.clientId = clientId; this.clientSecret = clientSecret; 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() { - const wellKnownUrl = process.env.OIDC_WELLKNOWN as string | undefined; + static async create() { + 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; - let configuration: OIDCWellKnown; - if (wellKnownUrl) { - const response: OIDCWellKnown = await $fetch(wellKnownUrl); - if ( - !response.authorization_endpoint || - !response.scopes_supported || - !response.token_endpoint || - !response.userinfo_endpoint - ) { - throw new Error("Well known response was invalid"); - } - if (scopes) { - response.scopes_supported = scopes.split(","); + let configuration: OIDCConfiguration; + if (wellKnownUrlString) { + const wellKnownUrl = new URL(wellKnownUrlString); + if (systemConfig.shouldOidcRequireHttps() && !isHttps(wellKnownUrl)) { + throw new Error("OIDC_WELLKNOWN URL must use HTTPS"); } - configuration = response; + const response = await $fetch(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 { const authorizationEndpoint = process.env.OIDC_AUTHORIZATION as | string | undefined; const tokenEndpoint = process.env.OIDC_TOKEN 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 ( !authorizationEndpoint || !tokenEndpoint || !userinfoEndpoint || - !scopes + !scopes || + !issuer || + !jwksEndpoint ) { const debugObject = { OIDC_AUTHORIZATION: authorizationEndpoint, OIDC_TOKEN: tokenEndpoint, OIDC_USERINFO: userinfoEndpoint, OIDC_SCOPES: scopes, + OIDC_ISSUER: issuer, + OIDC_JWKS: jwksEndpoint, }; throw new Error( "Missing all necessary OIDC configuration: \n" + @@ -112,19 +237,37 @@ export class OIDCManager { } configuration = { - authorization_endpoint: authorizationEndpoint, - token_endpoint: tokenEndpoint, - userinfo_endpoint: userinfoEndpoint, + authorization_endpoint: new URL(authorizationEndpoint), + token_endpoint: new URL(tokenEndpoint), + userinfo_endpoint: new URL(userinfoEndpoint), scopes_supported: scopes.split(","), + issuer: new URL(issuer), + jwks_uri: new URL(jwksEndpoint), }; } if (!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 clientSecret = process.env.OIDC_CLIENT_SECRET as string | undefined; - const externalUrl = systemConfig.getExternalUrl(); + const externalUrl = new URL(systemConfig.getExternalUrl()); if (!clientId || !clientSecret) throw new Error("Missing client ID or secret for OIDC"); @@ -150,17 +293,17 @@ export class OIDCManager { const normalisedUrl = new URL( this.oidcConfiguration.authorization_endpoint, ).toString(); - const redirectNormalisedUrl = new URL(this.externalUrl).toString(); - const redirectUrl = `${redirectNormalisedUrl}auth/callback/oidc`; - - const finalUrl = `${normalisedUrl}?client_id=${this.clientId}&redirect_uri=${encodeURIComponent(redirectUrl)}&state=${stateKey}&response_type=code&scope=${encodeURIComponent(this.oidcConfiguration.scopes_supported.join(" "))}`; + 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 session: OIDCAuthSession = { redirectUrl: finalUrl, - callbackUrl: redirectUrl, + callbackUrl: this.redirectUrl.toString(), state: stateKey, options: options ?? { redirect: undefined }, + claims: { + iss: this.oidcConfiguration.issuer.toString(), + }, }; this.signinStateTable[stateKey] = session; return session; @@ -169,16 +312,20 @@ export class OIDCManager { async authorize( code: string, state: string, - ): Promise<{ user: UserModel; options: OIDCAuthSessionOptions } | string> { + ): Promise< + | { + user: UserModel; + options: OIDCAuthSessionOptions; + claims: OIDCAuthSessionClaims; + } + | string + > { const session = this.signinStateTable[state]; if (!session) return "Invalid state parameter"; - const tokenEndpoint = new URL( - this.oidcConfiguration.token_endpoint, - ).toString(); - const userinfoEndpoint = new URL( - this.oidcConfiguration.userinfo_endpoint, - ).toString(); + const tokenEndpoint = this.oidcConfiguration.token_endpoint.toString(); + const userinfoEndpoint = + this.oidcConfiguration.userinfo_endpoint.toString(); const requestBody = new URLSearchParams({ client_id: this.clientId, @@ -190,18 +337,35 @@ export class OIDCManager { }); try { - const { access_token, token_type } = await $fetch<{ - access_token: string; - token_type: string; - id_token: string; - }>(tokenEndpoint, { + const rawTokenResponse = await $fetch(tokenEndpoint, { body: requestBody, 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(userinfoEndpoint, { 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; - 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) { logger.error(e); return `Request to identity provider failed: ${e}`; @@ -262,6 +436,7 @@ export class OIDCManager { */ const creds: OIDCAuthMekCredentialsV1 = { + iss: this.oidcConfiguration.issuer.toString(), sub: userinfo.sub, }; @@ -317,4 +492,64 @@ export class OIDCManager { 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 { + let jwt: jose.JWTVerifyResult & 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; } diff --git a/server/internal/config/sys-conf.ts b/server/internal/config/sys-conf.ts index cb6ed58..72cb980 100644 --- a/server/internal/config/sys-conf.ts +++ b/server/internal/config/sys-conf.ts @@ -10,8 +10,9 @@ class SystemConfig { process.env.EXTERNAL_URL ?? "http://localhost:3000", { stripWWW: false }, ); - private dropVersion; - private gitRef; + private dropVersion: string; + private gitRef: string; + private odicRequireHttps; private checkForUpdates = getUpdateCheckConfig(); @@ -20,6 +21,17 @@ class SystemConfig { const config = useRuntimeConfig(); this.dropVersion = config.dropVersion; 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() { @@ -49,6 +61,11 @@ class SystemConfig { getExternalUrl() { return this.externalUrl; } + + // if oidc should require https for endpoints + shouldOidcRequireHttps() { + return this.odicRequireHttps; + } } export const systemConfig = new SystemConfig(); diff --git a/server/internal/session/cache.ts b/server/internal/session/cache.ts index e883522..fd6387f 100644 --- a/server/internal/session/cache.ts +++ b/server/internal/session/cache.ts @@ -1,5 +1,5 @@ import cacheHandler from "../cache"; -import type { Session, SessionProvider } from "./types"; +import type { SessionProvider, SessionWithToken } from "./types"; /** * DO NOT USE THIS. THE CACHE EVICTS SESSIONS. @@ -7,19 +7,24 @@ import type { Session, SessionProvider } from "./types"; * This needs work. TODO. */ export default function createCacheSessionProvider() { - const sessions = cacheHandler.createCache("cacheSessionProvider"); + const sessions = cacheHandler.createCache( + "cacheSessionProvider", + ); const memoryProvider: SessionProvider = { async setSession(token, data) { - await sessions.set(token, data); - return true; + const session = { ...data, token }; + await sessions.set(token, session); + return session; }, - async getSession(token: string): Promise { + async getSession( + token: string, + ): Promise { const session = await sessions.get(token); return session ? (session as T) : undefined; // Ensure undefined is returned if session is not found }, async updateSession(token, data) { - return await this.setSession(token, data); + return (await this.setSession(token, data)) !== undefined; }, async removeSession(token) { await sessions.remove(token); @@ -34,6 +39,47 @@ export default function createCacheSessionProvider() { 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)[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; diff --git a/server/internal/session/db.ts b/server/internal/session/db.ts index 7ddc292..aa53fbc 100644 --- a/server/internal/session/db.ts +++ b/server/internal/session/db.ts @@ -1,16 +1,17 @@ import prisma from "../db/database"; -import type { Session, SessionProvider } from "./types"; +import type { SessionProvider, SessionWithToken } from "./types"; 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 { - const cache = cacheHandler.createCache("DBSession"); + const cache = cacheHandler.createCache("DBSession"); return { async setSession(token, session) { - await cache.set(token, session); + await cache.set(token, { ...session, token }); - // const strData = JSON.stringify(data); - await prisma.session.upsert({ + const result = await prisma.session.upsert({ where: { token, }, @@ -28,12 +29,14 @@ export default function createDBSessionHandler(): SessionProvider { 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) { - return await this.setSession(token, data); + return (await this.setSession(token, data)) !== undefined; }, - async getSession(token: string) { + async getSession(token: string) { const cached = await cache.get(token); if (cached !== null) return cached as T; @@ -44,6 +47,10 @@ export default function createDBSessionHandler(): SessionProvider { }); 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 // 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 @@ -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)) { + 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; +} diff --git a/server/internal/session/index.ts b/server/internal/session/index.ts index 6e79ce5..a96a018 100644 --- a/server/internal/session/index.ts +++ b/server/internal/session/index.ts @@ -1,5 +1,10 @@ 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 { parse as parseCookies } from "cookie-es"; import type { MinimumRequestObject } from "~/server/h3"; @@ -26,6 +31,16 @@ const extendedSessionLength: DurationLike = { }; 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 { private sessionProvider: SessionProvider; @@ -40,29 +55,38 @@ export class SessionHandler { async signin( h3: H3Event, userId: string, - rememberMe: boolean = false, + options?: SigninOptions, ): Promise { const mfaCount = await prisma.linkedMFAMec.count({ where: { userId, enabled: true }, }); + const rememberMe = options?.rememberMe ?? false; + const data = options?.data ?? {}; + const oidcData = options?.oidc; + const expiresAt = this.createExipreAt(rememberMe); const token = this.getSessionToken(h3) ?? this.createSessionCookie(h3, expiresAt); - const session = (await this.sessionProvider.getSession(token)) ?? { + const defaultSession: Session = { expiresAt, - data: {}, + data, }; + const session = + (await this.sessionProvider.getSession(token)) ?? defaultSession; const wasAuthenticated = !!session.authenticated; + // set authenticated session data session.authenticated = { userId, level: session.authenticated?.level ?? 10, requiredLevel: mfaCount > 0 ? 20 : 10, superleveledExpiry: undefined, }; + if (oidcData) session.oidc = oidcData; + // handle superlevel expiry if ( wasAuthenticated && session.authenticated.level >= session.authenticated.requiredLevel @@ -93,14 +117,21 @@ export class SessionHandler { * Get a session associated with a request * @returns session */ - async getSession(request: MinimumRequestObject) { + async getSession(request: MinimumRequestObject) { const token = this.getSessionToken(request); if (!token) return undefined; - const data = await this.sessionProvider.getSession(token); - if (!data) return undefined; - if (new Date(data.expiresAt).getTime() < Date.now()) return undefined; // Expired - return data; + const session = await this.sessionProvider.getSession(token); + if (!session) return undefined; + + // 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( @@ -122,10 +153,13 @@ export class SessionHandler { this.getSessionToken(request) ?? this.createSessionCookie(request, expiresAt); - const session = (await this.sessionProvider.getSession(token)) ?? { + const defaultSession: Session = { expiresAt, data: {}, }; + const session = + (await this.sessionProvider.getSession(token)) ?? defaultSession; + console.log(session); session.data[key] = value; await this.sessionProvider.setSession(token, session); return true; @@ -151,16 +185,38 @@ export class SessionHandler { async signout(h3: H3Event) { const token = this.getSessionToken(h3); if (!token) return false; - const res = await this.sessionProvider.removeSession(token); - if (!res) return false; + if (!this.signoutByToken(token)) return false; deleteCookie(h3, dropTokenCookieName); 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() { 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 * @param token session token diff --git a/server/internal/session/memory.ts b/server/internal/session/memory.ts index e16b478..7f80898 100644 --- a/server/internal/session/memory.ts +++ b/server/internal/session/memory.ts @@ -1,19 +1,22 @@ -import type { Session, SessionProvider } from "./types"; +import type { SessionProvider, SessionWithToken } from "./types"; export default function createMemorySessionHandler() { - const sessions = new Map(); + const sessions = new Map(); const memoryProvider: SessionProvider = { async setSession(token, data) { - sessions.set(token, data); - return true; + const session = { ...data, token }; + sessions.set(token, session); + return session; }, - async getSession(token: string): Promise { + async getSession( + token: string, + ): Promise { const session = sessions.get(token); return session ? (session as T) : undefined; // Ensure undefined is returned if session is not found }, async updateSession(token, data) { - return this.setSession(token, data); + return (await this.setSession(token, data)) !== undefined; }, async removeSession(token) { sessions.delete(token); @@ -26,6 +29,44 @@ export default function createMemorySessionHandler() { 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)[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; diff --git a/server/internal/session/types.d.ts b/server/internal/session/types.d.ts index 1a1d95c..88a941b 100644 --- a/server/internal/session/types.d.ts +++ b/server/internal/session/types.d.ts @@ -1,5 +1,6 @@ export type Session = { authenticated?: AuthenticatedSession; + oidc?: OIDCData; expiresAt: Date; data: { @@ -8,6 +9,12 @@ export type Session = { }; }; +export interface OIDCData { + sid?: string; + sub?: string; + iss: string; +} + export interface AuthenticatedSession { userId: string; level: number; @@ -15,10 +22,32 @@ export interface AuthenticatedSession { 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 { - getSession: (token: string) => Promise; - setSession: (token: string, data: Session) => Promise; + getSession: ( + token: string, + ) => Promise; + setSession: ( + token: string, + data: Session, + ) => Promise; updateSession: (token: string, data: Session) => Promise; removeSession: (token: string) => Promise; cleanupSessions: () => Promise; + findSessions: (options: SessionSearchTerms) => Promise; }