🔐 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
@@ -24,6 +24,7 @@ export default interface AuthServiceAPI extends TdriveServiceProvider {
generateJWT(
userId: uuid,
email: string,
session: string,
options: {
track: boolean;
provider_id: string;
@@ -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 || "",
@@ -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 },
@@ -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";
};
@@ -1,6 +1,7 @@
export type JwtType = {
type: "access" | "refresh";
sub: string;
sid: string;
provider_id: string; //Console sub
email: string;
application_id?: string;
@@ -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,
@@ -62,5 +62,11 @@ export interface ConsoleServiceClient {
getUserByAccessToken(idToken: string): Promise<ConsoleHookUser>;
updateUserSession(idToken: string): Promise<string>;
verifyJwtSid(_sid: string): Promise<void>;
backChannelLogout(logoutToken: string): Promise<void>;
resendVerificationEmail(email: string): Promise<void>;
}
@@ -100,8 +100,22 @@ export class ConsoleInternalClient implements ConsoleServiceClient {
throw new Error("Method should not be implemented.");
}
updateUserSession(_idToken: string): Promise<string> {
logger.info("Internal: updateUserSession");
throw new Error("Method should not be implemented.");
}
resendVerificationEmail(_accessToken: string): Promise<void> {
logger.info("Internal: resendVerificationEmail");
throw new Error("Method should not be implemented.");
}
backChannelLogout(_logoutToken: string): Promise<void> {
logger.info("Internal: backChannelLogout");
throw new Error("Method should not be implemented.");
}
async verifyJwtSid(_sid: string): Promise<void> {
logger.info("Internal: verifyJwtSid");
}
}
@@ -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;
}
}
@@ -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<string> {
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<void> {
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<void> {
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");
}
}
}
@@ -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<Session, "sid">;
export function getInstance(session: Partial<Session> & UserSessionPrimaryKey): Session {
return merge(new Session(), session);
}
@@ -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<Session>;
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", Session) : null;
return this;
}
@@ -52,6 +57,10 @@ export class ConsoleServiceImpl implements TdriveServiceProvider {
return ConsoleClientFactory.create(this);
}
getSessionRepo(): Repository<Session> {
return this.sessionRepository;
}
async processPendingUser(user: User, context?: ExecutionContext): Promise<void> {
await gr.services.workspaces.processPendingUser(user, null, context);
}
@@ -237,3 +237,7 @@ export interface AuthResponse {
access_token?: AccessToken;
error?: string;
}
export type ConsoleBackchannelLogoutRequest = {
logout_token: string;
};
@@ -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<void> {
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 });
}
}
}
@@ -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();
};
@@ -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,
+3
View File
@@ -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