chore: Move to Typescript (#2783)

This PR moves the entire project to Typescript. Due to the ~1000 ignores this will lead to a messy codebase for a while, but the churn is worth it – all of those ignore comments are places that were never type-safe previously.

closes #1282
This commit is contained in:
Tom Moor
2021-11-29 06:40:55 -08:00
committed by GitHub
parent 25ccfb5d04
commit 15b1069bcc
1017 changed files with 17410 additions and 54942 deletions

View File

@@ -1,20 +1,21 @@
// @flow
// @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module '@out... Remove this comment to see the full error message
import passport from "@outlinewiki/koa-passport";
// @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module '@out... Remove this comment to see the full error message
import { Strategy as AzureStrategy } from "@outlinewiki/passport-azure-ad-oauth2";
import jwt from "jsonwebtoken";
import Router from "koa-router";
import accountProvisioner from "../../../commands/accountProvisioner";
import env from "../../../env";
import { MicrosoftGraphError } from "../../../errors";
import passportMiddleware from "../../../middlewares/passport";
import { StateStore, request } from "../../../utils/passport";
import accountProvisioner from "@server/commands/accountProvisioner";
import env from "@server/env";
import { MicrosoftGraphError } from "@server/errors";
import passportMiddleware from "@server/middlewares/passport";
import { StateStore, request } from "@server/utils/passport";
const router = new Router();
const providerName = "azure";
const AZURE_CLIENT_ID = process.env.AZURE_CLIENT_ID;
const AZURE_CLIENT_SECRET = process.env.AZURE_CLIENT_SECRET;
const AZURE_RESOURCE_APP_ID = process.env.AZURE_RESOURCE_APP_ID;
// @ts-expect-error ts-migrate(7034) FIXME: Variable 'scopes' implicitly has type 'any[]' in s... Remove this comment to see the full error message
const scopes = [];
export const config = {
@@ -32,8 +33,10 @@ if (AZURE_CLIENT_ID) {
passReqToCallback: true,
resource: AZURE_RESOURCE_APP_ID,
store: new StateStore(),
// @ts-expect-error ts-migrate(7005) FIXME: Variable 'scopes' implicitly has an 'any[]' type.
scope: scopes,
},
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'req' implicitly has an 'any' type.
async function (req, accessToken, refreshToken, params, _, done) {
try {
// see docs for what the fields in profile represent here:
@@ -46,8 +49,9 @@ if (AZURE_CLIENT_ID) {
`https://graph.microsoft.com/v1.0/me`,
accessToken
);
if (!profileResponse) {
throw new MicrosoftGraphError(
throw MicrosoftGraphError(
"Unable to load user profile from Microsoft Graph API"
);
}
@@ -58,16 +62,19 @@ if (AZURE_CLIENT_ID) {
`https://graph.microsoft.com/v1.0/organization`,
accessToken
);
if (!organizationResponse) {
throw new MicrosoftGraphError(
throw MicrosoftGraphError(
"Unable to load organization info from Microsoft Graph API"
);
}
const organization = organizationResponse.value[0];
// @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'.
const email = profile.email || profileResponse.mail;
if (!email) {
throw new MicrosoftGraphError(
throw MicrosoftGraphError(
"'email' property is required but could not be found in user profile."
);
}
@@ -75,7 +82,6 @@ if (AZURE_CLIENT_ID) {
const domain = email.split("@")[1];
const subdomain = domain.split(".")[0];
const teamName = organization.displayName;
const result = await accountProvisioner({
ip: req.ip,
team: {
@@ -84,18 +90,23 @@ if (AZURE_CLIENT_ID) {
subdomain,
},
user: {
// @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'.
name: profile.name,
email,
// @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'.
avatarUrl: profile.picture,
},
authenticationProvider: {
name: providerName,
// @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'.
providerId: profile.tid,
},
authentication: {
// @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'.
providerId: profile.oid,
accessToken,
refreshToken,
// @ts-expect-error ts-migrate(7005) FIXME: Variable 'scopes' implicitly has an 'any[]' type.
scopes,
},
});
@@ -105,7 +116,6 @@ if (AZURE_CLIENT_ID) {
}
}
);
passport.use(strategy);
router.get("azure", passport.authenticate(providerName));

View File

@@ -1,30 +1,25 @@
// @flow
// @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module 'fetc... Remove this comment to see the full error message
import TestServer from "fetch-test-server";
import mailer from "../../../mailer";
import webService from "../../../services/web";
import { buildUser, buildGuestUser, buildTeam } from "../../../test/factories";
import { flushdb } from "../../../test/support";
import mailer from "@server/mailer";
import webService from "@server/services/web";
import { buildUser, buildGuestUser, buildTeam } from "@server/test/factories";
import { flushdb } from "@server/test/support";
const app = webService();
const server = new TestServer(app.callback());
jest.mock("../../../mailer");
beforeEach(async () => {
await flushdb();
// $FlowFixMe does not understand Jest mocks
// @ts-expect-error ts-migrate(2339) FIXME: Property 'mockReset' does not exist on type '(type... Remove this comment to see the full error message
mailer.sendTemplate.mockReset();
});
afterAll(() => server.close());
describe("email", () => {
it("should require email param", async () => {
const res = await server.post("/auth/email", {
body: {},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.error).toEqual("validation_error");
expect(body.ok).toEqual(false);
@@ -32,12 +27,12 @@ describe("email", () => {
it("should respond with redirect location when user is SSO enabled", async () => {
const user = await buildUser();
const res = await server.post("/auth/email", {
body: { email: user.email },
body: {
email: user.email,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.redirect).toMatch("slack");
expect(mailer.sendTemplate).not.toHaveBeenCalled();
@@ -46,19 +41,19 @@ describe("email", () => {
it("should respond with redirect location when user is SSO enabled on another subdomain", async () => {
process.env.URL = "http://localoutline.com";
process.env.SUBDOMAINS_ENABLED = "true";
const user = await buildUser();
await buildTeam({
subdomain: "example",
});
const res = await server.post("/auth/email", {
body: { email: user.email },
headers: { host: "example.localoutline.com" },
body: {
email: user.email,
},
headers: {
host: "example.localoutline.com",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.redirect).toMatch("slack");
expect(mailer.sendTemplate).not.toHaveBeenCalled();
@@ -66,12 +61,12 @@ describe("email", () => {
it("should respond with success when user is not SSO enabled", async () => {
const user = await buildGuestUser();
const res = await server.post("/auth/email", {
body: { email: user.email },
body: {
email: user.email,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.success).toEqual(true);
expect(mailer.sendTemplate).toHaveBeenCalled();
@@ -79,97 +74,116 @@ describe("email", () => {
it("should respond with success regardless of whether successful to prevent crawling email logins", async () => {
const res = await server.post("/auth/email", {
body: { email: "user@example.com" },
body: {
email: "user@example.com",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.success).toEqual(true);
expect(mailer.sendTemplate).not.toHaveBeenCalled();
});
describe("with multiple users matching email", () => {
it("should default to current subdomain with SSO", async () => {
process.env.URL = "http://localoutline.com";
process.env.SUBDOMAINS_ENABLED = "true";
const email = "sso-user@example.org";
const team = await buildTeam({
subdomain: "example",
});
await buildGuestUser({ email });
await buildUser({ email, teamId: team.id });
await buildGuestUser({
email,
});
await buildUser({
email,
teamId: team.id,
});
const res = await server.post("/auth/email", {
body: { email },
headers: { host: "example.localoutline.com" },
body: {
email,
},
headers: {
host: "example.localoutline.com",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.redirect).toMatch("slack");
expect(mailer.sendTemplate).not.toHaveBeenCalled();
});
it("should default to current subdomain with guest email", async () => {
process.env.URL = "http://localoutline.com";
process.env.SUBDOMAINS_ENABLED = "true";
const email = "guest-user@example.org";
const team = await buildTeam({
subdomain: "example",
});
await buildUser({ email });
await buildGuestUser({ email, teamId: team.id });
await buildUser({
email,
});
await buildGuestUser({
email,
teamId: team.id,
});
const res = await server.post("/auth/email", {
body: { email },
headers: { host: "example.localoutline.com" },
body: {
email,
},
headers: {
host: "example.localoutline.com",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.success).toEqual(true);
expect(mailer.sendTemplate).toHaveBeenCalled();
});
it("should default to custom domain with SSO", async () => {
const email = "sso-user-2@example.org";
const team = await buildTeam({
domain: "docs.mycompany.com",
});
await buildGuestUser({ email });
await buildUser({ email, teamId: team.id });
await buildGuestUser({
email,
});
await buildUser({
email,
teamId: team.id,
});
const res = await server.post("/auth/email", {
body: { email },
headers: { host: "docs.mycompany.com" },
body: {
email,
},
headers: {
host: "docs.mycompany.com",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.redirect).toMatch("slack");
expect(mailer.sendTemplate).not.toHaveBeenCalled();
});
it("should default to custom domain with guest email", async () => {
const email = "guest-user-2@example.org";
const team = await buildTeam({
domain: "docs.mycompany.com",
});
await buildUser({ email });
await buildGuestUser({ email, teamId: team.id });
await buildUser({
email,
});
await buildGuestUser({
email,
teamId: team.id,
});
const res = await server.post("/auth/email", {
body: { email },
headers: { host: "docs.mycompany.com" },
body: {
email,
},
headers: {
host: "docs.mycompany.com",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.success).toEqual(true);
expect(mailer.sendTemplate).toHaveBeenCalled();

View File

@@ -1,20 +1,16 @@
// @flow
import { subMinutes } from "date-fns";
import Router from "koa-router";
import { find } from "lodash";
import {
parseDomain,
isCustomSubdomain,
} from "../../../../shared/utils/domains";
import { AuthorizationError } from "../../../errors";
import mailer from "../../../mailer";
import errorHandling from "../../../middlewares/errorHandling";
import methodOverride from "../../../middlewares/methodOverride";
import validation from "../../../middlewares/validation";
import { User, Team } from "../../../models";
import { signIn } from "../../../utils/authentication";
import { isCustomDomain } from "../../../utils/domains";
import { getUserForEmailSigninToken } from "../../../utils/jwt";
import { parseDomain, isCustomSubdomain } from "@shared/utils/domains";
import { AuthorizationError } from "@server/errors";
import mailer from "@server/mailer";
import errorHandling from "@server/middlewares/errorHandling";
import methodOverride from "@server/middlewares/methodOverride";
import { User, Team } from "@server/models";
import { signIn } from "@server/utils/authentication";
import { isCustomDomain } from "@server/utils/domains";
import { getUserForEmailSigninToken } from "@server/utils/jwt";
import { assertEmail, assertPresent } from "@server/validation";
const router = new Router();
@@ -24,23 +20,25 @@ export const config = {
};
router.use(methodOverride());
router.use(validation());
router.post("email", errorHandling(), async (ctx) => {
const { email } = ctx.body;
ctx.assertEmail(email, "email is required");
assertEmail(email, "email is required");
const users = await User.scope("withAuthentications").findAll({
where: { email: email.toLowerCase() },
where: {
email: email.toLowerCase(),
},
});
if (users.length) {
// @ts-expect-error ts-migrate(7034) FIXME: Variable 'team' implicitly has type 'any' in some ... Remove this comment to see the full error message
let team;
if (isCustomDomain(ctx.request.hostname)) {
team = await Team.scope("withAuthenticationProviders").findOne({
where: { domain: ctx.request.hostname },
where: {
domain: ctx.request.hostname,
},
});
}
@@ -52,12 +50,15 @@ router.post("email", errorHandling(), async (ctx) => {
const domain = parseDomain(ctx.request.hostname);
const subdomain = domain ? domain.subdomain : undefined;
team = await Team.scope("withAuthenticationProviders").findOne({
where: { subdomain },
where: {
subdomain,
},
});
}
// If there are multiple users with this email address then give precedence
// to the one that is active on this subdomain/domain (if any)
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'user' implicitly has an 'any' type.
let user = users.find((user) => team && user.teamId === team.id);
// A user was found for the email address, but they don't belong to the team
@@ -94,7 +95,7 @@ router.post("email", errorHandling(), async (ctx) => {
}
if (!team.guestSignin) {
throw new AuthorizationError();
throw AuthorizationError();
}
// basic rate limit of endpoint to prevent send email abuse
@@ -116,7 +117,6 @@ router.post("email", errorHandling(), async (ctx) => {
token: user.getEmailSigninToken(),
teamUrl: team.url,
});
user.lastSigninEmailSentAt = new Date();
await user.save();
}
@@ -129,17 +129,20 @@ router.post("email", errorHandling(), async (ctx) => {
router.get("email.callback", async (ctx) => {
const { token } = ctx.request.query;
ctx.assertPresent(token, "token is required");
assertPresent(token, "token is required");
try {
// @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'string | string[] | undefined' i... Remove this comment to see the full error message
const user = await getUserForEmailSigninToken(token);
if (!user.team.guestSignin) {
return ctx.redirect("/?notice=auth-error");
}
if (user.isSuspended) {
return ctx.redirect("/?notice=suspended");
}
if (user.isInvited) {
await mailer.sendTemplate("welcome", {
to: user.email,
@@ -147,8 +150,9 @@ router.get("email.callback", async (ctx) => {
});
}
await user.update({ lastActiveAt: new Date() });
await user.update({
lastActiveAt: new Date(),
});
// set cookies on response and redirect to team subdomain
await signIn(ctx, user, user.team, "email", false, false);
} catch (err) {

View File

@@ -1,24 +1,24 @@
// @flow
// @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module '@out... Remove this comment to see the full error message
import passport from "@outlinewiki/koa-passport";
import Router from "koa-router";
import { capitalize } from "lodash";
// @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module 'pass... Remove this comment to see the full error message
import { Strategy as GoogleStrategy } from "passport-google-oauth2";
import accountProvisioner from "../../../commands/accountProvisioner";
import env from "../../../env";
import accountProvisioner from "@server/commands/accountProvisioner";
import env from "@server/env";
import {
GoogleWorkspaceRequiredError,
GoogleWorkspaceInvalidError,
} from "../../../errors";
import passportMiddleware from "../../../middlewares/passport";
import { getAllowedDomains } from "../../../utils/authentication";
import { StateStore } from "../../../utils/passport";
} from "@server/errors";
import passportMiddleware from "@server/middlewares/passport";
import { getAllowedDomains } from "@server/utils/authentication";
import { StateStore } from "@server/utils/passport";
const router = new Router();
const providerName = "google";
const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID;
const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET;
const allowedDomains = getAllowedDomains();
const scopes = [
"https://www.googleapis.com/auth/userinfo.profile",
"https://www.googleapis.com/auth/userinfo.email",
@@ -40,21 +40,21 @@ if (GOOGLE_CLIENT_ID) {
store: new StateStore(),
scope: scopes,
},
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'req' implicitly has an 'any' type.
async function (req, accessToken, refreshToken, profile, done) {
try {
const domain = profile._json.hd;
if (!domain) {
throw new GoogleWorkspaceRequiredError();
throw GoogleWorkspaceRequiredError();
}
if (allowedDomains.length && !allowedDomains.includes(domain)) {
throw new GoogleWorkspaceInvalidError();
throw GoogleWorkspaceInvalidError();
}
const subdomain = domain.split(".")[0];
const teamName = capitalize(subdomain);
const result = await accountProvisioner({
ip: req.ip,
team: {

View File

@@ -1,10 +1,11 @@
// @flow
import { signin } from "../../../../shared/utils/routeHelpers";
import { requireDirectory } from "../../../utils/fs";
import { signin } from "@shared/utils/routeHelpers";
import { requireDirectory } from "@server/utils/fs";
let providers = [];
// @ts-expect-error ts-migrate(7034) FIXME: Variable 'providers' implicitly has type 'any[]' i... Remove this comment to see the full error message
const providers = [];
requireDirectory(__dirname).forEach(([module, id]) => {
// @ts-expect-error ts-migrate(2339) FIXME: Property 'config' does not exist on type 'unknown'... Remove this comment to see the full error message
const { config, default: router } = module;
if (id === "index") {
@@ -34,4 +35,5 @@ requireDirectory(__dirname).forEach(([module, id]) => {
}
});
// @ts-expect-error ts-migrate(7005) FIXME: Variable 'providers' implicitly has an 'any[]' typ... Remove this comment to see the full error message
export default providers;

View File

@@ -1,26 +1,26 @@
// @flow
// @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module '@out... Remove this comment to see the full error message
import passport from "@outlinewiki/koa-passport";
import Router from "koa-router";
import get from "lodash/get";
import { get } from "lodash";
import { Strategy } from "passport-oauth2";
import accountProvisioner from "../../../commands/accountProvisioner";
import env from "../../../env";
import accountProvisioner from "@server/commands/accountProvisioner";
import env from "@server/env";
import {
OIDCMalformedUserInfoError,
AuthenticationError,
} from "../../../errors";
import passportMiddleware from "../../../middlewares/passport";
import { getAllowedDomains } from "../../../utils/authentication";
import { StateStore, request } from "../../../utils/passport";
} from "@server/errors";
import passportMiddleware from "@server/middlewares/passport";
import { getAllowedDomains } from "@server/utils/authentication";
import { StateStore, request } from "@server/utils/passport";
const router = new Router();
const providerName = "oidc";
const OIDC_DISPLAY_NAME = process.env.OIDC_DISPLAY_NAME || "OpenID Connect";
const OIDC_CLIENT_ID = process.env.OIDC_CLIENT_ID;
const OIDC_CLIENT_SECRET = process.env.OIDC_CLIENT_SECRET;
const OIDC_AUTH_URI = process.env.OIDC_AUTH_URI;
const OIDC_TOKEN_URI = process.env.OIDC_TOKEN_URI;
const OIDC_USERINFO_URI = process.env.OIDC_USERINFO_URI;
const OIDC_CLIENT_ID = process.env.OIDC_CLIENT_ID || "";
const OIDC_CLIENT_SECRET = process.env.OIDC_CLIENT_SECRET || "";
const OIDC_AUTH_URI = process.env.OIDC_AUTH_URI || "";
const OIDC_TOKEN_URI = process.env.OIDC_TOKEN_URI || "";
const OIDC_USERINFO_URI = process.env.OIDC_USERINFO_URI || "";
const OIDC_SCOPES = process.env.OIDC_SCOPES || "";
const OIDC_USERNAME_CLAIM =
process.env.OIDC_USERNAME_CLAIM || "preferred_username";
@@ -30,7 +30,6 @@ export const config = {
name: OIDC_DISPLAY_NAME,
enabled: !!OIDC_CLIENT_ID,
};
const scopes = OIDC_SCOPES.split(" ");
Strategy.prototype.userProfile = async function (accessToken, done) {
@@ -54,40 +53,45 @@ if (OIDC_CLIENT_ID) {
callbackURL: `${env.URL}/auth/${providerName}.callback`,
passReqToCallback: true,
scope: OIDC_SCOPES,
// @ts-expect-error custom state store
store: new StateStore(),
state: true,
pkce: false,
},
// OpenID Connect standard profile claims can be found in the official
// specification.
// https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
// Non-standard claims may be configured by individual identity providers.
// Any claim supplied in response to the userinfo request will be
// available on the `profile` parameter
async function (req, accessToken, refreshToken, profile, done) {
async function (
req: any,
accessToken: string,
refreshToken: string,
profile: Record<string, string>,
done: any
) {
try {
if (!profile.email) {
throw new AuthenticationError(
throw AuthenticationError(
`An email field was not returned in the profile parameter, but is required.`
);
}
const parts = profile.email.split("@");
const domain = parts.length && parts[1];
if (!domain) {
throw new OIDCMalformedUserInfoError();
throw OIDCMalformedUserInfoError();
}
if (allowedDomains.length && !allowedDomains.includes(domain)) {
throw new AuthenticationError(
throw AuthenticationError(
`Domain ${domain} is not on the whitelist`
);
}
const subdomain = domain.split(".")[0];
const result = await accountProvisioner({
ip: req.ip,
team: {
@@ -115,7 +119,6 @@ if (OIDC_CLIENT_ID) {
scopes,
},
});
return done(null, result.user, result);
} catch (err) {
return done(err, null);

View File

@@ -1,203 +0,0 @@
// @flow
import passport from "@outlinewiki/koa-passport";
import Router from "koa-router";
import { Strategy as SlackStrategy } from "passport-slack-oauth2";
import accountProvisioner from "../../../commands/accountProvisioner";
import env from "../../../env";
import auth from "../../../middlewares/authentication";
import passportMiddleware from "../../../middlewares/passport";
import {
IntegrationAuthentication,
Collection,
Integration,
Team,
} from "../../../models";
import { StateStore } from "../../../utils/passport";
import * as Slack from "../../../utils/slack";
const router = new Router();
const providerName = "slack";
const SLACK_CLIENT_ID = process.env.SLACK_KEY;
const SLACK_CLIENT_SECRET = process.env.SLACK_SECRET;
const scopes = [
"identity.email",
"identity.basic",
"identity.avatar",
"identity.team",
];
export const config = {
name: "Slack",
enabled: !!SLACK_CLIENT_ID,
};
if (SLACK_CLIENT_ID) {
const strategy = new SlackStrategy(
{
clientID: SLACK_CLIENT_ID,
clientSecret: SLACK_CLIENT_SECRET,
callbackURL: `${env.URL}/auth/slack.callback`,
passReqToCallback: true,
store: new StateStore(),
scope: scopes,
},
async function (req, accessToken, refreshToken, profile, done) {
try {
const result = await accountProvisioner({
ip: req.ip,
team: {
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,
scopes,
},
});
return done(null, result.user, result);
} 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({ required: false }), async (ctx) => {
const { code, state, error } = ctx.request.query;
const user = ctx.state.user;
ctx.assertPresent(code || error, "code is required");
if (error) {
ctx.redirect(`/settings/integrations/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 (state) {
try {
const team = await Team.findByPk(state);
return ctx.redirect(
`${team.url}/auth${ctx.request.path}?${ctx.request.querystring}`
);
} catch (err) {
return ctx.redirect(
`/settings/integrations/slack?error=unauthenticated`
);
}
} else {
return ctx.redirect(
`/settings/integrations/slack?error=unauthenticated`
);
}
}
const endpoint = `${process.env.URL || ""}/auth/slack.commands`;
const data = await Slack.oauthAccess(code, endpoint);
const authentication = await IntegrationAuthentication.create({
service: "slack",
userId: user.id,
teamId: user.teamId,
token: data.access_token,
scopes: data.scope.split(","),
});
await Integration.create({
service: "slack",
type: "command",
userId: user.id,
teamId: user.teamId,
authenticationId: authentication.id,
settings: {
serviceTeamId: data.team_id,
},
});
ctx.redirect("/settings/integrations/slack");
});
router.get("slack.post", auth({ required: false }), async (ctx) => {
const { code, error, state } = ctx.request.query;
const user = ctx.state.user;
ctx.assertPresent(code || error, "code is required");
const collectionId = state;
ctx.assertUuid(collectionId, "collectionId must be an uuid");
if (error) {
ctx.redirect(`/settings/integrations/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) {
try {
const collection = await Collection.findByPk(state);
const team = await Team.findByPk(collection.teamId);
return ctx.redirect(
`${team.url}/auth${ctx.request.path}?${ctx.request.querystring}`
);
} catch (err) {
return ctx.redirect(
`/settings/integrations/slack?error=unauthenticated`
);
}
}
const endpoint = `${process.env.URL || ""}/auth/slack.post`;
const data = await Slack.oauthAccess(code, endpoint);
const authentication = await IntegrationAuthentication.create({
service: "slack",
userId: user.id,
teamId: user.teamId,
token: data.access_token,
scopes: data.scope.split(","),
});
await Integration.create({
service: "slack",
type: "post",
userId: user.id,
teamId: user.teamId,
authenticationId: authentication.id,
collectionId,
events: [],
settings: {
url: data.incoming_webhook.url,
channel: data.incoming_webhook.channel,
channelId: data.incoming_webhook.channel_id,
},
});
ctx.redirect("/settings/integrations/slack");
});
}
export default router;

View File

@@ -0,0 +1,211 @@
// @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module '@out... Remove this comment to see the full error message
import passport from "@outlinewiki/koa-passport";
import Router from "koa-router";
// @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module 'pass... Remove this comment to see the full error message
import { Strategy as SlackStrategy } from "passport-slack-oauth2";
import accountProvisioner from "@server/commands/accountProvisioner";
import env from "@server/env";
import auth from "@server/middlewares/authentication";
import passportMiddleware from "@server/middlewares/passport";
import {
IntegrationAuthentication,
Collection,
Integration,
Team,
} from "@server/models";
import { StateStore } from "@server/utils/passport";
import * as Slack from "@server/utils/slack";
import { assertPresent, assertUuid } from "@server/validation";
const router = new Router();
const providerName = "slack";
const SLACK_CLIENT_ID = process.env.SLACK_KEY;
const SLACK_CLIENT_SECRET = process.env.SLACK_SECRET;
const scopes = [
"identity.email",
"identity.basic",
"identity.avatar",
"identity.team",
];
export const config = {
name: "Slack",
enabled: !!SLACK_CLIENT_ID,
};
if (SLACK_CLIENT_ID) {
const strategy = new SlackStrategy(
{
clientID: SLACK_CLIENT_ID,
clientSecret: SLACK_CLIENT_SECRET,
callbackURL: `${env.URL}/auth/slack.callback`,
passReqToCallback: true,
store: new StateStore(),
scope: scopes,
},
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'req' implicitly has an 'any' type.
async function (req, accessToken, refreshToken, profile, done) {
try {
const result = await accountProvisioner({
ip: req.ip,
team: {
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,
scopes,
},
});
return done(null, result.user, result);
} 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({
required: false,
}),
async (ctx) => {
const { code, state, error } = ctx.request.query;
const user = ctx.state.user;
assertPresent(code || error, "code is required");
if (error) {
ctx.redirect(`/settings/integrations/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 (state) {
try {
const team = await Team.findByPk(state);
return ctx.redirect(
`${team.url}/auth${ctx.request.path}?${ctx.request.querystring}`
);
} catch (err) {
return ctx.redirect(
`/settings/integrations/slack?error=unauthenticated`
);
}
} else {
return ctx.redirect(
`/settings/integrations/slack?error=unauthenticated`
);
}
}
const endpoint = `${process.env.URL || ""}/auth/slack.commands`;
// @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'string | string[] | undefined' i... Remove this comment to see the full error message
const data = await Slack.oauthAccess(code, endpoint);
const authentication = await IntegrationAuthentication.create({
service: "slack",
userId: user.id,
teamId: user.teamId,
token: data.access_token,
scopes: data.scope.split(","),
});
await Integration.create({
service: "slack",
type: "command",
userId: user.id,
teamId: user.teamId,
authenticationId: authentication.id,
settings: {
serviceTeamId: data.team_id,
},
});
ctx.redirect("/settings/integrations/slack");
}
);
router.get(
"slack.post",
auth({
required: false,
}),
async (ctx) => {
const { code, error, state } = ctx.request.query;
const user = ctx.state.user;
assertPresent(code || error, "code is required");
const collectionId = state;
assertUuid(collectionId, "collectionId must be an uuid");
if (error) {
ctx.redirect(`/settings/integrations/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) {
try {
const collection = await Collection.findByPk(state);
const team = await Team.findByPk(collection.teamId);
return ctx.redirect(
`${team.url}/auth${ctx.request.path}?${ctx.request.querystring}`
);
} catch (err) {
return ctx.redirect(
`/settings/integrations/slack?error=unauthenticated`
);
}
}
const endpoint = `${process.env.URL || ""}/auth/slack.post`;
// @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'string | string[] | undefined' i... Remove this comment to see the full error message
const data = await Slack.oauthAccess(code, endpoint);
const authentication = await IntegrationAuthentication.create({
service: "slack",
userId: user.id,
teamId: user.teamId,
token: data.access_token,
scopes: data.scope.split(","),
});
await Integration.create({
service: "slack",
type: "post",
userId: user.id,
teamId: user.teamId,
authenticationId: authentication.id,
collectionId,
events: [],
settings: {
url: data.incoming_webhook.url,
channel: data.incoming_webhook.channel,
channelId: data.incoming_webhook.channel_id,
},
});
ctx.redirect("/settings/integrations/slack");
}
);
}
export default router;