🔐 OIDC back-channel logout (#521)

* 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 <monta@HP-ProBook-445-14-inch-G9-Notebook-PC-505aadfc.localdomain>
Co-authored-by: Anton SHEPILOV <ashepilov@linagora.com>
This commit is contained in:
Montassar Ghanmy
2024-05-24 12:52:58 +01:00
committed by GitHub
parent c8a9145906
commit b4412d3ed3
22 changed files with 426 additions and 22 deletions
@@ -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>(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<UserApi> {
const helpers = new UserApi(platform);
await helpers.init(newUser, options);
@@ -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);
});
});
@@ -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<Session> {
const session = new Session();
session.sid = sid;
session.sub = sub;
const sessionRepository = await this.database.getRepository<Session>("session", Session);
await sessionRepository.save(session);
return session;
}
async getSessionById(sid: string): Promise<Session> {
const sessionRepository = await this.database.getRepository<Session>("session", Session);
return sessionRepository.findOne({ sid });
}
async getSessionsByUserId(sub: string): Promise<Session[]> {
const sessionRepository = await this.database.getRepository<Session>("session", Session);
return (await sessionRepository.find({ sub })).getEntities();
}
async cleanUp() {
await this.database.getConnector().drop()
await this.database.getConnector().drop();
}
getRepository = (type, entity) => {