From b4412d3ed3fc945f2b666cab915e0a4fd71e26d9 Mon Sep 17 00:00:00 2001 From: Montassar Ghanmy Date: Fri, 24 May 2024 12:52:58 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=90=20OIDC=20back-channel=20logout=20(?= =?UTF-8?q?#521)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: oidc backchannel session storage * feat: oidc backchannel logout * ref: e2e test * 🛠️ Code review * ♻️ Do not mock internal auth token but call "/login" with oidc_token instead * ♻️ Refactoring for back-channel logout test * fix: feedback and e2e tests * feat: more e2e tests * ♻️ Added test to test multiple login requests flow * ref: jwt sid verifier and e2e tests --------- Co-authored-by: Monta Co-authored-by: Anton SHEPILOV --- tdrive/backend/node/config/default.json | 1 - tdrive/backend/node/config/test.json | 5 + .../core/platform/services/auth/provider.ts | 1 + .../core/platform/services/auth/service.ts | 3 + .../core/platform/services/auth/web/jwt.ts | 8 +- .../services/database/services/orm/utils.ts | 4 +- .../src/core/platform/services/types/index.ts | 1 + .../applications-api/web/controllers/index.ts | 2 +- .../src/services/console/client-interface.ts | 6 + .../src/services/console/clients/internal.ts | 14 ++ .../console/clients/remote-jwks-verifier.ts | 8 ++ .../src/services/console/clients/remote.ts | 64 ++++++++- .../src/services/console/entities/session.ts | 26 ++++ .../node/src/services/console/service.ts | 9 ++ .../node/src/services/console/types.ts | 4 + .../src/services/console/web/controller.ts | 28 +++- .../node/src/services/console/web/routes.ts | 6 + .../documents/web/controllers/documents.ts | 15 +- tdrive/backend/node/src/utils/types.ts | 3 + .../backend/node/test/e2e/common/user-api.ts | 78 +++++++++- .../e2e/console/backchannel-logout.spec.ts | 134 ++++++++++++++++++ .../backend/node/test/e2e/utils.prepare.db.ts | 28 +++- 22 files changed, 426 insertions(+), 22 deletions(-) create mode 100644 tdrive/backend/node/src/services/console/entities/session.ts create mode 100644 tdrive/backend/node/test/e2e/console/backchannel-logout.spec.ts diff --git a/tdrive/backend/node/config/default.json b/tdrive/backend/node/config/default.json index 44e93518..10c85580 100644 --- a/tdrive/backend/node/config/default.json +++ b/tdrive/backend/node/config/default.json @@ -9,7 +9,6 @@ "mobile_googleplay": "https://play.google.com/store/apps/details?id=com.tdrive.tdrive&gl=FR" }, "accounts": { - "_type": "remote", "type": "internal", "internal": { "disable_account_creation": false diff --git a/tdrive/backend/node/config/test.json b/tdrive/backend/node/config/test.json index 851c4e70..d6462f97 100644 --- a/tdrive/backend/node/config/test.json +++ b/tdrive/backend/node/config/test.json @@ -1,4 +1,9 @@ { + "general": { + "accounts": { + "type": "remote" + } + }, "database": { "secret": "", "mongodb": { diff --git a/tdrive/backend/node/src/core/platform/services/auth/provider.ts b/tdrive/backend/node/src/core/platform/services/auth/provider.ts index fda645d0..a71bb369 100644 --- a/tdrive/backend/node/src/core/platform/services/auth/provider.ts +++ b/tdrive/backend/node/src/core/platform/services/auth/provider.ts @@ -24,6 +24,7 @@ export default interface AuthServiceAPI extends TdriveServiceProvider { generateJWT( userId: uuid, email: string, + session: string, options: { track: boolean; provider_id: string; diff --git a/tdrive/backend/node/src/core/platform/services/auth/service.ts b/tdrive/backend/node/src/core/platform/services/auth/service.ts index 1ed15849..22e76c99 100644 --- a/tdrive/backend/node/src/core/platform/services/auth/service.ts +++ b/tdrive/backend/node/src/core/platform/services/auth/service.ts @@ -31,6 +31,7 @@ export class AuthService implements AuthServiceAPI { generateJWT( userId: uuid, email: string, + session: string, options: { track: boolean; provider_id: string; @@ -54,6 +55,7 @@ export class AuthService implements AuthServiceAPI { iat: now - 60 * 10, nbf: now - 60 * 10, sub: userId, + sid: session, email: email, track: !!options.track, provider_id: options.provider_id || "", @@ -66,6 +68,7 @@ export class AuthService implements AuthServiceAPI { iat: now - 60 * 10, nbf: now - 60 * 10, sub: userId, + sid: session, email: email, track: !!options.track, provider_id: options.provider_id || "", diff --git a/tdrive/backend/node/src/core/platform/services/auth/web/jwt.ts b/tdrive/backend/node/src/core/platform/services/auth/web/jwt.ts index 9bfd49a9..a6de6e43 100644 --- a/tdrive/backend/node/src/core/platform/services/auth/web/jwt.ts +++ b/tdrive/backend/node/src/core/platform/services/auth/web/jwt.ts @@ -5,8 +5,9 @@ import fp from "fastify-plugin"; import config from "../../../../config"; import { JwtType } from "../../types"; import { executionStorage } from "../../../framework/execution-storage"; +import gr from "../../../../../services/global-resolver"; -const jwtPlugin: FastifyPluginCallback = (fastify, _opts, next) => { +const jwtPlugin: FastifyPluginCallback = async (fastify, _opts, next) => { fastify.register(cookie); fastify.register(fastifyJwt, { secret: config.get("auth.jwt.secret"), @@ -18,6 +19,10 @@ const jwtPlugin: FastifyPluginCallback = (fastify, _opts, next) => { const authenticate = async (request: FastifyRequest) => { const jwt: JwtType = await request.jwtVerify(); + + // Verify the SID exists and is valid + await gr.services.console.getClient().verifyJwtSid(jwt.sid); + if (jwt.type === "refresh") { // TODO in the future we must invalidate the refresh token (because it should be single use) } @@ -25,6 +30,7 @@ const jwtPlugin: FastifyPluginCallback = (fastify, _opts, next) => { request.currentUser = { ...{ email: jwt.email }, ...{ id: jwt.sub }, + ...{ sid: jwt.sid }, ...{ identity_provider_id: jwt.provider_id }, ...{ application_id: jwt.application_id || null }, ...{ server_request: jwt.server_request || false }, diff --git a/tdrive/backend/node/src/core/platform/services/database/services/orm/utils.ts b/tdrive/backend/node/src/core/platform/services/database/services/orm/utils.ts index 1f8bf8c5..61666b11 100644 --- a/tdrive/backend/node/src/core/platform/services/database/services/orm/utils.ts +++ b/tdrive/backend/node/src/core/platform/services/database/services/orm/utils.ts @@ -107,8 +107,8 @@ export function toMongoDbOrderable(timeuuid?: string): string { /** * Check if filtering is necessary * @param {string} key - * @returns {boolean} Returns true if key is "is_in_trash" or "scope", otherwise returns false. + * @returns {boolean} Returns true if key is "is_in_trash", "scope" or "sub", otherwise returns false. */ export const filteringRequired = (key: string) => { - return key === "is_in_trash" || key === "scope"; + return key === "is_in_trash" || key === "scope" || key === "sub"; }; diff --git a/tdrive/backend/node/src/core/platform/services/types/index.ts b/tdrive/backend/node/src/core/platform/services/types/index.ts index 30452e75..ca99548f 100644 --- a/tdrive/backend/node/src/core/platform/services/types/index.ts +++ b/tdrive/backend/node/src/core/platform/services/types/index.ts @@ -1,6 +1,7 @@ export type JwtType = { type: "access" | "refresh"; sub: string; + sid: string; provider_id: string; //Console sub email: string; application_id?: string; diff --git a/tdrive/backend/node/src/services/applications-api/web/controllers/index.ts b/tdrive/backend/node/src/services/applications-api/web/controllers/index.ts index 5b1ab5a0..c74c7779 100644 --- a/tdrive/backend/node/src/services/applications-api/web/controllers/index.ts +++ b/tdrive/backend/node/src/services/applications-api/web/controllers/index.ts @@ -39,7 +39,7 @@ export class ApplicationsApiController { return { resource: { - access_token: gr.platformServices.auth.generateJWT(request.body.id, null, { + access_token: gr.platformServices.auth.generateJWT(request.body.id, null, "", { track: false, provider_id: "", application_id: request.body.id, diff --git a/tdrive/backend/node/src/services/console/client-interface.ts b/tdrive/backend/node/src/services/console/client-interface.ts index 440147b3..c18d1c0d 100644 --- a/tdrive/backend/node/src/services/console/client-interface.ts +++ b/tdrive/backend/node/src/services/console/client-interface.ts @@ -62,5 +62,11 @@ export interface ConsoleServiceClient { getUserByAccessToken(idToken: string): Promise; + updateUserSession(idToken: string): Promise; + + verifyJwtSid(_sid: string): Promise; + + backChannelLogout(logoutToken: string): Promise; + resendVerificationEmail(email: string): Promise; } diff --git a/tdrive/backend/node/src/services/console/clients/internal.ts b/tdrive/backend/node/src/services/console/clients/internal.ts index ccbbfed9..b4b830c0 100644 --- a/tdrive/backend/node/src/services/console/clients/internal.ts +++ b/tdrive/backend/node/src/services/console/clients/internal.ts @@ -100,8 +100,22 @@ export class ConsoleInternalClient implements ConsoleServiceClient { throw new Error("Method should not be implemented."); } + updateUserSession(_idToken: string): Promise { + logger.info("Internal: updateUserSession"); + throw new Error("Method should not be implemented."); + } + resendVerificationEmail(_accessToken: string): Promise { logger.info("Internal: resendVerificationEmail"); throw new Error("Method should not be implemented."); } + + backChannelLogout(_logoutToken: string): Promise { + logger.info("Internal: backChannelLogout"); + throw new Error("Method should not be implemented."); + } + + async verifyJwtSid(_sid: string): Promise { + logger.info("Internal: verifyJwtSid"); + } } diff --git a/tdrive/backend/node/src/services/console/clients/remote-jwks-verifier.ts b/tdrive/backend/node/src/services/console/clients/remote-jwks-verifier.ts index ab180be0..9ce0e052 100644 --- a/tdrive/backend/node/src/services/console/clients/remote-jwks-verifier.ts +++ b/tdrive/backend/node/src/services/console/clients/remote-jwks-verifier.ts @@ -97,4 +97,12 @@ export class OidcJwtVerifier { return jwt; } + + async verifyLogoutToken(logoutTokenString: string) { + const jwt = await this.verifyAsPromise(logoutTokenString); + // verifyAudience(expectedClientId, jwt.claims.aud); + // verifyIssuer(this.issuer, jwt.claims.iss); + + return jwt; + } } diff --git a/tdrive/backend/node/src/services/console/clients/remote.ts b/tdrive/backend/node/src/services/console/clients/remote.ts index aa2b673b..1ffc9105 100644 --- a/tdrive/backend/node/src/services/console/clients/remote.ts +++ b/tdrive/backend/node/src/services/console/clients/remote.ts @@ -23,6 +23,7 @@ import { ConsoleServiceImpl } from "../service"; import coalesce from "../../../utils/coalesce"; import config from "config"; import { CompanyUserRole } from "src/services/user/web/types"; +import Session from "../entities/session"; export class ConsoleRemoteClient implements ConsoleServiceClient { version: "1"; @@ -166,7 +167,6 @@ export class ConsoleRemoteClient implements ConsoleServiceClient { await gr.services.users.save(user); //For now TDrive works with only one company as we don't get it from the SSO - let company = await gr.services.companies.getCompany({ id: "00000000-0000-4000-0000-000000000000", }); @@ -253,4 +253,66 @@ export class ConsoleRemoteClient implements ConsoleServiceClient { }, }; } + + async updateUserSession(idToken: string): Promise { + const sessionInfo = (await this.verifier.verifyIdToken(idToken, this.infos.client_id))?.claims; + // make sure sid claim is present in the token and not empty + if (sessionInfo.sid) { + const sessionRepository = gr.services.console.getSessionRepo(); + + // check for existing session + const existingSession = await sessionRepository.findOne({ + sid: sessionInfo.sid, + }); + if (existingSession) { + return existingSession.sid; + } else { + const sessionBody = new Session(); + sessionBody.sub = sessionInfo.sub; + sessionBody.sid = sessionInfo.sid; + await sessionRepository.save(sessionBody); + return sessionBody.sid; + } + } else { + throw new CrudException("Missing sid claim", 400); + } + } + + async backChannelLogout(logoutToken: string): Promise { + const payload = await this.verifier.verifyLogoutToken(logoutToken); + + if (!payload.iss || !payload.aud || !payload.iat || !payload.jti || !payload.events) { + throw new CrudException("Missing required claims", 400); + } + + if (payload.nonce) { + throw new CrudException("Nonce claim is prohibited", 400); + } + + if (!payload.sub && !payload.sid) { + throw new CrudException("Missing sub or sid claim", 400); + } + + const sessionRepository = gr.services.console.getSessionRepo(); + const session = await sessionRepository.findOne({ sid: payload.sid }); + if (session) { + await sessionRepository.remove(session); + } + } + + async verifyJwtSid(sid: string): Promise { + const sessionRepository = gr.services.console.getSessionRepo(); + if (sid) { + const session = await sessionRepository.findOne({ + sid, + }); + if (!session) { + // fail for not matching session id + throw new Error("Invalid session id"); + } + } else { + // fail for missing session id + throw new Error("Missing session id"); + } + } } diff --git a/tdrive/backend/node/src/services/console/entities/session.ts b/tdrive/backend/node/src/services/console/entities/session.ts new file mode 100644 index 00000000..43725ede --- /dev/null +++ b/tdrive/backend/node/src/services/console/entities/session.ts @@ -0,0 +1,26 @@ +import { merge } from "lodash"; +import { Column, Entity } from "../../../core/platform/services/database/services/orm/decorators"; + +export const TYPE = "session"; + +@Entity(TYPE, { + primaryKey: [["company_id"], "sid"], + globalIndexes: [["sid"]], + type: TYPE, +}) +export default class Session { + @Column("company_id", "uuid") + company_id: string; + + @Column("sub", "string") + sub: string; + + @Column("sid", "string") + sid: string; +} + +export type UserSessionPrimaryKey = Pick; + +export function getInstance(session: Partial & UserSessionPrimaryKey): Session { + return merge(new Session(), session); +} diff --git a/tdrive/backend/node/src/services/console/service.ts b/tdrive/backend/node/src/services/console/service.ts index c8c9342a..e4d72a3c 100644 --- a/tdrive/backend/node/src/services/console/service.ts +++ b/tdrive/backend/node/src/services/console/service.ts @@ -7,6 +7,8 @@ import gr from "../global-resolver"; import { Configuration, TdriveServiceProvider } from "../../core/platform/framework"; import assert from "assert"; import { ExecutionContext } from "../../core/platform/framework/api/crud-service"; +import Repository from "src/core/platform/services/database/services/orm/repository/repository"; +import Session from "./entities/session"; export class ConsoleServiceImpl implements TdriveServiceProvider { version: "1"; @@ -17,6 +19,7 @@ export class ConsoleServiceImpl implements TdriveServiceProvider { database: DatabaseServiceAPI; }; private configuration: Configuration; + private sessionRepository: Repository; constructor(options?: ConsoleOptions) { this.consoleOptions = options; @@ -44,6 +47,8 @@ export class ConsoleServiceImpl implements TdriveServiceProvider { this.consoleOptions.type = type; this.consoleType = type; + this.sessionRepository = + type === "remote" ? await gr.database.getRepository("session", Session) : null; return this; } @@ -52,6 +57,10 @@ export class ConsoleServiceImpl implements TdriveServiceProvider { return ConsoleClientFactory.create(this); } + getSessionRepo(): Repository { + return this.sessionRepository; + } + async processPendingUser(user: User, context?: ExecutionContext): Promise { await gr.services.workspaces.processPendingUser(user, null, context); } diff --git a/tdrive/backend/node/src/services/console/types.ts b/tdrive/backend/node/src/services/console/types.ts index 310fe281..9a100f92 100644 --- a/tdrive/backend/node/src/services/console/types.ts +++ b/tdrive/backend/node/src/services/console/types.ts @@ -237,3 +237,7 @@ export interface AuthResponse { access_token?: AccessToken; error?: string; } + +export type ConsoleBackchannelLogoutRequest = { + logout_token: string; +}; diff --git a/tdrive/backend/node/src/services/console/web/controller.ts b/tdrive/backend/node/src/services/console/web/controller.ts index 5cdad974..35a3217f 100644 --- a/tdrive/backend/node/src/services/console/web/controller.ts +++ b/tdrive/backend/node/src/services/console/web/controller.ts @@ -10,6 +10,7 @@ import { ConsoleHookResponse, ConsoleHookUser, ConsoleType, + ConsoleBackchannelLogoutRequest, } from "../types"; import Company from "../../user/entities/company"; import { CrudException } from "../../../core/platform/framework/api/crud-service"; @@ -132,6 +133,7 @@ export class ConsoleController { access_token: gr.platformServices.auth.generateJWT( request.currentUser.id, request.currentUser.email, + request.currentUser.sid, { track: request.currentUser?.allow_tracking || false, provider_id: request.currentUser.identity_provider_id, @@ -287,7 +289,7 @@ export class ConsoleController { logger.warn("ERROR_NOTONPROD: YOU ARE RUNNING IN DEVELOPMENT MODE, AUTH IS DISABLED!!!"); } - return gr.platformServices.auth.generateJWT(user.id, user.email_canonical, { + return gr.platformServices.auth.generateJWT(user.id, user.email_canonical, "", { track: user?.preferences?.allow_tracking || false, provider_id: user.identity_provider_id, }); @@ -297,12 +299,34 @@ export class ConsoleController { const client = gr.services.console.getClient(); const userDTO = await client.getUserByAccessToken(idToken); const user = await client.updateLocalUserFromConsole(userDTO); + if (!user) { throw CrudException.notFound(`User details not found for access token ${idToken}`); } - return gr.platformServices.auth.generateJWT(user.id, user.email_canonical, { + + // update the user session + const session = await client.updateUserSession(idToken); + + return gr.platformServices.auth.generateJWT(user.id, user.email_canonical, session, { track: user?.preferences?.allow_tracking || false, provider_id: user.identity_provider_id, }); } + + async backChannelLogout( + request: FastifyRequest<{ Body: ConsoleBackchannelLogoutRequest }>, + reply: FastifyReply, + ): Promise { + try { + const logoutToken = request.body.logout_token; + if (!logoutToken) { + throw new CrudException("Missing logout_token", 400); + } + await gr.services.console.getClient().backChannelLogout(logoutToken); + reply.status(200).send({ success: true }); + } catch (e) { + logger.error(e.message); + reply.status(400).send({ error: e.message }); + } + } } diff --git a/tdrive/backend/node/src/services/console/web/routes.ts b/tdrive/backend/node/src/services/console/web/routes.ts index 7edcd1b2..638ae94f 100644 --- a/tdrive/backend/node/src/services/console/web/routes.ts +++ b/tdrive/backend/node/src/services/console/web/routes.ts @@ -51,6 +51,12 @@ const routes: FastifyPluginCallback = (fastify: FastifyInstance, options, next) handler: controller.resendVerificationEmail.bind(controller), }); + fastify.route({ + method: "POST", + url: "/backchannel_logout", + handler: controller.backChannelLogout.bind(controller), + }); + next(); }; diff --git a/tdrive/backend/node/src/services/documents/web/controllers/documents.ts b/tdrive/backend/node/src/services/documents/web/controllers/documents.ts index 010aeeaf..aeb9378d 100644 --- a/tdrive/backend/node/src/services/documents/web/controllers/documents.ts +++ b/tdrive/backend/node/src/services/documents/web/controllers/documents.ts @@ -554,11 +554,16 @@ export class DocumentsController { } await globalResolver.services.companies.setUserRole(document.item.company_id, user.id, "guest"); - const token = globalResolver.platformServices.auth.generateJWT(user.id, user.email_canonical, { - track: false, - provider_id: "tdrive", - public_token_document_id: req.body.document_id, - }); + const token = globalResolver.platformServices.auth.generateJWT( + user.id, + user.email_canonical, + "", + { + track: false, + provider_id: "tdrive", + public_token_document_id: req.body.document_id, + }, + ); return { access_token: token, diff --git a/tdrive/backend/node/src/utils/types.ts b/tdrive/backend/node/src/utils/types.ts index 2061fb0a..173ec1ef 100644 --- a/tdrive/backend/node/src/utils/types.ts +++ b/tdrive/backend/node/src/utils/types.ts @@ -22,6 +22,9 @@ export interface User { identity_provider_id?: uuid; // user email email?: string; + + // session id + sid?: string; // server request server_request?: boolean; //Set to true if request if from the user, can be used to cancel any access restriction // application call diff --git a/tdrive/backend/node/test/e2e/common/user-api.ts b/tdrive/backend/node/test/e2e/common/user-api.ts index 39c76454..370d4f7b 100644 --- a/tdrive/backend/node/test/e2e/common/user-api.ts +++ b/tdrive/backend/node/test/e2e/common/user-api.ts @@ -1,6 +1,6 @@ // @ts-ignore import fs from "fs"; -import { Readable } from 'stream'; +import { Readable } from "stream"; import { ResourceUpdateResponse, Workspace } from "../../../src/utils/types"; import { File } from "../../../src/services/files/entities/file"; import { deserialize } from "class-transformer"; @@ -11,7 +11,7 @@ import { TestDbService } from "../utils.prepare.db"; import { DriveFile } from "../../../src/services/documents/entities/drive-file"; import { FileVersion } from "../../../src/services/documents/entities/file-version"; import { - AccessTokenMockClass, DriveFileMockClass, + AccessTokenMockClass, DriveItemDetailsMockClass, SearchResultMockClass, UserQuotaMockClass @@ -21,6 +21,7 @@ import { expect } from "@jest/globals"; import { publicAccessLevel } from "../../../src/services/documents/types"; import { UserQuota } from "../../../src/services/user/web/types"; import { Api } from "../utils.api"; +import { OidcJwtVerifier } from "../../../src/services/console/clients/remote-jwks-verifier"; export default class UserApi { @@ -43,6 +44,7 @@ export default class UserApi { workspace: Workspace; jwt: string; api: Api; + session: string; private constructor( platform: TestPlatform @@ -54,12 +56,15 @@ export default class UserApi { this.dbService = await TestDbService.getInstance(this.platform, true); if (newUser) { this.workspace = this.platform.workspace; - const workspacePK = { id: this.workspace.workspace_id, company_id: this.workspace.company_id }; + const workspacePK = { + id: this.workspace.workspace_id, + company_id: this.workspace.company_id, + }; this.user = await this.dbService.createUser([workspacePK], options, uuidv1()); this.anonymous = await this.dbService.createUser([workspacePK], { ...options, - identity_provider: "anonymous" + identity_provider: "anonymous", }, uuidv1()); } else { @@ -67,9 +72,72 @@ export default class UserApi { this.workspace = this.platform.workspace; } this.api = new Api(this.platform, this.user); - this.jwt = this.getJWTTokenForUser(this.user.id); + this.jwt = await this.doLogin(); } + private async doLogin() { + const loginResponse = await this.login(); + + expect(loginResponse).toBeDefined(); + expect(loginResponse.statusCode).toEqual(200); + + const accessToken = deserialize(AccessTokenMockClass, loginResponse.body); + if (!accessToken.access_token?.value) + throw Error("Auth error: authentication token doesn't exists in response"); + return accessToken.access_token.value; + } + + /** + * Just send the login requests without any validation and login response assertion + */ + public async login(session?: string) { + if (session !== undefined) { + this.session = session; + } else { + this.session = uuidv1(); + } + const payload = { + claims: { + sub: this.user.id, + first_name: this.user.first_name, + sid: this.session, + }, + }; + const verifierMock = jest.spyOn(OidcJwtVerifier.prototype, "verifyIdToken"); + verifierMock.mockImplementation(() => { + return Promise.resolve(payload); // Return the predefined payload + }); + return await this.api.post("/internal/services/console/v1/login", { + oidc_id_token: "sample_oidc_token", + }); + } + + public async logout() { + const payload = { + iss: "tdrive_lemonldap", + sub: this.user.id, + sid: this.session, + aud: "your-audience", + iat: Math.floor(Date.now() / 1000), + jti: "jwt-id", + events: { + "http://schemas.openid.net/event/backchannel-logout": {}, + }, + }; + const verifierMock = jest.spyOn(OidcJwtVerifier.prototype, "verifyLogoutToken"); + verifierMock.mockImplementation(() => { + return Promise.resolve(payload); // Return the predefined payload + }); + const logoutToken = "logout_token_rsa256"; + + const response = await this.api.post("/internal/services/console/v1/backchannel_logout", { + logout_token: logoutToken, + }); + + return response; + } + + public static async getInstance(platform: TestPlatform, newUser = false, options?: {}): Promise { const helpers = new UserApi(platform); await helpers.init(newUser, options); diff --git a/tdrive/backend/node/test/e2e/console/backchannel-logout.spec.ts b/tdrive/backend/node/test/e2e/console/backchannel-logout.spec.ts new file mode 100644 index 00000000..220ce6f6 --- /dev/null +++ b/tdrive/backend/node/test/e2e/console/backchannel-logout.spec.ts @@ -0,0 +1,134 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "@jest/globals"; +import { init, TestPlatform } from "../setup"; +import { TestDbService } from "../utils.prepare.db"; +import UserApi from "../common/user-api"; + +describe("The /backchannel_logout API", () => { + const url = "/internal/services/console/v1/backchannel_logout"; + let platform: TestPlatform; + let testDbService: TestDbService; + let currentUser: UserApi; + + beforeEach(async () => { + platform = await init(); + currentUser = await UserApi.getInstance(platform); + }); + + beforeAll(async () => { + platform = await init({ + services: [ + "database", + "search", + "message-queue", + "websocket", + "applications", + "webserver", + "user", + "auth", + "storage", + "counter", + "console", + "workspaces", + "statistics", + "platform-services", + ], + }); + testDbService = await TestDbService.getInstance(platform); + }); + + afterAll(async () => { + await platform.tearDown(); + platform = null; + }); + + it("should 400 when logout_token is missing", async () => { + const response = await platform.app.inject({ + method: "POST", + url: url, + payload: {}, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ + error: "Missing logout_token", + }); + }); + + it("should 200 when valid logout_token is provided", async () => { + const response = await currentUser.logout(); + expect(response.statusCode).toBeDefined(); + expect(response.statusCode).toBe(200); + + // Verify the session is removed from the database + const deletedSession = await currentUser.dbService.getSessionById(currentUser.session); + expect(deletedSession).toBeNull(); + expect((await currentUser.dbService.getSessionsByUserId(currentUser.user.id)).length).toEqual(0); + + }); + + it("should create a session on login", async () => { + const session = currentUser.session; + expect(session).not.toBeNull(); + expect(session).not.toBeUndefined(); + }); + + it("should receive 401 after logout and trying to access with the same token", async () => { + const myDriveId = "user_" + currentUser.user.id; + + let response = await currentUser.getDocument(myDriveId); + expect(response.statusCode).toBe(200); + + await currentUser.logout(); + + response = await currentUser.getDocument(myDriveId); + expect(response.statusCode).toBe(401); + }); + + it("should be able to log-in several times by having multiple sessions", async () => { + // Perform a second login + const newUserSession = await UserApi.getInstance(platform); + + //two sessions are different + expect(newUserSession.session).not.toEqual(currentUser.session); + + // Verify that the user has two sessions + const oldSession = await testDbService.getSessionById(currentUser.session); + expect(oldSession).not.toBeNull(); + expect(oldSession.sub).toBe(currentUser.user.id); + + const newSession = await testDbService.getSessionById(newUserSession.session); + expect(newSession).not.toBeNull(); + expect(newSession.sub).toBe(currentUser.user.id); + + //check that we can send requests for both session + expect((await currentUser.getDocument("user_" + currentUser.user.id)).statusCode).toEqual(200); + expect((await newUserSession.getDocument("user_" + currentUser.user.id)).statusCode).toEqual(200); + + }); + + it("should logout from one session and still be logged in another", async () => { + // Perform a second login + const newUserSession = await UserApi.getInstance(platform); + + await currentUser.logout(); + + // Verify session1 is removed + expect((await currentUser.getDocument("user_" + currentUser.user.id)).statusCode).toEqual(401); + expect((await newUserSession.getDocument("user_" + currentUser.user.id)).statusCode).toEqual(200); + }); + + it("I want to be able to log-in/recieve access token several time with the same session id", async () => { + await currentUser.login(currentUser.session); + await currentUser.login(currentUser.session); + + expect((await currentUser.getDocument("user_" + currentUser.user.id)).statusCode).toEqual(200); + const sessions = await currentUser.dbService.getSessionsByUserId(currentUser.user.id); + expect(sessions.length).toEqual(1); + }); + + it("should fail to login with empty session id", async () => { + const response = await currentUser.login(""); + expect(response.statusCode).toBeDefined(); + expect(response.statusCode).toBe(400); + }); +}); diff --git a/tdrive/backend/node/test/e2e/utils.prepare.db.ts b/tdrive/backend/node/test/e2e/utils.prepare.db.ts index ac30b4ce..dd27cdcb 100644 --- a/tdrive/backend/node/test/e2e/utils.prepare.db.ts +++ b/tdrive/backend/node/test/e2e/utils.prepare.db.ts @@ -7,6 +7,7 @@ import Workspace, { getInstance as getWorkspaceInstance, WorkspacePrimaryKey, } from "./../../src/services/workspaces/entities/workspace"; +import Session from "../../src/services/console/entities/session"; import { v1 as uuidv1 } from "uuid"; import CompanyUser from "../../src/services/user/entities/company_user"; @@ -161,9 +162,9 @@ export class TestDbService { this.users.push(createdUser); if (workspacesPk && workspacesPk.length) { await gr.services.companies.setUserRole( - this.company ? this.company.id : workspacesPk[0].company_id, - createdUser.id, - options.companyRole ? options.companyRole : "member", + this.company ? this.company.id : workspacesPk[0].company_id, + createdUser.id, + options.companyRole ? options.companyRole : "member", ); } @@ -255,8 +256,27 @@ export class TestDbService { return this; } + async createSession(sid: string, sub: string): Promise { + const session = new Session(); + session.sid = sid; + session.sub = sub; + const sessionRepository = await this.database.getRepository("session", Session); + await sessionRepository.save(session); + return session; + } + + async getSessionById(sid: string): Promise { + const sessionRepository = await this.database.getRepository("session", Session); + return sessionRepository.findOne({ sid }); + } + + async getSessionsByUserId(sub: string): Promise { + const sessionRepository = await this.database.getRepository("session", Session); + return (await sessionRepository.find({ sub })).getEntities(); + } + async cleanUp() { - await this.database.getConnector().drop() + await this.database.getConnector().drop(); } getRepository = (type, entity) => {