Add ability to link Slack <-> Outline accounts (#6682)

This commit is contained in:
Tom Moor
2024-03-18 19:21:38 -06:00
committed by GitHub
parent e294fafd4f
commit cbdacc7cfd
23 changed files with 647 additions and 421 deletions

View File

@@ -2,25 +2,11 @@ import isEmpty from "lodash/isEmpty";
import { z } from "zod";
import { BaseSchema } from "@server/routes/api/schema";
export const SlackCommandsSchema = BaseSchema.extend({
query: z
.object({
code: z.string().nullish(),
state: z.string().uuid().nullish(),
error: z.string().nullish(),
})
.refine((req) => !(isEmpty(req.code) && isEmpty(req.error)), {
message: "one of code or error is required",
}),
});
export type SlackCommandsReq = z.infer<typeof SlackCommandsSchema>;
export const SlackPostSchema = BaseSchema.extend({
query: z
.object({
code: z.string().nullish(),
state: z.string().uuid().nullish(),
state: z.string(),
error: z.string().nullish(),
})
.refine((req) => !(isEmpty(req.code) && isEmpty(req.error)), {

View File

@@ -2,24 +2,6 @@ import { getTestServer } from "@server/test/support";
const server = getTestServer();
describe("#slack.commands", () => {
it("should fail with status 400 bad request if query param state is not a uuid", async () => {
const res = await server.get("/auth/slack.commands?state=123");
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.message).toEqual("state: Invalid uuid");
});
it("should fail with status 400 bad request when both code and error are missing in query params", async () => {
const res = await server.get(
"/auth/slack.commands?state=182d14d5-0dbd-4521-ac52-25484c25c96e"
);
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.message).toEqual("query: one of code or error is required");
});
});
describe("#slack.post", () => {
it("should fail with status 400 bad request if query param state is not a uuid", async () => {
const res = await server.get("/auth/slack.post?state=123");

View File

@@ -5,16 +5,19 @@ import { Profile } from "passport";
import { Strategy as SlackStrategy } from "passport-slack-oauth2";
import { IntegrationService, IntegrationType } from "@shared/types";
import accountProvisioner from "@server/commands/accountProvisioner";
import { ValidationError } from "@server/errors";
import auth from "@server/middlewares/authentication";
import passportMiddleware from "@server/middlewares/passport";
import validate from "@server/middlewares/validate";
import {
IntegrationAuthentication,
Collection,
Integration,
Team,
User,
Collection,
} from "@server/models";
import { authorize } from "@server/policies";
import { sequelize } from "@server/storage/database";
import { APIContext, AuthenticationResult } from "@server/types";
import {
getClientFromContext,
@@ -125,71 +128,8 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
passport.use(strategy);
router.get("slack", passport.authenticate(providerName));
router.get("slack.callback", passportMiddleware(providerName));
router.get(
"slack.commands",
auth({
optional: true,
}),
validate(T.SlackCommandsSchema),
async (ctx: APIContext<T.SlackCommandsReq>) => {
const { code, state: teamId, error } = ctx.input.query;
const { user } = ctx.state.auth;
if (error) {
ctx.redirect(SlackUtils.errorUrl(error));
return;
}
// this code block accounts for the root domain being unable to
// access authentication for subdomains. We must forward to the appropriate
// subdomain to complete the oauth flow
if (!user) {
if (teamId) {
try {
const team = await Team.findByPk(teamId, {
rejectOnEmpty: true,
});
return redirectOnClient(
ctx,
SlackUtils.commandsUrl({
baseUrl: team.url,
params: ctx.request.querystring,
})
);
} catch (err) {
return ctx.redirect(SlackUtils.errorUrl("unauthenticated"));
}
} else {
return ctx.redirect(SlackUtils.errorUrl("unauthenticated"));
}
}
// validation middleware ensures that code is non-null at this point
const data = await Slack.oauthAccess(code!, SlackUtils.commandsUrl());
const authentication = await IntegrationAuthentication.create({
service: IntegrationService.Slack,
userId: user.id,
teamId: user.teamId,
token: data.access_token,
scopes: data.scope.split(","),
});
await Integration.create({
service: IntegrationService.Slack,
type: IntegrationType.Command,
userId: user.id,
teamId: user.teamId,
authenticationId: authentication.id,
settings: {
serviceTeamId: data.team_id,
},
});
ctx.redirect(SlackUtils.url);
}
);
router.get(
"slack.post",
auth({
@@ -197,7 +137,7 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
}),
validate(T.SlackPostSchema),
async (ctx: APIContext<T.SlackPostReq>) => {
const { code, error, state: collectionId } = ctx.input.query;
const { code, error, state } = ctx.input.query;
const { user } = ctx.state.auth;
if (error) {
@@ -205,21 +145,28 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
return;
}
// this code block accounts for the root domain being unable to
// access authentication for subdomains. We must forward to the
// appropriate subdomain to complete the oauth flow
let parsedState;
try {
parsedState = SlackUtils.parseState<{
collectionId: string;
}>(state);
} catch (err) {
throw ValidationError("Invalid state");
}
const { teamId, collectionId, type } = parsedState;
// This code block accounts for the root domain being unable to access authentication for
// subdomains. We must forward to the appropriate subdomain to complete the OAuth flow.
if (!user) {
if (collectionId) {
if (teamId) {
try {
const collection = await Collection.findByPk(collectionId, {
rejectOnEmpty: true,
});
const team = await Team.findByPk(collection.teamId, {
const team = await Team.findByPk(teamId, {
rejectOnEmpty: true,
});
return redirectOnClient(
ctx,
SlackUtils.postUrl({
SlackUtils.connectUrl({
baseUrl: team.url,
params: ctx.request.querystring,
})
@@ -232,30 +179,105 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
}
}
// validation middleware ensures that code is non-null at this point
const data = await Slack.oauthAccess(code!, SlackUtils.postUrl());
const authentication = await IntegrationAuthentication.create({
service: IntegrationService.Slack,
userId: user.id,
teamId: user.teamId,
token: data.access_token,
scopes: data.scope.split(","),
});
switch (type) {
case IntegrationType.Post: {
const collection = await Collection.findByPk(collectionId, {
rejectOnEmpty: true,
});
authorize(user, "read", collection);
authorize(user, "update", user.team);
// validation middleware ensures that code is non-null at this point
const data = await Slack.oauthAccess(code!, SlackUtils.connectUrl());
await sequelize.transaction(async (transaction) => {
const authentication = await IntegrationAuthentication.create(
{
service: IntegrationService.Slack,
userId: user.id,
teamId: user.teamId,
token: data.access_token,
scopes: data.scope.split(","),
},
{ transaction }
);
await Integration.create(
{
service: IntegrationService.Slack,
type: IntegrationType.Post,
userId: user.id,
teamId: user.teamId,
authenticationId: authentication.id,
collectionId,
events: ["documents.update", "documents.publish"],
settings: {
url: data.incoming_webhook.url,
channel: data.incoming_webhook.channel,
channelId: data.incoming_webhook.channel_id,
},
},
{ transaction }
);
});
break;
}
case IntegrationType.Command: {
authorize(user, "update", user.team);
// validation middleware ensures that code is non-null at this point
const data = await Slack.oauthAccess(code!, SlackUtils.connectUrl());
await sequelize.transaction(async (transaction) => {
const authentication = await IntegrationAuthentication.create(
{
service: IntegrationService.Slack,
userId: user.id,
teamId: user.teamId,
token: data.access_token,
scopes: data.scope.split(","),
},
{ transaction }
);
await Integration.create(
{
service: IntegrationService.Slack,
type: IntegrationType.Command,
userId: user.id,
teamId: user.teamId,
authenticationId: authentication.id,
settings: {
serviceTeamId: data.team_id,
},
},
{ transaction }
);
});
break;
}
case IntegrationType.LinkedAccount: {
// validation middleware ensures that code is non-null at this point
const data = await Slack.oauthAccess(code!, SlackUtils.connectUrl());
await Integration.create({
service: IntegrationService.Slack,
type: IntegrationType.LinkedAccount,
userId: user.id,
teamId: user.teamId,
settings: {
slack: {
serviceUserId: data.user_id,
serviceTeamId: data.team_id,
},
},
});
break;
}
default:
throw ValidationError("Invalid integration type");
}
await Integration.create({
service: IntegrationService.Slack,
type: IntegrationType.Post,
userId: user.id,
teamId: user.teamId,
authenticationId: authentication.id,
collectionId,
events: ["documents.update", "documents.publish"],
settings: {
url: data.incoming_webhook.url,
channel: data.incoming_webhook.channel,
channelId: data.incoming_webhook.channel_id,
},
});
ctx.redirect(SlackUtils.url);
}
);