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:
Husky
2026-01-19 17:50:04 -05:00
committed by GitHub
parent 2967e433ca
commit f04daf0388
18 changed files with 710 additions and 115 deletions
+52 -6
View File
@@ -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<Session>("cacheSessionProvider");
const sessions = cacheHandler.createCache<SessionWithToken>(
"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<T extends Session>(token: string): Promise<T | undefined> {
async getSession<T extends SessionWithToken>(
token: string,
): Promise<T | undefined> {
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<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;
+109 -8
View File
@@ -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<Session>("DBSession");
const cache = cacheHandler.createCache<SessionWithToken>("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<T extends Session>(token: string) {
async getSession<T extends SessionWithToken>(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<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;
}
+68 -12
View File
@@ -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<SigninResult> {
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<T extends Session>(request: MinimumRequestObject) {
async getSession<T extends SessionWithToken>(request: MinimumRequestObject) {
const token = this.getSessionToken(request);
if (!token) return undefined;
const data = await this.sessionProvider.getSession<T>(token);
if (!data) return undefined;
if (new Date(data.expiresAt).getTime() < Date.now()) return undefined; // Expired
return data;
const session = await this.sessionProvider.getSession<T>(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<T>(
@@ -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
+47 -6
View File
@@ -1,19 +1,22 @@
import type { Session, SessionProvider } from "./types";
import type { SessionProvider, SessionWithToken } from "./types";
export default function createMemorySessionHandler() {
const sessions = new Map<string, Session>();
const sessions = new Map<string, SessionWithToken>();
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<T extends Session>(token: string): Promise<T | undefined> {
async getSession<T extends SessionWithToken>(
token: string,
): Promise<T | undefined> {
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<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;
+31 -2
View File
@@ -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: <T extends Session>(token: string) => Promise<T | undefined>;
setSession: (token: string, data: Session) => Promise<boolean>;
getSession: <T extends SessionWithToken>(
token: string,
) => Promise<T | undefined>;
setSession: (
token: string,
data: Session,
) => Promise<SessionWithToken | undefined>;
updateSession: (token: string, data: Session) => Promise<boolean>;
removeSession: (token: string) => Promise<boolean>;
cleanupSessions: () => Promise<void>;
findSessions: (options: SessionSearchTerms) => Promise<SessionWithToken[]>;
}