diff --git a/app/stores/AuthStore.ts b/app/stores/AuthStore.ts index 5f4d80168..f5b1f993d 100644 --- a/app/stores/AuthStore.ts +++ b/app/stores/AuthStore.ts @@ -144,7 +144,9 @@ export default class AuthStore { @action fetch = async () => { try { - const res = await client.post("/auth.info"); + const res = await client.post("/auth.info", undefined, { + credentials: "same-origin", + }); invariant(res?.data, "Auth not available"); runInAction("AuthStore#fetch", () => { this.addPolicies(res.policies); diff --git a/app/utils/ApiClient.ts b/app/utils/ApiClient.ts index d4f2635ca..f4591911a 100644 --- a/app/utils/ApiClient.ts +++ b/app/utils/ApiClient.ts @@ -25,6 +25,7 @@ type Options = { type FetchOptions = { download?: boolean; + credentials?: "omit" | "same-origin" | "include"; headers?: Record; }; @@ -111,7 +112,11 @@ class ApiClient { // not needed for authentication this offers a performance increase. // For self-hosted we include them to support a wide variety of // authenticated proxies, e.g. Pomerium, Cloudflare Access etc. - credentials: isCloudHosted ? "omit" : "same-origin", + credentials: options.credentials + ? options.credentials + : isCloudHosted + ? "omit" + : "same-origin", cache: "no-cache", }); } catch (err) { diff --git a/server/migrations/20220816175234-user-email-index.js b/server/migrations/20220816175234-user-email-index.js new file mode 100644 index 000000000..9e97b2b02 --- /dev/null +++ b/server/migrations/20220816175234-user-email-index.js @@ -0,0 +1,11 @@ +'use strict'; + +module.exports = { + async up (queryInterface) { + await queryInterface.addIndex("users", ["email"]); + }, + + async down (queryInterface) { + await queryInterface.removeIndex("users", ["email"]); + } +}; diff --git a/server/models/User.test.ts b/server/models/User.test.ts index e921813e2..362abc924 100644 --- a/server/models/User.test.ts +++ b/server/models/User.test.ts @@ -26,12 +26,26 @@ describe("user model", () => { expect(await UserAuthentication.count()).toBe(0); }); }); + describe("getJwtToken", () => { it("should set JWT secret", async () => { const user = await buildUser(); expect(user.getJwtToken()).toBeTruthy(); }); }); + + describe("availableTeams", () => { + it("should return teams where another user with the same email exists", async () => { + const user = await buildUser(); + const anotherUser = await buildUser({ email: user.email }); + + const response = await user.availableTeams(); + expect(response.length).toEqual(2); + expect(response[0].id).toEqual(user.teamId); + expect(response[1].id).toEqual(anotherUser.teamId); + }); + }); + describe("collectionIds", () => { it("should return read_write collections", async () => { const team = await buildTeam(); diff --git a/server/models/User.ts b/server/models/User.ts index 404571b54..ffa69dbfc 100644 --- a/server/models/User.ts +++ b/server/models/User.ts @@ -405,6 +405,23 @@ class User extends ParanoidModel { ); }; + /** + * Returns a list of teams that have a user matching this user's email. + * + * @returns A promise resolving to a list of teams + */ + availableTeams = async () => { + return Team.findAll({ + include: [ + { + model: this.constructor as typeof User, + required: true, + where: { email: this.email }, + }, + ], + }); + }; + demote = async (to: UserRole, options?: SaveOptions) => { const res = await (this.constructor as typeof User).findAndCountAll({ where: { diff --git a/server/presenters/availableTeam.ts b/server/presenters/availableTeam.ts new file mode 100644 index 000000000..175345800 --- /dev/null +++ b/server/presenters/availableTeam.ts @@ -0,0 +1,11 @@ +import { Team } from "@server/models"; + +export default function present(team: Team, isSignedIn = false) { + return { + id: team.id, + name: team.name, + avatarUrl: team.logoUrl, + url: team.url, + isSignedIn, + }; +} diff --git a/server/presenters/index.ts b/server/presenters/index.ts index cf82fec15..894739d2d 100644 --- a/server/presenters/index.ts +++ b/server/presenters/index.ts @@ -1,5 +1,6 @@ import presentApiKey from "./apiKey"; import presentAuthenticationProvider from "./authenticationProvider"; +import presentAvailableTeam from "./availableTeam"; import presentCollection from "./collection"; import presentCollectionGroupMembership from "./collectionGroupMembership"; import presentDocument from "./document"; @@ -26,28 +27,29 @@ import presentWebhookSubscription from "./webhookSubscription"; export { presentApiKey, - presentFileOperation, presentAuthenticationProvider, - presentUser, - presentView, + presentAvailableTeam, + presentCollection, + presentCollectionGroupMembership, presentDocument, presentEvent, - presentRevision, - presentCollection, - presentShare, - presentSearchQuery, - presentStar, - presentSubscription, - presentTeam, + presentFileOperation, presentGroup, + presentGroupMembership, presentIntegration, presentMembership, presentNotificationSetting, - presentSlackAttachment, presentPin, presentPolicies, - presentGroupMembership, - presentCollectionGroupMembership, + presentRevision, + presentSearchQuery, + presentShare, + presentSlackAttachment, + presentStar, + presentSubscription, + presentTeam, + presentUser, + presentView, presentWebhook, presentWebhookSubscription, }; diff --git a/server/presenters/team.ts b/server/presenters/team.ts index 400941cd1..d48daa2f9 100644 --- a/server/presenters/team.ts +++ b/server/presenters/team.ts @@ -16,6 +16,6 @@ export default function present(team: Team) { url: team.url, defaultUserRole: team.defaultUserRole, inviteRequired: team.inviteRequired, - allowedDomains: team.allowedDomains.map((d) => d.name), + allowedDomains: team.allowedDomains?.map((d) => d.name), }; } diff --git a/server/routes/api/auth.test.ts b/server/routes/api/auth.test.ts index 96d80b43e..f84b070d5 100644 --- a/server/routes/api/auth.test.ts +++ b/server/routes/api/auth.test.ts @@ -3,6 +3,16 @@ import env from "@server/env"; import { buildUser, buildTeam } from "@server/test/factories"; import { getTestDatabase, getTestServer } from "@server/test/support"; +const mockTeamInSessionId = "1e023d05-951c-41c6-9012-c9fa0402e1c3"; + +jest.mock("@server/utils/authentication", () => { + return { + getSessionsInCookie() { + return { [mockTeamInSessionId]: {} }; + }, + }; +}); + const db = getTestDatabase(); const server = getTestServer(); @@ -13,16 +23,32 @@ beforeEach(db.flush); describe("#auth.info", () => { it("should return current authentication", async () => { const team = await buildTeam(); + const team2 = await buildTeam(); + const team3 = await buildTeam({ + id: mockTeamInSessionId, + }); + const user = await buildUser({ teamId: team.id, }); + await buildUser(); + await buildUser({ + teamId: team2.id, + email: user.email, + }); const res = await server.post("/api/auth.info", { body: { token: user.getJwtToken(), }, }); const body = await res.json(); + const availableTeamIds = body.data.availableTeams.map((t: any) => t.id); + expect(res.status).toEqual(200); + expect(availableTeamIds.length).toEqual(3); + expect(availableTeamIds).toContain(team.id); + expect(availableTeamIds).toContain(team2.id); + expect(availableTeamIds).toContain(team3.id); expect(body.data.user.name).toBe(user.name); expect(body.data.team.name).toBe(team.name); expect(body.data.team.allowedDomains).toEqual([]); diff --git a/server/routes/api/auth.ts b/server/routes/api/auth.ts index dbef44147..df7911f29 100644 --- a/server/routes/api/auth.ts +++ b/server/routes/api/auth.ts @@ -1,12 +1,18 @@ import Router from "koa-router"; -import { find } from "lodash"; +import { find, uniqBy } from "lodash"; import { parseDomain } from "@shared/utils/domains"; import { sequelize } from "@server/database/sequelize"; import env from "@server/env"; import auth from "@server/middlewares/authentication"; import { Event, Team } from "@server/models"; -import { presentUser, presentTeam, presentPolicies } from "@server/presenters"; +import { + presentUser, + presentTeam, + presentPolicies, + presentAvailableTeam, +} from "@server/presenters"; import ValidateSSOAccessTask from "@server/queues/tasks/ValidateSSOAccessTask"; +import { getSessionsInCookie } from "@server/utils/authentication"; import providers from "../auth/providers"; const router = new Router(); @@ -107,9 +113,20 @@ router.post("auth.config", async (ctx) => { router.post("auth.info", auth(), async (ctx) => { const { user } = ctx.state; - const team = await Team.scope("withDomains").findByPk(user.teamId, { - rejectOnEmpty: true, - }); + const sessions = getSessionsInCookie(ctx); + const signedInTeamIds = Object.keys(sessions); + + const [team, signedInTeams, availableTeams] = await Promise.all([ + Team.scope("withDomains").findByPk(user.teamId, { + rejectOnEmpty: true, + }), + Team.findAll({ + where: { + id: signedInTeamIds, + }, + }), + user.availableTeams(), + ]); await ValidateSSOAccessTask.schedule({ userId: user.id }); @@ -119,6 +136,15 @@ router.post("auth.info", auth(), async (ctx) => { includeDetails: true, }), team: presentTeam(team), + availableTeams: uniqBy( + [...signedInTeams, ...availableTeams], + "id" + ).map((team) => + presentAvailableTeam( + team, + signedInTeamIds.includes(team.id) || team.id === user.teamId + ) + ), }, policies: presentPolicies(user, [team]), }; diff --git a/server/utils/authentication.ts b/server/utils/authentication.ts index c1f3dccd3..e3983a163 100644 --- a/server/utils/authentication.ts +++ b/server/utils/authentication.ts @@ -7,6 +7,20 @@ import env from "@server/env"; import Logger from "@server/logging/Logger"; import { User, Event, Team, Collection, View } from "@server/models"; +/** + * Parse and return the details from the "sessions" cookie in the request, if + * any. The cookie is on the apex domain and includes session details for + * other subdomains. + * + * @param ctx The Koa context + * @returns The session details + */ +export function getSessionsInCookie(ctx: Context) { + return JSON.parse( + decodeURIComponent(ctx.cookies.get("sessions") || "") || "{}" + ); +} + export async function signIn( ctx: Context, user: User, @@ -68,9 +82,7 @@ export async function signIn( // to the teams subdomain if subdomains are enabled if (env.SUBDOMAINS_ENABLED && team.subdomain) { // get any existing sessions (teams signed in) and add this team - const existing = JSON.parse( - decodeURIComponent(ctx.cookies.get("sessions") || "") || "{}" - ); + const existing = getSessionsInCookie(ctx); const sessions = encodeURIComponent( JSON.stringify({ ...existing,