mirror of
https://github.com/outline/outline.git
synced 2025-04-11 15:59:08 +00:00
feat: authenticationProviders API endpoints (#1962)
This commit is contained in:
@ -34,7 +34,8 @@ Interested in more documentation on the API routes? Check out the [API documenta
|
||||
server
|
||||
├── api - All API routes are contained within here
|
||||
│ └── middlewares - Koa middlewares specific to the API
|
||||
├── auth - Authentication providers, in the form of passport.js strategies
|
||||
├── auth - Authentication logic
|
||||
│ └── providers - Authentication providers export passport.js strategies and config
|
||||
├── commands - We are gradually moving to the command pattern for new write logic
|
||||
├── config - Database configuration
|
||||
├── emails - Transactional email templates
|
||||
|
@ -1,29 +1,14 @@
|
||||
// @flow
|
||||
import path from "path";
|
||||
import Router from "koa-router";
|
||||
import { find } from "lodash";
|
||||
import { parseDomain, isCustomSubdomain } from "../../shared/utils/domains";
|
||||
import { signin } from "../../shared/utils/routeHelpers";
|
||||
import providers from "../auth/providers";
|
||||
import auth from "../middlewares/authentication";
|
||||
import { Team } from "../models";
|
||||
import { presentUser, presentTeam, presentPolicies } from "../presenters";
|
||||
import { isCustomDomain } from "../utils/domains";
|
||||
import { requireDirectory } from "../utils/fs";
|
||||
|
||||
const router = new Router();
|
||||
let providers = [];
|
||||
|
||||
requireDirectory(path.join(__dirname, "..", "auth")).forEach(
|
||||
([{ config }, id]) => {
|
||||
if (config && config.enabled) {
|
||||
providers.push({
|
||||
id,
|
||||
name: config.name,
|
||||
authUrl: signin(id),
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
function filterProviders(team) {
|
||||
return providers
|
||||
|
85
server/api/authenticationProviders.js
Normal file
85
server/api/authenticationProviders.js
Normal file
@ -0,0 +1,85 @@
|
||||
// @flow
|
||||
import Router from "koa-router";
|
||||
import allAuthenticationProviders from "../auth/providers";
|
||||
import auth from "../middlewares/authentication";
|
||||
import { AuthenticationProvider, Event } from "../models";
|
||||
import policy from "../policies";
|
||||
import { presentAuthenticationProvider, presentPolicies } from "../presenters";
|
||||
|
||||
const router = new Router();
|
||||
const { authorize } = policy;
|
||||
|
||||
router.post("authenticationProviders.info", auth(), async (ctx) => {
|
||||
const { id } = ctx.body;
|
||||
ctx.assertUuid(id, "id is required");
|
||||
|
||||
const user = ctx.state.user;
|
||||
const authenticationProvider = await AuthenticationProvider.findByPk(id);
|
||||
authorize(user, "read", authenticationProvider);
|
||||
|
||||
ctx.body = {
|
||||
data: presentAuthenticationProvider(authenticationProvider),
|
||||
policies: presentPolicies(user, [authenticationProvider]),
|
||||
};
|
||||
});
|
||||
|
||||
router.post("authenticationProviders.update", auth(), async (ctx) => {
|
||||
const { id, isEnabled } = ctx.body;
|
||||
ctx.assertUuid(id, "id is required");
|
||||
ctx.assertPresent(isEnabled, "isEnabled is required");
|
||||
|
||||
const user = ctx.state.user;
|
||||
const authenticationProvider = await AuthenticationProvider.findByPk(id);
|
||||
authorize(user, "update", authenticationProvider);
|
||||
|
||||
const enabled = !!isEnabled;
|
||||
if (enabled) {
|
||||
await authenticationProvider.enable();
|
||||
} else {
|
||||
await authenticationProvider.disable();
|
||||
}
|
||||
|
||||
await Event.create({
|
||||
name: "authenticationProviders.update",
|
||||
data: { enabled },
|
||||
modelId: id,
|
||||
teamId: user.teamId,
|
||||
actorId: user.id,
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
data: presentAuthenticationProvider(authenticationProvider),
|
||||
policies: presentPolicies(user, [authenticationProvider]),
|
||||
};
|
||||
});
|
||||
|
||||
router.post("authenticationProviders.list", auth(), async (ctx) => {
|
||||
const user = ctx.state.user;
|
||||
authorize(user, "read", user.team);
|
||||
|
||||
const teamAuthenticationProviders = await user.team.getAuthenticationProviders();
|
||||
const otherAuthenticationProviders = allAuthenticationProviders.filter(
|
||||
(p) =>
|
||||
!teamAuthenticationProviders.find((t) => t.name === p.id) &&
|
||||
p.enabled &&
|
||||
// email auth is dealt with separetly right now, although it definitely
|
||||
// wants to be here in the future – we'll need to migrate more data though
|
||||
p.id !== "email"
|
||||
);
|
||||
|
||||
ctx.body = {
|
||||
data: {
|
||||
authenticationProviders: [
|
||||
...teamAuthenticationProviders.map(presentAuthenticationProvider),
|
||||
...otherAuthenticationProviders.map((p) => ({
|
||||
name: p.id,
|
||||
isEnabled: false,
|
||||
isConnected: false,
|
||||
})),
|
||||
],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export default router;
|
156
server/api/authenticationProviders.test.js
Normal file
156
server/api/authenticationProviders.test.js
Normal file
@ -0,0 +1,156 @@
|
||||
// @flow
|
||||
import TestServer from "fetch-test-server";
|
||||
import uuid from "uuid";
|
||||
import app from "../app";
|
||||
import { buildUser, buildAdmin, buildTeam } from "../test/factories";
|
||||
import { flushdb } from "../test/support";
|
||||
|
||||
const server = new TestServer(app.callback());
|
||||
|
||||
beforeEach(() => flushdb());
|
||||
afterAll(() => server.close());
|
||||
|
||||
describe("#authenticationProviders.info", () => {
|
||||
it("should return auth provider", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const authenticationProviders = await team.getAuthenticationProviders();
|
||||
|
||||
const res = await server.post("/api/authenticationProviders.info", {
|
||||
body: {
|
||||
id: authenticationProviders[0].id,
|
||||
token: user.getJwtToken(),
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.name).toBe("slack");
|
||||
expect(body.data.isEnabled).toBe(true);
|
||||
expect(body.data.isConnected).toBe(true);
|
||||
expect(body.policies[0].abilities.read).toBe(true);
|
||||
expect(body.policies[0].abilities.update).toBe(false);
|
||||
});
|
||||
|
||||
it("should require authorization", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser();
|
||||
const authenticationProviders = await team.getAuthenticationProviders();
|
||||
|
||||
const res = await server.post("/api/authenticationProviders.info", {
|
||||
body: {
|
||||
id: authenticationProviders[0].id,
|
||||
token: user.getJwtToken(),
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const team = await buildTeam();
|
||||
const authenticationProviders = await team.getAuthenticationProviders();
|
||||
|
||||
const res = await server.post("/api/authenticationProviders.info", {
|
||||
body: {
|
||||
id: authenticationProviders[0].id,
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#authenticationProviders.update", () => {
|
||||
it("should not allow admins to disable when last authentication provider", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildAdmin({ teamId: team.id });
|
||||
const authenticationProviders = await team.getAuthenticationProviders();
|
||||
|
||||
const res = await server.post("/api/authenticationProviders.update", {
|
||||
body: {
|
||||
id: authenticationProviders[0].id,
|
||||
isEnabled: false,
|
||||
token: user.getJwtToken(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toEqual(400);
|
||||
});
|
||||
|
||||
it("should allow admins to disable", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildAdmin({ teamId: team.id });
|
||||
await team.createAuthenticationProvider({
|
||||
name: "google",
|
||||
providerId: uuid.v4(),
|
||||
});
|
||||
const authenticationProviders = await team.getAuthenticationProviders();
|
||||
|
||||
const res = await server.post("/api/authenticationProviders.update", {
|
||||
body: {
|
||||
id: authenticationProviders[0].id,
|
||||
isEnabled: false,
|
||||
token: user.getJwtToken(),
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.name).toBe("slack");
|
||||
expect(body.data.isEnabled).toBe(false);
|
||||
expect(body.data.isConnected).toBe(true);
|
||||
});
|
||||
|
||||
it("should require authorization", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const authenticationProviders = await team.getAuthenticationProviders();
|
||||
|
||||
const res = await server.post("/api/authenticationProviders.update", {
|
||||
body: {
|
||||
id: authenticationProviders[0].id,
|
||||
isEnabled: false,
|
||||
token: user.getJwtToken(),
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const team = await buildTeam();
|
||||
const authenticationProviders = await team.getAuthenticationProviders();
|
||||
|
||||
const res = await server.post("/api/authenticationProviders.update", {
|
||||
body: {
|
||||
id: authenticationProviders[0].id,
|
||||
isEnabled: false,
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#authenticationProviders.list", () => {
|
||||
it("should return enabled and available auth providers", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
|
||||
const res = await server.post("/api/authenticationProviders.list", {
|
||||
body: { token: user.getJwtToken() },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.authenticationProviders.length).toBe(2);
|
||||
expect(body.data.authenticationProviders[0].name).toBe("slack");
|
||||
expect(body.data.authenticationProviders[0].isEnabled).toBe(true);
|
||||
expect(body.data.authenticationProviders[0].isConnected).toBe(true);
|
||||
expect(body.data.authenticationProviders[1].name).toBe("google");
|
||||
expect(body.data.authenticationProviders[1].isEnabled).toBe(false);
|
||||
expect(body.data.authenticationProviders[1].isConnected).toBe(false);
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const res = await server.post("/api/authenticationProviders.list");
|
||||
expect(res.status).toEqual(401);
|
||||
});
|
||||
});
|
@ -10,6 +10,7 @@ import validation from "../middlewares/validation";
|
||||
import apiKeys from "./apiKeys";
|
||||
import attachments from "./attachments";
|
||||
import auth from "./auth";
|
||||
import authenticationProviders from "./authenticationProviders";
|
||||
import collections from "./collections";
|
||||
import documents from "./documents";
|
||||
import events from "./events";
|
||||
@ -45,6 +46,7 @@ api.use(editor());
|
||||
|
||||
// routes
|
||||
router.use("/", auth.routes());
|
||||
router.use("/", authenticationProviders.routes());
|
||||
router.use("/", events.routes());
|
||||
router.use("/", users.routes());
|
||||
router.use("/", collections.routes());
|
||||
|
@ -1,100 +0,0 @@
|
||||
// @flow
|
||||
import passport from "@outlinewiki/koa-passport";
|
||||
import Router from "koa-router";
|
||||
import { capitalize } from "lodash";
|
||||
import { Strategy as GoogleStrategy } from "passport-google-oauth2";
|
||||
import accountProvisioner from "../commands/accountProvisioner";
|
||||
import env from "../env";
|
||||
import {
|
||||
GoogleWorkspaceRequiredError,
|
||||
GoogleWorkspaceInvalidError,
|
||||
} from "../errors";
|
||||
import auth from "../middlewares/authentication";
|
||||
import passportMiddleware from "../middlewares/passport";
|
||||
import { getAllowedDomains } from "../utils/authentication";
|
||||
import { StateStore } from "../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",
|
||||
];
|
||||
|
||||
export const config = {
|
||||
name: "Google",
|
||||
enabled: !!GOOGLE_CLIENT_ID,
|
||||
};
|
||||
|
||||
if (GOOGLE_CLIENT_ID) {
|
||||
passport.use(
|
||||
new GoogleStrategy(
|
||||
{
|
||||
clientID: GOOGLE_CLIENT_ID,
|
||||
clientSecret: GOOGLE_CLIENT_SECRET,
|
||||
callbackURL: `${env.URL}/auth/google.callback`,
|
||||
prompt: "select_account consent",
|
||||
passReqToCallback: true,
|
||||
store: new StateStore(),
|
||||
scope: scopes,
|
||||
},
|
||||
async function (req, accessToken, refreshToken, profile, done) {
|
||||
try {
|
||||
const domain = profile._json.hd;
|
||||
|
||||
if (!domain) {
|
||||
throw new GoogleWorkspaceRequiredError();
|
||||
}
|
||||
|
||||
if (allowedDomains.length && !allowedDomains.includes(domain)) {
|
||||
throw new GoogleWorkspaceInvalidError();
|
||||
}
|
||||
|
||||
const subdomain = domain.split(".")[0];
|
||||
const teamName = capitalize(subdomain);
|
||||
|
||||
const result = await accountProvisioner({
|
||||
ip: req.ip,
|
||||
team: {
|
||||
name: teamName,
|
||||
domain,
|
||||
subdomain,
|
||||
},
|
||||
user: {
|
||||
name: profile.displayName,
|
||||
email: profile.email,
|
||||
avatarUrl: profile.picture,
|
||||
},
|
||||
authenticationProvider: {
|
||||
name: providerName,
|
||||
providerId: domain,
|
||||
},
|
||||
authentication: {
|
||||
providerId: profile.id,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
scopes,
|
||||
},
|
||||
});
|
||||
return done(null, result.user, result);
|
||||
} catch (err) {
|
||||
return done(err, null);
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
router.get("google", passport.authenticate(providerName));
|
||||
|
||||
router.get(
|
||||
"google.callback",
|
||||
auth({ required: false }),
|
||||
passportMiddleware(providerName)
|
||||
);
|
||||
}
|
||||
|
||||
export default router;
|
@ -9,7 +9,7 @@ import { AuthenticationError } from "../errors";
|
||||
import auth from "../middlewares/authentication";
|
||||
import validation from "../middlewares/validation";
|
||||
import { Team } from "../models";
|
||||
import { requireDirectory } from "../utils/fs";
|
||||
import providers from "./providers";
|
||||
|
||||
const log = debug("server");
|
||||
const app = new Koa();
|
||||
@ -17,15 +17,11 @@ const router = new Router();
|
||||
|
||||
router.use(passport.initialize());
|
||||
|
||||
// dynamically load available authentication providers
|
||||
requireDirectory(__dirname).forEach(([{ default: provider, config }]) => {
|
||||
if (provider && provider.routes) {
|
||||
if (!config) {
|
||||
throw new Error("Auth providers must export a 'config' object");
|
||||
}
|
||||
|
||||
router.use("/", provider.routes());
|
||||
log(`loaded ${config.name} auth provider`);
|
||||
// dynamically load available authentication provider routes
|
||||
providers.forEach((provider) => {
|
||||
if (provider.enabled) {
|
||||
router.use("/", provider.router.routes());
|
||||
log(`loaded ${provider.name} auth provider`);
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -8,5 +8,6 @@ Auth providers generally use [Passport](http://www.passportjs.org/) strategies,
|
||||
although they can use any custom logic if needed. See the `google` auth provider for the cleanest example of what is required – some rules:
|
||||
|
||||
- The strategy name _must_ be lowercase
|
||||
- The stragegy _must_ call the `accountProvisioner` command in the verify callback
|
||||
- The strategy _must_ call the `accountProvisioner` command in the verify callback
|
||||
- The auth file _must_ export a `config` object with `name` and `enabled` keys
|
||||
- The auth file _must_ have a default export with a koa-router
|
@ -2,13 +2,13 @@
|
||||
import subMinutes from "date-fns/sub_minutes";
|
||||
import Router from "koa-router";
|
||||
import { find } from "lodash";
|
||||
import { AuthorizationError } from "../errors";
|
||||
import mailer from "../mailer";
|
||||
import auth from "../middlewares/authentication";
|
||||
import methodOverride from "../middlewares/methodOverride";
|
||||
import validation from "../middlewares/validation";
|
||||
import { User, Team } from "../models";
|
||||
import { getUserForEmailSigninToken } from "../utils/jwt";
|
||||
import { AuthorizationError } from "../../errors";
|
||||
import mailer from "../../mailer";
|
||||
import auth from "../../middlewares/authentication";
|
||||
import methodOverride from "../../middlewares/methodOverride";
|
||||
import validation from "../../middlewares/validation";
|
||||
import { User, Team } from "../../models";
|
||||
import { getUserForEmailSigninToken } from "../../utils/jwt";
|
||||
|
||||
const router = new Router();
|
||||
|
98
server/auth/providers/google.js
Normal file
98
server/auth/providers/google.js
Normal file
@ -0,0 +1,98 @@
|
||||
// @flow
|
||||
import passport from "@outlinewiki/koa-passport";
|
||||
import Router from "koa-router";
|
||||
import { capitalize } from "lodash";
|
||||
import { Strategy as GoogleStrategy } from "passport-google-oauth2";
|
||||
import accountProvisioner from "../../commands/accountProvisioner";
|
||||
import env from "../../env";
|
||||
import {
|
||||
GoogleWorkspaceRequiredError,
|
||||
GoogleWorkspaceInvalidError,
|
||||
} from "../../errors";
|
||||
import auth from "../../middlewares/authentication";
|
||||
import passportMiddleware from "../../middlewares/passport";
|
||||
import { getAllowedDomains } from "../../utils/authentication";
|
||||
import { StateStore } from "../../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",
|
||||
];
|
||||
|
||||
export const config = {
|
||||
name: "Google",
|
||||
enabled: !!GOOGLE_CLIENT_ID,
|
||||
};
|
||||
|
||||
passport.use(
|
||||
new GoogleStrategy(
|
||||
{
|
||||
clientID: GOOGLE_CLIENT_ID,
|
||||
clientSecret: GOOGLE_CLIENT_SECRET,
|
||||
callbackURL: `${env.URL}/auth/google.callback`,
|
||||
prompt: "select_account consent",
|
||||
passReqToCallback: true,
|
||||
store: new StateStore(),
|
||||
scope: scopes,
|
||||
},
|
||||
async function (req, accessToken, refreshToken, profile, done) {
|
||||
try {
|
||||
const domain = profile._json.hd;
|
||||
|
||||
if (!domain) {
|
||||
throw new GoogleWorkspaceRequiredError();
|
||||
}
|
||||
|
||||
if (allowedDomains.length && !allowedDomains.includes(domain)) {
|
||||
throw new GoogleWorkspaceInvalidError();
|
||||
}
|
||||
|
||||
const subdomain = domain.split(".")[0];
|
||||
const teamName = capitalize(subdomain);
|
||||
|
||||
const result = await accountProvisioner({
|
||||
ip: req.ip,
|
||||
team: {
|
||||
name: teamName,
|
||||
domain,
|
||||
subdomain,
|
||||
},
|
||||
user: {
|
||||
name: profile.displayName,
|
||||
email: profile.email,
|
||||
avatarUrl: profile.picture,
|
||||
},
|
||||
authenticationProvider: {
|
||||
name: providerName,
|
||||
providerId: domain,
|
||||
},
|
||||
authentication: {
|
||||
providerId: profile.id,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
scopes,
|
||||
},
|
||||
});
|
||||
return done(null, result.user, result);
|
||||
} catch (err) {
|
||||
return done(err, null);
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
router.get("google", passport.authenticate(providerName));
|
||||
|
||||
router.get(
|
||||
"google.callback",
|
||||
auth({ required: false }),
|
||||
passportMiddleware(providerName)
|
||||
);
|
||||
|
||||
export default router;
|
37
server/auth/providers/index.js
Normal file
37
server/auth/providers/index.js
Normal file
@ -0,0 +1,37 @@
|
||||
// @flow
|
||||
import { signin } from "../../../shared/utils/routeHelpers";
|
||||
import { requireDirectory } from "../../utils/fs";
|
||||
|
||||
let providers = [];
|
||||
|
||||
requireDirectory(__dirname).forEach(([module, id]) => {
|
||||
const { config, default: router } = module;
|
||||
|
||||
if (id === "index") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
throw new Error(
|
||||
`Auth providers must export a 'config' object, missing in ${id}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!router || !router.routes) {
|
||||
throw new Error(
|
||||
`Default export of an auth provider must be a koa-router, missing in ${id}`
|
||||
);
|
||||
}
|
||||
|
||||
if (config && config.enabled) {
|
||||
providers.push({
|
||||
id,
|
||||
name: config.name,
|
||||
enabled: config.enabled,
|
||||
authUrl: signin(id),
|
||||
router: router,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default providers;
|
196
server/auth/providers/slack.js
Normal file
196
server/auth/providers/slack.js
Normal file
@ -0,0 +1,196 @@
|
||||
// @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 { Authentication, Collection, Integration, Team } from "../../models";
|
||||
import * as Slack from "../../slack";
|
||||
import { StateStore } from "../../utils/passport";
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
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",
|
||||
auth({ required: false }),
|
||||
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 Authentication.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 authentcation 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 Authentication.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;
|
@ -1,202 +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 { Authentication, Collection, Integration, Team } from "../models";
|
||||
import * as Slack from "../slack";
|
||||
import { StateStore } from "../utils/passport";
|
||||
|
||||
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",
|
||||
auth({ required: false }),
|
||||
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 Authentication.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 authentcation 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 Authentication.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;
|
@ -1,19 +1,7 @@
|
||||
// @flow
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { DataTypes, sequelize } from "../sequelize";
|
||||
|
||||
// Each authentication provider must have a definition under server/auth, the
|
||||
// name of the file will be used as reference in the db, one less thing to config
|
||||
const authProviders = fs
|
||||
.readdirSync(path.resolve(__dirname, "..", "auth"))
|
||||
.filter(
|
||||
(file) =>
|
||||
file.indexOf(".") !== 0 &&
|
||||
!file.includes(".test") &&
|
||||
!file.includes("index.js")
|
||||
)
|
||||
.map((fileName) => fileName.replace(".js", ""));
|
||||
import providers from "../auth/providers";
|
||||
import { ValidationError } from "../errors";
|
||||
import { DataTypes, Op, sequelize } from "../sequelize";
|
||||
|
||||
const AuthenticationProvider = sequelize.define(
|
||||
"authentication_providers",
|
||||
@ -26,7 +14,7 @@ const AuthenticationProvider = sequelize.define(
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
validate: {
|
||||
isIn: [authProviders],
|
||||
isIn: [providers.map((p) => p.id)],
|
||||
},
|
||||
},
|
||||
enabled: {
|
||||
@ -48,4 +36,29 @@ AuthenticationProvider.associate = (models) => {
|
||||
AuthenticationProvider.hasMany(models.UserAuthentication);
|
||||
};
|
||||
|
||||
AuthenticationProvider.prototype.disable = async function () {
|
||||
const res = await AuthenticationProvider.findAndCountAll({
|
||||
where: {
|
||||
teamId: this.teamId,
|
||||
enabled: true,
|
||||
id: {
|
||||
[Op.ne]: this.id,
|
||||
},
|
||||
},
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
if (res.count >= 1) {
|
||||
return this.update({ enabled: false });
|
||||
} else {
|
||||
throw new ValidationError(
|
||||
"At least one authentication provider is required"
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
AuthenticationProvider.prototype.enable = async function () {
|
||||
return this.update({ enabled: true });
|
||||
};
|
||||
|
||||
export default AuthenticationProvider;
|
||||
|
@ -72,6 +72,7 @@ Event.ACTIVITY_EVENTS = [
|
||||
Event.AUDIT_EVENTS = [
|
||||
"api_keys.create",
|
||||
"api_keys.delete",
|
||||
"authenticationProviders.update",
|
||||
"collections.create",
|
||||
"collections.update",
|
||||
"collections.move",
|
||||
|
31
server/policies/authenticationProvider.js
Normal file
31
server/policies/authenticationProvider.js
Normal file
@ -0,0 +1,31 @@
|
||||
// @flow
|
||||
import { AdminRequiredError } from "../errors";
|
||||
import { AuthenticationProvider, User, Team } from "../models";
|
||||
import policy from "./policy";
|
||||
|
||||
const { allow } = policy;
|
||||
|
||||
allow(User, "createAuthenticationProvider", Team, (actor, team) => {
|
||||
if (!team || actor.teamId !== team.id) return false;
|
||||
if (actor.isAdmin) return true;
|
||||
throw new AdminRequiredError();
|
||||
});
|
||||
|
||||
allow(
|
||||
User,
|
||||
"read",
|
||||
AuthenticationProvider,
|
||||
(actor, authenticationProvider) =>
|
||||
actor && actor.teamId === authenticationProvider.teamId
|
||||
);
|
||||
|
||||
allow(
|
||||
User,
|
||||
["update", "delete"],
|
||||
AuthenticationProvider,
|
||||
(actor, authenticationProvider) => {
|
||||
if (actor.teamId !== authenticationProvider.teamId) return false;
|
||||
if (actor.isAdmin) return true;
|
||||
throw new AdminRequiredError();
|
||||
}
|
||||
);
|
@ -3,6 +3,7 @@ import { Attachment, Team, User, Collection, Document, Group } from "../models";
|
||||
import policy from "./policy";
|
||||
import "./apiKey";
|
||||
import "./attachment";
|
||||
import "./authenticationProvider";
|
||||
import "./collection";
|
||||
import "./document";
|
||||
import "./integration";
|
||||
|
14
server/presenters/authenticationProvider.js
Normal file
14
server/presenters/authenticationProvider.js
Normal file
@ -0,0 +1,14 @@
|
||||
// @flow
|
||||
import { AuthenticationProvider } from "../models";
|
||||
|
||||
export default function present(
|
||||
authenticationProvider: AuthenticationProvider
|
||||
) {
|
||||
return {
|
||||
id: authenticationProvider.id,
|
||||
name: authenticationProvider.name,
|
||||
createdAt: authenticationProvider.createdAt,
|
||||
isEnabled: authenticationProvider.enabled,
|
||||
isConnected: true,
|
||||
};
|
||||
}
|
@ -1,5 +1,6 @@
|
||||
// @flow
|
||||
import presentApiKey from "./apiKey";
|
||||
import presentAuthenticationProvider from "./authenticationProvider";
|
||||
import presentCollection from "./collection";
|
||||
import presentCollectionGroupMembership from "./collectionGroupMembership";
|
||||
import presentDocument from "./document";
|
||||
@ -18,13 +19,14 @@ import presentUser from "./user";
|
||||
import presentView from "./view";
|
||||
|
||||
export {
|
||||
presentApiKey,
|
||||
presentAuthenticationProvider,
|
||||
presentUser,
|
||||
presentView,
|
||||
presentDocument,
|
||||
presentEvent,
|
||||
presentRevision,
|
||||
presentCollection,
|
||||
presentApiKey,
|
||||
presentShare,
|
||||
presentTeam,
|
||||
presentGroup,
|
||||
|
Reference in New Issue
Block a user