Files
outline/plugins/slack/server/auth/slack.ts
Tom Moor 60e52d0423 Separate environment configs (#6597)
* Separate environment configs

* wip

* wip

* test

* plugins

* test

* test

* .sequelizerc, unfortunately can't go through /utils/environment due to not supporting TS

* docker-compose -> docker compose

* fix: .local wipes .development

* Add custom validation message for invalid SECRET_KEY (often confused)
2024-02-27 09:24:23 -08:00

269 lines
7.7 KiB
TypeScript

import passport from "@outlinewiki/koa-passport";
import type { Context } from "koa";
import Router from "koa-router";
import { Profile } from "passport";
import { Strategy as SlackStrategy } from "passport-slack-oauth2";
import { IntegrationService, IntegrationType } from "@shared/types";
import { integrationSettingsPath } from "@shared/utils/routeHelpers";
import accountProvisioner from "@server/commands/accountProvisioner";
import auth from "@server/middlewares/authentication";
import passportMiddleware from "@server/middlewares/passport";
import validate from "@server/middlewares/validate";
import {
IntegrationAuthentication,
Collection,
Integration,
Team,
User,
} from "@server/models";
import { APIContext, AuthenticationResult } from "@server/types";
import {
getClientFromContext,
getTeamFromContext,
StateStore,
} from "@server/utils/passport";
import env from "../env";
import * as Slack from "../slack";
import * as T from "./schema";
type SlackProfile = Profile & {
team: {
id: string;
name: string;
domain: string;
image_192: string;
image_230: string;
};
user: {
id: string;
name: string;
email: string;
image_192: string;
image_230: string;
};
};
const router = new Router();
const providerName = "slack";
const scopes = [
"identity.email",
"identity.basic",
"identity.avatar",
"identity.team",
];
function redirectOnClient(ctx: Context, url: string) {
ctx.type = "text/html";
ctx.body = `
<html>
<head>
<meta http-equiv="refresh" content="0;URL='${url}'"/>
</head>`;
}
if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
const strategy = new SlackStrategy(
{
clientID: env.SLACK_CLIENT_ID,
clientSecret: env.SLACK_CLIENT_SECRET,
callbackURL: `${env.URL}/auth/slack.callback`,
passReqToCallback: true,
// @ts-expect-error StateStore
store: new StateStore(),
scope: scopes,
},
async function (
ctx: Context,
accessToken: string,
refreshToken: string,
params: { expires_in: number },
profile: SlackProfile,
done: (
err: Error | null,
user: User | null,
result?: AuthenticationResult
) => void
) {
try {
const team = await getTeamFromContext(ctx);
const client = getClientFromContext(ctx);
const result = await accountProvisioner({
ip: ctx.ip,
team: {
teamId: team?.id,
name: profile.team.name,
subdomain: profile.team.domain,
avatarUrl: profile.team.image_230,
},
user: {
name: profile.user.name,
email: profile.user.email,
avatarUrl: profile.user.image_192,
},
authenticationProvider: {
name: providerName,
providerId: profile.team.id,
},
authentication: {
providerId: profile.user.id,
accessToken,
refreshToken,
expiresIn: params.expires_in,
scopes,
},
});
return done(null, result.user, { ...result, client });
} catch (err) {
return done(err, null);
}
}
);
// For some reason the author made the strategy name capatilised, I don't know
// why but we need everything lowercase so we just monkey-patch it here.
strategy.name = providerName;
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(integrationSettingsPath(`slack?error=${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,
`${team.url}/auth/slack.commands?${ctx.request.querystring}`
);
} catch (err) {
return ctx.redirect(
integrationSettingsPath(`slack?error=unauthenticated`)
);
}
} else {
return ctx.redirect(
integrationSettingsPath(`slack?error=unauthenticated`)
);
}
}
const endpoint = `${env.URL}/auth/slack.commands`;
// validation middleware ensures that code is non-null at this point
const data = await Slack.oauthAccess(code!, endpoint);
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(integrationSettingsPath("slack"));
}
);
router.get(
"slack.post",
auth({
optional: true,
}),
validate(T.SlackPostSchema),
async (ctx: APIContext<T.SlackPostReq>) => {
const { code, error, state: collectionId } = ctx.input.query;
const { user } = ctx.state.auth;
if (error) {
ctx.redirect(integrationSettingsPath(`slack?error=${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 (collectionId) {
try {
const collection = await Collection.findByPk(collectionId, {
rejectOnEmpty: true,
});
const team = await Team.findByPk(collection.teamId, {
rejectOnEmpty: true,
});
return redirectOnClient(
ctx,
`${team.url}/auth/slack.post?${ctx.request.querystring}`
);
} catch (err) {
return ctx.redirect(
integrationSettingsPath(`slack?error=unauthenticated`)
);
}
} else {
return ctx.redirect(
integrationSettingsPath(`slack?error=unauthenticated`)
);
}
}
const endpoint = `${env.URL}/auth/slack.post`;
// validation middleware ensures that code is non-null at this point
const data = await Slack.oauthAccess(code!, endpoint);
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.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(integrationSettingsPath("slack"));
}
);
}
export default router;