diff --git a/backend/src/api/private/auth/auth.controller.ts b/backend/src/api/private/auth/auth.controller.ts index 60f6a963c..2308fdf13 100644 --- a/backend/src/api/private/auth/auth.controller.ts +++ b/backend/src/api/private/auth/auth.controller.ts @@ -3,6 +3,7 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ +import { promisify } from 'node:util'; import { AuthProviderType } from '@hedgedoc/commons'; import { BadRequestException, @@ -41,20 +42,21 @@ export class AuthController { @UseGuards(SessionGuard) @Delete('logout') @OpenApi(200, 400, 401) - logout(@Req() request: RequestWithSession): LogoutResponseDto { + async logout(@Req() request: RequestWithSession): Promise { let logoutUrl: string | null = null; - if (request.session.authProviderType === AuthProviderType.OIDC) { + if (request.session.loginAuthProviderType === AuthProviderType.OIDC) { logoutUrl = this.oidcService.getLogoutUrl(request); } - request.session.destroy((err) => { - if (err) { - this.logger.error('Error during logout:' + String(err), undefined, 'logout'); - throw new InternalServerErrorException('Unable to log out'); - } - }); - return LogoutResponseDto.create({ - redirect: logoutUrl || '/', - }); + const destroySessionPromise = promisify(request.session.destroy).bind(request.session); + try { + await destroySessionPromise(); + return LogoutResponseDto.create({ + redirect: logoutUrl || '/', + }); + } catch (error) { + this.logger.error('Error during logout:' + String(error), undefined, 'logout'); + throw new InternalServerErrorException('Unable to log out'); + } } @Get('pending-user') @@ -88,16 +90,22 @@ export class AuthController { request.session.pendingUser.authProviderIdentifier, request.session.pendingUser.providerUserId, ); - request.session.authProviderType = request.session.pendingUser.authProviderType; - request.session.authProviderIdentifier = request.session.pendingUser.authProviderIdentifier; + request.session.loginAuthProviderType = request.session.pendingUser.authProviderType; + request.session.loginAuthProviderIdentifier = + request.session.pendingUser.authProviderIdentifier; // Cleanup - request.session.pendingUser = undefined; + request.session.pendingUser = null; } @Delete('pending-user') @OpenApi(204, 400) deletePendingUserData(@Req() request: RequestWithSession): void { - request.session.pendingUser = undefined; - request.session.oidc = undefined; + request.session.pendingUser = null; + request.session.oidc = { + idToken: null, + sid: null, + loginCode: null, + loginState: null, + }; } } diff --git a/backend/src/api/private/auth/guest/guest.controller.ts b/backend/src/api/private/auth/guest/guest.controller.ts index 00408664f..c0fa289d6 100644 --- a/backend/src/api/private/auth/guest/guest.controller.ts +++ b/backend/src/api/private/auth/guest/guest.controller.ts @@ -33,7 +33,7 @@ export class GuestController { ): Promise { const [uuid, userId] = await this.usersService.createGuestUser(); // Log the user in after registration - request.session.authProviderType = AuthProviderType.GUEST; + request.session.loginAuthProviderType = AuthProviderType.GUEST; request.session.userId = userId; return GuestRegistrationResponseDto.create({ uuid, @@ -48,7 +48,7 @@ export class GuestController { @Body() loginDto: GuestLoginDto, ): Promise { const userId = await this.usersService.getUserIdByGuestUuid(loginDto.uuid); - request.session.authProviderType = AuthProviderType.GUEST; + request.session.loginAuthProviderType = AuthProviderType.GUEST; request.session.userId = userId; } } diff --git a/backend/src/api/private/auth/ldap/ldap.controller.ts b/backend/src/api/private/auth/ldap/ldap.controller.ts index f5f0a1fee..2c648645b 100644 --- a/backend/src/api/private/auth/ldap/ldap.controller.ts +++ b/backend/src/api/private/auth/ldap/ldap.controller.ts @@ -58,8 +58,8 @@ export class LdapController { userInfo.photoUrl, ); } - request.session.authProviderType = AuthProviderType.LDAP; - request.session.authProviderIdentifier = ldapIdentifier; + request.session.loginAuthProviderType = AuthProviderType.LDAP; + request.session.loginAuthProviderIdentifier = ldapIdentifier; request.session.userId = identity[FieldNameIdentity.userId]; return LdapLoginResponseDto.create({ newUser: false }); } catch (error) { diff --git a/backend/src/api/private/auth/local/local.controller.ts b/backend/src/api/private/auth/local/local.controller.ts index df4332302..899d3ea41 100644 --- a/backend/src/api/private/auth/local/local.controller.ts +++ b/backend/src/api/private/auth/local/local.controller.ts @@ -47,9 +47,9 @@ export class LocalController { registerDto.displayName, ); // Log the user in after registration - request.session.authProviderType = AuthProviderType.LOCAL; + request.session.loginAuthProviderType = AuthProviderType.LOCAL; request.session.userId = userId; - request.session.pendingUser = undefined; + request.session.pendingUser = null; } @UseGuards(LoginEnabledGuard, SessionGuard) @@ -82,8 +82,8 @@ export class LocalController { loginDto.password, ); request.session.userId = identity[FieldNameIdentity.userId]; - request.session.authProviderType = AuthProviderType.LOCAL; - request.session.pendingUser = undefined; + request.session.loginAuthProviderType = AuthProviderType.LOCAL; + request.session.pendingUser = null; } catch (error) { this.logger.log(`Failed to log in user: ${String(error)}`, 'login'); throw new UnauthorizedException('Invalid username or password'); diff --git a/backend/src/api/private/auth/oidc/oidc.controller.ts b/backend/src/api/private/auth/oidc/oidc.controller.ts index 5afe332e8..1a82dd079 100644 --- a/backend/src/api/private/auth/oidc/oidc.controller.ts +++ b/backend/src/api/private/auth/oidc/oidc.controller.ts @@ -48,6 +48,8 @@ export class OidcController { request.session.oidc = { loginCode: code, loginState: state, + idToken: null, + sid: null, }; request.session.pendingUser = { authProviderType: AuthProviderType.OIDC, @@ -92,9 +94,9 @@ export class OidcController { } request.session.userId = userId; - request.session.authProviderType = AuthProviderType.OIDC; - request.session.authProviderIdentifier = oidcIdentifier; - request.session.pendingUser = undefined; + request.session.loginAuthProviderType = AuthProviderType.OIDC; + request.session.loginAuthProviderIdentifier = oidcIdentifier; + request.session.pendingUser = null; return { url: '/' }; } catch (error) { if (error instanceof HttpException) { diff --git a/backend/src/api/private/me/me.controller.ts b/backend/src/api/private/me/me.controller.ts index 62a8023b0..04c5345e2 100644 --- a/backend/src/api/private/me/me.controller.ts +++ b/backend/src/api/private/me/me.controller.ts @@ -4,7 +4,16 @@ * SPDX-License-Identifier: AGPL-3.0-only */ import { AuthProviderType } from '@hedgedoc/commons'; -import { Body, Controller, Delete, Get, Put, UseGuards } from '@nestjs/common'; +import { + Body, + Controller, + Delete, + Get, + InternalServerErrorException, + Put, + Req, + UseGuards, +} from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { SessionGuard } from '../../../auth/session.guard'; @@ -16,6 +25,8 @@ import { UsersService } from '../../../users/users.service'; import { OpenApi } from '../../utils/decorators/openapi.decorator'; import { RequestUserId } from '../../utils/decorators/request-user-id.decorator'; import { SessionAuthProvider } from '../../utils/decorators/session-authprovider.decorator'; +import { promisify } from 'node:util'; +import { RequestWithSession } from '../../utils/request.type'; @UseGuards(SessionGuard) @OpenApi(401) @@ -49,7 +60,10 @@ export class MeController { @Delete() @OpenApi(204, 404, 500) - async deleteUser(@RequestUserId() userId: number): Promise { + async deleteUser( + @Req() request: RequestWithSession, + @RequestUserId() userId: number, + ): Promise { const mediaUploads = await this.mediaService.getMediaUploadUuidsByUserId(userId); for (const mediaUpload of mediaUploads) { await this.mediaService.deleteFile(mediaUpload); @@ -57,6 +71,11 @@ export class MeController { this.logger.debug(`Deleted all media uploads for user with id ${userId}`); await this.userService.deleteUser(userId); this.logger.debug(`Deleted user with id ${userId}`); + const destroySessionPromise = promisify(request.session.destroy).bind(request.session); + destroySessionPromise().catch((error) => { + this.logger.error('Error while destroying session:' + String(error), undefined, 'deleteUser'); + throw new InternalServerErrorException('Error trying to destroy session of deleted user'); + }); } @Put('profile') diff --git a/backend/src/api/utils/decorators/session-authprovider.decorator.ts b/backend/src/api/utils/decorators/session-authprovider.decorator.ts index 2d7d87208..63c661ab6 100644 --- a/backend/src/api/utils/decorators/session-authprovider.decorator.ts +++ b/backend/src/api/utils/decorators/session-authprovider.decorator.ts @@ -19,9 +19,9 @@ import { CompleteRequest } from '../request.type'; // oxlint-disable-next-line @typescript-eslint/naming-convention export const SessionAuthProvider = createParamDecorator((data: unknown, ctx: ExecutionContext) => { const request: CompleteRequest = ctx.switchToHttp().getRequest(); - if (!request.session?.authProviderType) { + if (!request.session?.loginAuthProviderType) { // We should have an auth provider here, otherwise something is wrong throw new InternalServerErrorException('Session is missing an auth provider identifier'); } - return request.session.authProviderType; + return request.session.loginAuthProviderType; }); diff --git a/backend/src/api/utils/request.type.ts b/backend/src/api/utils/request.type.ts index ca776f87e..2c15cbe3e 100644 --- a/backend/src/api/utils/request.type.ts +++ b/backend/src/api/utils/request.type.ts @@ -6,7 +6,7 @@ import { AuthProviderType } from '@hedgedoc/commons'; import { FieldNameNote, FieldNameUser, Note, User } from '@hedgedoc/database'; import { FastifyRequest } from 'fastify'; -import { SessionState } from 'src/sessions/session-state.type'; +import { SessionState } from 'src/sessions/session-state'; export type CompleteRequest = FastifyRequest & { userId?: User[FieldNameUser.id]; diff --git a/backend/src/app-init.ts b/backend/src/app-init.ts index e6225d51f..7bab74db4 100644 --- a/backend/src/app-init.ts +++ b/backend/src/app-init.ts @@ -73,12 +73,12 @@ export async function setupApp( await setupSessionMiddleware( app as INestApplication, authConfig, - app.get(SessionService).getSessionStore(), + app.get(SessionService).getFastifySessionStore(), ); // Setup CSRF protection await app.register(fastifyCsrfProtection, { - cookieKey: 'hedgedoc-csrf', + sessionKey: 'csrfToken', sessionPlugin: '@fastify/session', getToken: (req) => req.headers['csrf-token'] as string | undefined, }); diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index f325f3dd6..28e57e077 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -5,6 +5,7 @@ */ import { Module } from '@nestjs/common'; +import { SessionModule } from '../sessions/session.module'; import { UsersModule } from '../users/users.module'; import { IdentityService } from './identity.service'; import { LdapService } from './ldap/ldap.service'; @@ -12,7 +13,7 @@ import { LocalService } from './local/local.service'; import { OidcService } from './oidc/oidc.service'; @Module({ - imports: [UsersModule], + imports: [UsersModule, SessionModule], controllers: [], providers: [IdentityService, LdapService, LocalService, OidcService], exports: [IdentityService, LdapService, LocalService, OidcService], diff --git a/backend/src/auth/oidc/oidc.service.ts b/backend/src/auth/oidc/oidc.service.ts index 2207005fd..6b0b48afa 100644 --- a/backend/src/auth/oidc/oidc.service.ts +++ b/backend/src/auth/oidc/oidc.service.ts @@ -173,8 +173,8 @@ export class OidcService { const client = clientConfig.client; const oidcConfig = clientConfig.config; const params = client.callbackParams(request.raw); - const code = request.session.oidc?.loginCode; - const state = request.session.oidc?.loginState; + const code = request.session.oidc?.loginCode ?? undefined; + const state = request.session.oidc?.loginState ?? undefined; const isAutodiscovered = clientConfig.config.authorizeUrl === undefined; const callbackMethod = isAutodiscovered ? client.callback.bind(client) @@ -185,8 +185,14 @@ export class OidcService { state, }); + // If the id_token is not present, we are not able to extract claims like the sessionId from it, therefore we set it to null. + // This is the case with non-OIDC-compliant OAuth2 auth providers like GitHub. + const sid = tokenSet.id_token ? ((tokenSet.claims()?.sid as string | undefined) ?? null) : null; request.session.oidc = { - idToken: tokenSet.id_token, + idToken: tokenSet.id_token ?? null, + sid, + loginState: null, + loginCode: null, }; const userInfoResponse = await client.userinfo(tokenSet); const userId = OidcService.getResponseFieldValue( @@ -270,7 +276,7 @@ export class OidcService { * @returns The logout URL if the user is logged in with OIDC, or null if there is no URL to redirect to */ getLogoutUrl(request: RequestWithSession): string | null { - const oidcIdentifier = request.session.authProviderIdentifier; + const oidcIdentifier = request.session.loginAuthProviderIdentifier; if (!oidcIdentifier) { return null; } diff --git a/backend/src/auth/session.guard.ts b/backend/src/auth/session.guard.ts index 37d45db7b..6efcc6510 100644 --- a/backend/src/auth/session.guard.ts +++ b/backend/src/auth/session.guard.ts @@ -32,7 +32,7 @@ export class SessionGuard implements CanActivate { canActivate(context: ExecutionContext): boolean { const request: CompleteRequest = context.switchToHttp().getRequest(); const userId = request.session?.userId; - const authProviderType = request.session?.authProviderType; + const authProviderType = request.session?.loginAuthProviderType; if (!userId || !authProviderType) { this.logger.debug('The user has no session.'); throw new UnauthorizedException('You have no active session'); diff --git a/backend/src/database/migrations/20250312211152_initial.js b/backend/src/database/migrations/20250312211152_initial.js index 33fb88c69..24edf7910 100644 --- a/backend/src/database/migrations/20250312211152_initial.js +++ b/backend/src/database/migrations/20250312211152_initial.js @@ -40,6 +40,8 @@ const { TableUser, TableUserPinnedNote, TableVisitedNote, + FieldNameSession, + TableSession, } = require('@hedgedoc/database'); const up = async function (knex) { @@ -401,10 +403,52 @@ const up = async function (knex) { table.index([FieldNameVisitedNote.userId], 'idx_visited_notes_user_id'); table.index([FieldNameVisitedNote.noteId], 'idx_visited_notes_note_id'); }); + + // Create session table + await knex.schema.createTable(TableSession, (table) => { + table.string(FieldNameSession.id).primary(); + table + .integer(FieldNameSession.userId) + .unsigned() + .nullable() + .references(FieldNameUser.id) + .inTable(TableUser) + .onDelete('CASCADE'); + table.string(FieldNameSession.csrfToken).nullable(); + table + .enu( + FieldNameSession.loginAuthProviderType, + [ + AuthProviderType.LDAP, + AuthProviderType.LOCAL, + AuthProviderType.OIDC, + AuthProviderType.GUEST, + ], + { + useNative: true, + enumName: FieldNameSession.loginAuthProviderType, + }, + ) + .nullable(); + table.string(FieldNameSession.loginAuthProviderIdentifier).nullable(); + table.string(FieldNameSession.oidcIdToken).nullable(); + table.string(FieldNameSession.oidcSid).nullable(); + table.string(FieldNameSession.oidcLoginState).nullable(); + table.string(FieldNameSession.oidcLoginCode).nullable(); + table.text(FieldNameSession.pendingUserData); + table.timestamp(FieldNameSession.createdAt, { useTz: false, precision: 3 }).notNullable(); + table.timestamp(FieldNameSession.updatedAt, { useTz: false, precision: 3 }).notNullable(); + table.timestamp(FieldNameSession.expiresAt, { useTz: false, precision: 3 }).notNullable(); + + table.index([FieldNameSession.userId], 'idx_session_user_id'); + table.index([FieldNameSession.oidcSid], 'idx_session_oidc_sid'); + table.index([FieldNameSession.expiresAt], 'idx_session_expires_at'); + }); }; const down = async function (knex) { // Drop tables in reverse order of creation to avoid integer key constraints + await knex.schema.dropTableIfExists(TableSession); await knex.schema.dropTableIfExists(TableVisitedNote); await knex.schema.dropTableIfExists(TableUserPinnedNote); await knex.schema.dropTableIfExists(TableMediaUpload); diff --git a/backend/src/database/types/knex.types.ts b/backend/src/database/types/knex.types.ts index ec66b1225..0dfa111bd 100644 --- a/backend/src/database/types/knex.types.ts +++ b/backend/src/database/types/knex.types.ts @@ -16,6 +16,7 @@ import { NoteUserPermission, Revision, RevisionTag, + Session, TableAlias, TableApiToken, TableAuthorshipInfo, @@ -28,6 +29,7 @@ import { TableNoteUserPermission, TableRevision, TableRevisionTag, + TableSession, TableUser, TableUserPinnedNote, TypeInsertGroup, @@ -71,6 +73,7 @@ declare module 'knex/types/tables.js' { NoteUserPermission, TypeUpdateNoteUserPermission >; + [TableSession]: Session; [TableRevision]: KnexOriginal.CompositeTableType; [TableRevisionTag]: RevisionTag; [TableUser]: KnexOriginal.CompositeTableType; diff --git a/backend/src/realtime/websocket/websocket.gateway.spec.ts b/backend/src/realtime/websocket/websocket.gateway.spec.ts index be827a5d4..f6563f82c 100644 --- a/backend/src/realtime/websocket/websocket.gateway.spec.ts +++ b/backend/src/realtime/websocket/websocket.gateway.spec.ts @@ -5,7 +5,6 @@ */ import { PermissionLevel } from '@hedgedoc/commons'; import { FieldNameUser } from '@hedgedoc/database'; -import { Optional } from '@mrdrogdrog/optional'; import { Provider } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { EventEmitter2 } from '@nestjs/event-emitter'; @@ -112,11 +111,8 @@ describe('Websocket gateway', () => { jest .spyOn(sessionService, 'extractSessionIdFromRequest') - .mockImplementation( - (request: IncomingMessage): Optional => - Optional.ofNullable( - request.headers?.cookie === mockedValidSessionCookie ? mockedSessionIdWithUser : null, - ), + .mockImplementation((request: IncomingMessage): string | null => + request.headers?.cookie === mockedValidSessionCookie ? mockedSessionIdWithUser : null, ); const mockUsername = 'Testy'; diff --git a/backend/src/realtime/websocket/websocket.gateway.ts b/backend/src/realtime/websocket/websocket.gateway.ts index c3457e569..aef3df3b3 100644 --- a/backend/src/realtime/websocket/websocket.gateway.ts +++ b/backend/src/realtime/websocket/websocket.gateway.ts @@ -126,9 +126,9 @@ export class WebsocketGateway implements OnGatewayConnection { */ private async findUserIdByRequestSession(request: IncomingMessage): Promise { const sessionId = this.sessionService.extractSessionIdFromRequest(request); - if (sessionId.isEmpty()) { + if (sessionId === null) { return undefined; } - return await this.sessionService.getUserIdForSessionId(sessionId.get()); + return await this.sessionService.getUserIdForSessionId(sessionId); } } diff --git a/backend/src/security/rate-limiting.ts b/backend/src/security/rate-limiting.ts index 5adfc140a..e9b55ec77 100644 --- a/backend/src/security/rate-limiting.ts +++ b/backend/src/security/rate-limiting.ts @@ -17,9 +17,9 @@ interface RateLimitConfig { * Extracts the user ID from the session if present. * * @param req The incoming Fastify request - * @returns The user ID if authenticated, undefined otherwise + * @returns The user ID if authenticated, null otherwise */ -function getUserIdFromSession(req: FastifyRequest): number | undefined { +function getUserIdFromSession(req: FastifyRequest): number | null { return (req as RequestWithSession).session?.userId; } @@ -33,7 +33,7 @@ function getUserIdFromSession(req: FastifyRequest): number | undefined { */ export function generateRateLimitKey(req: FastifyRequest): string { const userId = getUserIdFromSession(req); - if (userId !== undefined) { + if (userId !== null) { return `user:${userId}`; } return `ip:${req.ip}`; diff --git a/backend/src/sessions/fastify-session.d.ts b/backend/src/sessions/fastify-session.d.ts deleted file mode 100644 index 2e8065b3d..000000000 --- a/backend/src/sessions/fastify-session.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import '@fastify/session'; -import { SessionState } from './session-state.type'; - -declare module 'fastify' { - interface Session extends SessionState {} -} diff --git a/backend/src/sessions/keyv-session-store.ts b/backend/src/sessions/keyv-session-store.ts deleted file mode 100644 index 5e40afaab..000000000 --- a/backend/src/sessions/keyv-session-store.ts +++ /dev/null @@ -1,51 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import Keyv from 'keyv'; -import { Session } from 'fastify'; -import { SessionStore } from '@fastify/session'; -import type {} from './fastify-session.d'; - -export interface SessionStoreOptions { - /** The time how long a session lives in seconds */ - ttl?: number; -} - -export class KeyvSessionStore implements SessionStore { - private readonly dataStore: Keyv; - - constructor(options?: SessionStoreOptions) { - this.dataStore = new Keyv({ - namespace: 'sessions', - ttl: options?.ttl ? options.ttl * 1000 : undefined, - // TODO Add support for non-in-memory keyv backends like redis/valkey - }); - } - - destroy(sessionId: string, callback: (error?: Error) => void): void { - this.dataStore - .delete(sessionId) - .then(() => callback()) - .catch(callback); - } - - get(sessionId: string, callback: (error: Error | null, session?: Session | null) => void): void { - this.dataStore - .get(sessionId) - .then((session) => callback(null, session ?? null)) - .catch((error: Error) => callback(error)); - } - - set(sessionId: string, session: Session, callback: (error?: Error) => void): void { - this.dataStore - .set(sessionId, session) - .then(() => callback()) - .catch(callback); - } - - getAsync(sessionId: string): Promise { - return this.dataStore.get(sessionId); - } -} diff --git a/backend/src/sessions/knex-session-store.spec.ts b/backend/src/sessions/knex-session-store.spec.ts new file mode 100644 index 000000000..aae39ed4d --- /dev/null +++ b/backend/src/sessions/knex-session-store.spec.ts @@ -0,0 +1,209 @@ +/* + * SPDX-FileCopyrightText: 2026 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { AuthProviderType } from '@hedgedoc/commons'; +import { FieldNameSession, Session, TableSession } from '@hedgedoc/database'; +import { Session as FastifySession } from 'fastify'; +import knex, { Knex } from 'knex'; +import { createTracker, MockClient, Tracker } from 'knex-mock-client'; + +import { expectBindings } from '../database/mock/expect-bindings'; +import { mockDelete, mockInsert, mockSelect } from '../database/mock/mock-queries'; +import { dateTimeToDB, dbToDateTime, isoStringToDateTime } from '../utils/datetime'; +import { KnexSessionStore } from './knex-session-store'; + +const mockNowIso = '2026-03-04T12:00:00.000Z'; +const mockNowDb = dateTimeToDB(isoStringToDateTime(mockNowIso)); +const ttl = 3600; +const mockExpiryDb = dateTimeToDB(isoStringToDateTime(mockNowIso).plus({ seconds: ttl })); + +const insertColumns = [ + FieldNameSession.createdAt, + FieldNameSession.csrfToken, + FieldNameSession.expiresAt, + FieldNameSession.id, + FieldNameSession.loginAuthProviderIdentifier, + FieldNameSession.loginAuthProviderType, + FieldNameSession.oidcIdToken, + FieldNameSession.oidcLoginCode, + FieldNameSession.oidcLoginState, + FieldNameSession.oidcSid, + FieldNameSession.pendingUserData, + FieldNameSession.updatedAt, + FieldNameSession.userId, +]; + +describe('KnexSessionStore', () => { + let db: Knex; + let tracker: Tracker; + let store: KnexSessionStore; + + beforeAll(() => { + db = knex({ client: MockClient, dialect: 'pg' }); + tracker = createTracker(db); + store = new KnexSessionStore({ ttl, knex: db }); + }); + + beforeEach(() => { + jest.useFakeTimers().setSystemTime(new Date(mockNowIso)); + }); + + afterEach(() => { + tracker.reset(); + jest.useRealTimers(); + }); + + describe('destroy', () => { + it('issues a DELETE for the given session ID', (done) => { + mockDelete(tracker, TableSession, [FieldNameSession.id], 1); + store.destroy('session-1', (error) => { + expect(error).toBeUndefined(); + expectBindings(tracker, 'delete', [['session-1']]); + done(); + }); + }); + }); + + describe('get', () => { + it('returns null when no matching session exists', (done) => { + mockSelect( + tracker, + [], + TableSession, + [FieldNameSession.id, FieldNameSession.expiresAt], + undefined, + ); + store.get('session-1', (error, session) => { + expect(error).toBeNull(); + expect(session).toBeNull(); + expectBindings(tracker, 'select', [['session-1', mockNowDb]], true); + done(); + }); + }); + + it('maps all database columns to the correct session fields', (done) => { + mockSelect( + tracker, + [], + TableSession, + [FieldNameSession.id, FieldNameSession.expiresAt], + [ + { + [FieldNameSession.id]: 'session-1', + [FieldNameSession.userId]: 42, + [FieldNameSession.csrfToken]: 'my-csrf', + [FieldNameSession.loginAuthProviderType]: AuthProviderType.LOCAL, + [FieldNameSession.loginAuthProviderIdentifier]: 'local', + [FieldNameSession.oidcIdToken]: 'id-tok', + [FieldNameSession.oidcSid]: 'oidc-sid', + [FieldNameSession.oidcLoginCode]: 'oidc-code', + [FieldNameSession.oidcLoginState]: 'oidc-state', + [FieldNameSession.pendingUserData]: JSON.stringify({ providerUserId: 'prov-1' }), + [FieldNameSession.createdAt]: mockNowDb, + [FieldNameSession.updatedAt]: mockNowDb, + [FieldNameSession.expiresAt]: mockExpiryDb, + } as Session, + ], + ); + store.get('session-1', (error, session) => { + expect(error).toBeNull(); + expect(session).not.toBeNull(); + expect(session?.userId).toBe(42); + expect(session?.csrfToken).toBe('my-csrf'); + expect(session?.loginAuthProviderType).toBe(AuthProviderType.LOCAL); + expect(session?.loginAuthProviderIdentifier).toBe('local'); + expect(session?.oidc.idToken).toBe('id-tok'); + expect(session?.oidc.sid).toBe('oidc-sid'); + expect(session?.oidc.loginCode).toBe('oidc-code'); + expect(session?.oidc.loginState).toBe('oidc-state'); + expect(session?.pendingUser).toEqual({ providerUserId: 'prov-1' }); + expect(session?.cookie?.expires).toEqual(dbToDateTime(mockExpiryDb).toJSDate()); + expect(session?.cookie?.signed).toBe(true); + expect(session?.cookie?.secure).toBe('auto'); + expect(session?.cookie?.httpOnly).toBe(true); + expect(session?.cookie?.sameSite).toBe('lax'); + expect(session?.cookie?.originalMaxAge).toBeNull(); + expectBindings(tracker, 'select', [['session-1', mockNowDb]], true); + done(); + }); + }); + }); + + describe('set', () => { + it('inserts a session where all optional fields are null', (done) => { + mockInsert(tracker, TableSession, insertColumns); + const session = { + cookie: { originalMaxAge: null }, + csrfToken: null, + userId: null, + loginAuthProviderType: null, + loginAuthProviderIdentifier: null, + oidc: { idToken: null, sid: null, loginCode: null, loginState: null }, + pendingUser: null, + } as FastifySession; + store.set('session-1', session, (error) => { + expect(error).toBeUndefined(); + expectBindings(tracker, 'insert', [ + [ + mockNowDb, + null, + mockExpiryDb, + 'session-1', + null, + null, + null, + null, + null, + null, + '{}', + mockNowDb, + null, + ], + ]); + done(); + }); + }); + + it('inserts a fully-populated session with auth provider and OIDC data', (done) => { + mockInsert(tracker, TableSession, insertColumns); + const pendingUser = { providerUserId: 'prov-1' }; + const session = { + cookie: { originalMaxAge: null }, + csrfToken: 'my-csrf', + userId: 42, + loginAuthProviderType: AuthProviderType.OIDC, + loginAuthProviderIdentifier: 'oidc-provider', + oidc: { + idToken: 'id-tok', + sid: 'oidc-sid', + loginCode: 'oidc-code', + loginState: 'oidc-state', + }, + pendingUser, + } as FastifySession; + store.set('session-1', session, (error) => { + expect(error).toBeUndefined(); + expectBindings(tracker, 'insert', [ + [ + mockNowDb, + 'my-csrf', + mockExpiryDb, + 'session-1', + 'oidc-provider', + AuthProviderType.OIDC, + 'id-tok', + 'oidc-code', + 'oidc-state', + 'oidc-sid', + JSON.stringify(pendingUser), + mockNowDb, + 42, + ], + ]); + done(); + }); + }); + }); +}); diff --git a/backend/src/sessions/knex-session-store.ts b/backend/src/sessions/knex-session-store.ts new file mode 100644 index 000000000..7b28652f5 --- /dev/null +++ b/backend/src/sessions/knex-session-store.ts @@ -0,0 +1,146 @@ +/* + * SPDX-FileCopyrightText: 2026 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { Session as FastifySession } from 'fastify'; +import { SessionStore } from '@fastify/session'; +import { Knex } from 'knex'; +import { FieldNameSession, Session, TableSession } from '@hedgedoc/database'; +import { dateTimeToDB, dbToDateTime, getCurrentDateTime } from '../utils/datetime'; +import { SessionState } from './session-state'; + +export interface SessionStoreOptions { + /** The time how long a session lives in seconds */ + ttl: number; + + /** The Knex instance to use for database operations */ + knex: Knex; +} + +/** + * Fastify session store implementation using Knex for database operations + */ +export class KnexSessionStore implements SessionStore { + private readonly knex: Knex; + private readonly ttl: number; + + constructor(options: SessionStoreOptions) { + this.knex = options.knex; + this.ttl = options.ttl; + } + + /** + * Destroy a session by its session ID + * @param sessionId The session ID to destroy + * @param callback Callback for fastify-session when the session has been destroyed + */ + destroy(sessionId: string, callback: (error?: Error) => void): void { + this.knex(TableSession) + .where(FieldNameSession.id, sessionId) + .delete() + .then(() => callback()) + .catch((error: Error) => callback(error)); + } + + /** + * Get a session by its session ID + * @param sessionId The session ID to get the session for + * @param callback Callback for fastify-session with the session or null if no session was found + */ + get( + sessionId: string, + callback: (error: Error | null, session?: FastifySession | null) => void, + ): void { + const nowDbTime = dateTimeToDB(getCurrentDateTime()); + this.knex(TableSession) + .where(FieldNameSession.id, sessionId) + .andWhere(FieldNameSession.expiresAt, '>', nowDbTime) + .first() + .then((entry?: Session) => { + const data = this.convertDatabaseEntryToSession(entry ?? null); + return callback(null, data); + }) + .catch((error: Error) => callback(error)); + } + + /** + * Set a session by its session ID + * @param sessionId The session ID to set the session for + * @param session The session to set + * @param callback Callback for fastify-session when the session has been set + */ + set(sessionId: string, session: FastifySession, callback: (error?: Error) => void): void { + const dbEntry = this.convertSessionToDatabaseEntry(sessionId, session); + this.knex(TableSession) + .insert(dbEntry) + .onConflict(FieldNameSession.id) + .merge([ + FieldNameSession.userId, + FieldNameSession.pendingUserData, + FieldNameSession.loginAuthProviderType, + FieldNameSession.loginAuthProviderIdentifier, + FieldNameSession.oidcIdToken, + FieldNameSession.oidcSid, + FieldNameSession.oidcLoginCode, + FieldNameSession.oidcLoginState, + FieldNameSession.expiresAt, + FieldNameSession.updatedAt, + // FieldNameSession.createdAt is missing intentionally because this should not be overwritten on updates + ]) + .then(() => callback()) + .catch((error: Error) => callback(error)); + } + + private convertDatabaseEntryToSession(dbEntry: Session | null): FastifySession | null { + if (dbEntry === null) { + return null; + } + return { + userId: dbEntry[FieldNameSession.userId], + csrfToken: dbEntry[FieldNameSession.csrfToken], + pendingUser: JSON.parse(dbEntry[FieldNameSession.pendingUserData]), + loginAuthProviderType: dbEntry[FieldNameSession.loginAuthProviderType], + loginAuthProviderIdentifier: dbEntry[FieldNameSession.loginAuthProviderIdentifier], + oidc: { + idToken: dbEntry[FieldNameSession.oidcIdToken], + sid: dbEntry[FieldNameSession.oidcSid], + loginCode: dbEntry[FieldNameSession.oidcLoginCode], + loginState: dbEntry[FieldNameSession.oidcLoginState], + }, + // all cookie attributes except the expiry are static and don't need to be stored and retrieved again + cookie: { + originalMaxAge: null, + expires: dbToDateTime(dbEntry[FieldNameSession.expiresAt]).toJSDate(), + signed: true, + secure: 'auto', + httpOnly: true, + sameSite: 'lax', + }, + }; + } + + private convertSessionToDatabaseEntry(sessionId: string, session: SessionState): Session { + const now = getCurrentDateTime(); + const nowDbTime = dateTimeToDB(now); + const expiry = now.plus({ seconds: this.ttl }); + const expiryDbTime = dateTimeToDB(expiry); + return { + // createdAt is always set to the current time in order for new sessions to work + // for existing sessions, the Knex call ignores the createdAt field explicitly, so we can safely set it here + [FieldNameSession.createdAt]: nowDbTime, + [FieldNameSession.updatedAt]: nowDbTime, + [FieldNameSession.expiresAt]: expiryDbTime, + [FieldNameSession.id]: sessionId, + [FieldNameSession.csrfToken]: session.csrfToken ?? null, + [FieldNameSession.userId]: session.userId ?? null, + [FieldNameSession.loginAuthProviderType]: session.loginAuthProviderType ?? null, + [FieldNameSession.loginAuthProviderIdentifier]: session.loginAuthProviderIdentifier ?? null, + [FieldNameSession.pendingUserData]: JSON.stringify(session.pendingUser ?? {}), + [FieldNameSession.oidcIdToken]: session.oidc?.idToken ?? null, + [FieldNameSession.oidcSid]: session.oidc?.sid ?? null, + [FieldNameSession.oidcLoginCode]: session.oidc?.loginCode ?? null, + [FieldNameSession.oidcLoginState]: session.oidc?.loginState ?? null, + }; + } +} diff --git a/backend/src/sessions/session-state.type.ts b/backend/src/sessions/session-state.ts similarity index 65% rename from backend/src/sessions/session-state.type.ts rename to backend/src/sessions/session-state.ts index d59cfea3e..ce3b2aa19 100644 --- a/backend/src/sessions/session-state.type.ts +++ b/backend/src/sessions/session-state.ts @@ -3,20 +3,21 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import { AuthProviderType } from '@hedgedoc/commons'; -import { FieldNameUser, User } from '@hedgedoc/database'; - -import { PendingUserInfoDto } from '../dtos/pending-user-info.dto'; +import type { AuthProviderType } from '@hedgedoc/commons'; +import type { PendingUserInfoDto } from '../dtos/pending-user-info.dto'; interface OidcAuthSessionState { /** The id token to identify a user session with an OIDC auth provider, required for the logout */ - idToken?: string; + idToken: string | null; /** The (random) OIDC code for verifying that OIDC responses match the OIDC requests */ - loginCode?: string; + loginCode: string | null; /** The (random) OIDC state for verifying that OIDC responses match the OIDC requests */ - loginState?: string; + loginState: string | null; + + /** The session ID from the OIDC provider, used for backchannel logout */ + sid: string | null; } interface PendingUserSessionState { @@ -35,7 +36,7 @@ interface PendingUserSessionState { export interface SessionState { /** Session cookie properties */ - cookie: { + cookie?: { originalMaxAge: number | null; maxAge?: number; signed?: boolean; @@ -47,18 +48,25 @@ export interface SessionState { sameSite?: boolean | 'lax' | 'strict' | 'none'; }; - /** Contains the username if logged in completely, is undefined when not being logged in */ - userId?: User[FieldNameUser.id]; + /** The current session owner's CSRF token */ + csrfToken: string | null; + + /** Contains the username if logged in completely, is null when not being logged in */ + userId: number | null; /** The auth provider that is used for the current login */ - authProviderType?: AuthProviderType; + loginAuthProviderType: AuthProviderType | null; /** The identifier of the auth provider that is used for the current login */ - authProviderIdentifier?: string; + loginAuthProviderIdentifier: string | null; /** Session data used on OIDC login */ - oidc?: OidcAuthSessionState; + oidc: OidcAuthSessionState; /** The user data of the user that is currently being created */ - pendingUser?: PendingUserSessionState; + pendingUser: PendingUserSessionState | null; +} + +declare module 'fastify' { + interface Session extends SessionState {} } diff --git a/backend/src/sessions/session.module.ts b/backend/src/sessions/session.module.ts index 3ea459ea4..5034f9d43 100644 --- a/backend/src/sessions/session.module.ts +++ b/backend/src/sessions/session.module.ts @@ -4,11 +4,12 @@ * SPDX-License-Identifier: AGPL-3.0-only */ import { Module } from '@nestjs/common'; +import { KnexModule } from 'nest-knexjs'; import { SessionService } from './session.service'; @Module({ - imports: [], + imports: [KnexModule], exports: [SessionService], providers: [SessionService], }) diff --git a/backend/src/sessions/session.service.spec.ts b/backend/src/sessions/session.service.spec.ts index a2c1d04be..2d2c6cf1f 100644 --- a/backend/src/sessions/session.service.spec.ts +++ b/backend/src/sessions/session.service.spec.ts @@ -6,6 +6,7 @@ import { Provider } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; +import { FieldNameSession, TableSession } from '@hedgedoc/database'; import { serialize } from 'cookie'; import { sign } from 'cookie-signature'; import type { Tracker } from 'knex-mock-client'; @@ -16,6 +17,7 @@ import { Mock } from 'ts-mockery'; import appConfigMock from '../config/mock/app.config.mock'; import { createDefaultMockAuthConfig, registerAuthConfig } from '../config/mock/auth.config.mock'; import { mockKnexDb } from '../database/mock/provider'; +import { mockSelect } from '../database/mock/mock-queries'; import { LoggerModule } from '../logger/logger.module'; import { HEDGEDOC_SESSION } from '../utils/session'; import { SessionService } from './session.service'; @@ -48,7 +50,7 @@ describe('SessionService', () => { }); it('getSessionStore', () => { - const store = service.getSessionStore(); + const store = service.getFastifySessionStore(); expect(store).toBeDefined(); }); @@ -56,24 +58,35 @@ describe('SessionService', () => { it('returns the correct user id for session id', async () => { const testSessionId = 'testSessionId'; const testUserId = 1337; - const sessionsStore = service.getSessionStore(); - sessionsStore.set( - testSessionId, - { - cookie: { - originalMaxAge: null, + mockSelect( + tracker, + [], + TableSession, + [FieldNameSession.id, FieldNameSession.expiresAt], + [ + { + [FieldNameSession.id]: testSessionId, + [FieldNameSession.userId]: testUserId, + [FieldNameSession.csrfToken]: null, + [FieldNameSession.loginAuthProviderType]: null, + [FieldNameSession.loginAuthProviderIdentifier]: null, + [FieldNameSession.oidcIdToken]: null, + [FieldNameSession.oidcSid]: null, + [FieldNameSession.oidcLoginCode]: null, + [FieldNameSession.oidcLoginState]: null, + [FieldNameSession.pendingUserData]: '{}', + [FieldNameSession.createdAt]: '2025-01-01 00:00:00', + [FieldNameSession.updatedAt]: '2025-01-01 00:00:00', + [FieldNameSession.expiresAt]: '2099-12-31 00:00:00', }, - userId: testUserId, - }, - async (error) => { - expect(error).toBeUndefined(); - const result = await service.getUserIdForSessionId(testSessionId); - expect(result).toEqual(testUserId); - }, + ], ); + const result = await service.getUserIdForSessionId(testSessionId); + expect(result).toEqual(testUserId); }); it('returns undefined for non-valid session id', async () => { const testSessionId = 'non-valid-session-id'; + mockSelect(tracker, [], TableSession, [FieldNameSession.id, FieldNameSession.expiresAt], []); const result = await service.getUserIdForSessionId(testSessionId); expect(result).toBeUndefined(); }); @@ -82,9 +95,9 @@ describe('SessionService', () => { describe('extractSessionIdFromRequest', () => { const mockSocket = Mock.of(); const sessionId = 'testSessionId'; - it('returns empty Optional if no cookie header is set', () => { + it('returns null if no cookie header is set', () => { const testRequest = new IncomingMessage(mockSocket); - expect(service.extractSessionIdFromRequest(testRequest).isEmpty()).toBe(true); + expect(service.extractSessionIdFromRequest(testRequest)).toBeNull(); }); it('returns empty Optional if cookie is malformed', async () => { const testRequest = new IncomingMessage(mockSocket); @@ -100,7 +113,7 @@ describe('SessionService', () => { const signature = sign(sessionId, authConfig.session.secret); const testRequest = new IncomingMessage(mockSocket); testRequest.headers.cookie = serialize(HEDGEDOC_SESSION, `s:${signature}`, {}); - expect(service.extractSessionIdFromRequest(testRequest).get()).toEqual(sessionId); + expect(service.extractSessionIdFromRequest(testRequest)).toEqual(sessionId); }); }); }); diff --git a/backend/src/sessions/session.service.ts b/backend/src/sessions/session.service.ts index d3b537449..a6713cbec 100644 --- a/backend/src/sessions/session.service.ts +++ b/backend/src/sessions/session.service.ts @@ -3,17 +3,17 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import { FieldNameUser, User } from '@hedgedoc/database'; -import { Optional } from '@mrdrogdrog/optional'; import { Inject, Injectable } from '@nestjs/common'; -import { parse as parseCookie } from 'cookie'; -import { unsign } from 'cookie-signature'; +import { fastifyCookie } from '@fastify/cookie'; import { IncomingMessage } from 'http'; +import { InjectConnection } from 'nest-knexjs'; +import { Knex } from 'knex'; +import { promisify } from 'node:util'; import authConfiguration, { AuthConfig } from '../config/auth.config'; import { ConsoleLoggerService } from '../logger/console-logger.service'; import { HEDGEDOC_SESSION } from '../utils/session'; -import { KeyvSessionStore } from './keyv-session-store'; +import { KnexSessionStore } from './knex-session-store'; /** * Finds {@link Session sessions} by session id and verifies session cookies. @@ -21,17 +21,21 @@ import { KeyvSessionStore } from './keyv-session-store'; @Injectable() export class SessionService { private static readonly sessionCookieContentRegex = /^s:(([^.]+)\.(.+))$/; - private readonly sessionStore: KeyvSessionStore; + private readonly sessionStore: KnexSessionStore; constructor( private readonly logger: ConsoleLoggerService, @Inject(authConfiguration.KEY) private authConfig: AuthConfig, + + @InjectConnection() + private readonly knex: Knex, ) { this.logger.setContext(SessionService.name); - this.sessionStore = new KeyvSessionStore({ + this.sessionStore = new KnexSessionStore({ ttl: authConfig.session.lifetime, + knex: this.knex, }); } @@ -41,7 +45,7 @@ export class SessionService { * * @returns The used session store */ - getSessionStore(): KeyvSessionStore { + getFastifySessionStore(): KnexSessionStore { return this.sessionStore; } @@ -51,9 +55,10 @@ export class SessionService { * @param sessionId The session id for which the owning user should be found * @returns A Promise that either resolves with the username or rejects with an error */ - async getUserIdForSessionId(sessionId: string): Promise { - const session = await this.sessionStore.getAsync(sessionId); - return session?.userId; + async getUserIdForSessionId(sessionId: string): Promise { + const getSession = promisify(this.sessionStore.get.bind(this.sessionStore)); + const session = await getSession(sessionId); + return session?.userId ?? undefined; } /** @@ -64,10 +69,13 @@ export class SessionService { * @throws Error if the cookie has been found but the content is malformed * @throws Error if the cookie has been found but the content isn't signed */ - extractSessionIdFromRequest(request: IncomingMessage): Optional { - return Optional.ofNullable(request.headers?.cookie) - .map((cookieHeader) => parseCookie(cookieHeader)[HEDGEDOC_SESSION]) - .map((rawCookie) => this.extractVerifiedSessionIdFromCookieContent(rawCookie)); + extractSessionIdFromRequest(request: IncomingMessage): string | null { + const cookies = fastifyCookie.parse(request.headers.cookie ?? ''); + const sessionCookie = cookies[HEDGEDOC_SESSION]; + if (!sessionCookie) { + return null; + } + return this.extractVerifiedSessionIdFromCookieContent(sessionCookie); } /** @@ -83,7 +91,7 @@ export class SessionService { if (parsedCookie === null) { throw new Error(`cookie "${HEDGEDOC_SESSION}" doesn't look like a signed session cookie`); } - if (unsign(parsedCookie[1], this.authConfig.session.secret) === false) { + if (!fastifyCookie.unsign(parsedCookie[1], this.authConfig.session.secret).valid) { throw new Error(`signature of cookie "${HEDGEDOC_SESSION}" isn't valid.`); } return parsedCookie[2]; diff --git a/backend/src/utils/session.ts b/backend/src/utils/session.ts index 278a80669..4d81ac8ee 100644 --- a/backend/src/utils/session.ts +++ b/backend/src/utils/session.ts @@ -9,7 +9,7 @@ import fastifyCookie from '@fastify/cookie'; import { NestFastifyApplication } from '@nestjs/platform-fastify'; import { AuthConfig } from '../config/auth.config'; -import { KeyvSessionStore } from '../sessions/keyv-session-store'; +import { KnexSessionStore } from '../sessions/knex-session-store'; export const HEDGEDOC_SESSION = 'hedgedoc-session'; @@ -23,7 +23,7 @@ export const HEDGEDOC_SESSION = 'hedgedoc-session'; export async function setupSessionMiddleware( app: INestApplication, authConfig: AuthConfig, - sessionStore: KeyvSessionStore, + sessionStore: KnexSessionStore, ): Promise { const fastifyApp = app as NestFastifyApplication; @@ -35,9 +35,12 @@ export async function setupSessionMiddleware( cookie: { // Handle session duration in seconds instead of ms maxAge: authConfig.session.lifetime * 1000, - secure: false, + secure: 'auto', + httpOnly: true, + sameSite: 'lax', }, saveUninitialized: false, + rolling: false, store: sessionStore, }); } diff --git a/database/src/types/index.ts b/database/src/types/index.ts index 240566b58..0dea85d26 100644 --- a/database/src/types/index.ts +++ b/database/src/types/index.ts @@ -3,19 +3,19 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ - export * from './alias.js' export * from './api-token.js' export * from './authorship-info.js' -export * from './group.js' export * from './group-user.js' +export * from './group.js' export * from './identity.js' export * from './media-upload.js' -export * from './note.js' export * from './note-group-permission.js' export * from './note-user-permission.js' -export * from './revision.js' +export * from './note.js' export * from './revision-tag.js' -export * from './user.js' +export * from './revision.js' +export * from './session.js' export * from './user-pinned-note.js' +export * from './user.js' export * from './visited-note.js' diff --git a/database/src/types/session.ts b/database/src/types/session.ts new file mode 100644 index 000000000..bba94a9e5 --- /dev/null +++ b/database/src/types/session.ts @@ -0,0 +1,75 @@ +/* + * SPDX-FileCopyrightText: 2026 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { AuthProviderType } from './identity' + +/** + * Database representation of a session + */ +export interface Session { + /** The session ID (primary key) */ + [FieldNameSession.id]: string + + /** The CSRF token for the current session */ + [FieldNameSession.csrfToken]: string | null + + /** The user ID associated with this session (foreign key to user table) */ + [FieldNameSession.userId]: number | null + + /** The auth provider that is used for the current login */ + [FieldNameSession.loginAuthProviderType]: AuthProviderType | null + + /** The identifier of the auth provider that is used for the current login */ + [FieldNameSession.loginAuthProviderIdentifier]: string | null + + /** The OIDC ID token used during authentication, for logout */ + [FieldNameSession.oidcIdToken]: string | null + + /** The OIDC secret code for verifying authentication callback */ + [FieldNameSession.oidcLoginCode]: string | null + + /** The OIDC state value for verifying authentication callback */ + [FieldNameSession.oidcLoginState]: string | null + + /** The OIDC session ID (sid) for OIDC provider backchannel logout */ + [FieldNameSession.oidcSid]: string | null + + /** JSON representation of the pending user data on registration */ + [FieldNameSession.pendingUserData]: string + + /** Timestamp when the session was created */ + [FieldNameSession.createdAt]: string + + /** Timestamp when the session was last updated */ + [FieldNameSession.updatedAt]: string + + /** Timestamp when the session expires */ + [FieldNameSession.expiresAt]: string +} + +/** + * Field names of the {@link Session} table + */ +export enum FieldNameSession { + id = 'id', + userId = 'user_id', + csrfToken = 'csrf_token', + loginAuthProviderType = 'login_auth_provider_type', + loginAuthProviderIdentifier = 'login_auth_provider_identifier', + oidcIdToken = 'oidc_id_token', + oidcLoginCode = 'oidc_login_code', + oidcLoginState = 'oidc_login_state', + oidcSid = 'oidc_sid', + pendingUserData = 'pending_user_data', + createdAt = 'created_at', + updatedAt = 'updated_at', + expiresAt = 'expires_at', +} + +/** + * Name of the session table + */ +export const TableSession = 'session'