chore: Move remaining auth methods to plugins (#4900)
* Move Google, Email, and Azure to plugins * Move OIDC provider, remove old loading code * Move AuthLogo to plugin * AuthLogo -> PluginIcon * Lazy load plugin settings
This commit is contained in:
@@ -6,8 +6,8 @@ import Router from "koa-router";
|
||||
import { AuthenticationError } from "@server/errors";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import { Collection, Team, View } from "@server/models";
|
||||
import AuthenticationHelper from "@server/models/helpers/AuthenticationHelper";
|
||||
import { AppState, AppContext, APIContext } from "@server/types";
|
||||
import providers from "./providers";
|
||||
|
||||
const app = new Koa<AppState, AppContext>();
|
||||
const router = new Router();
|
||||
@@ -15,10 +15,8 @@ const router = new Router();
|
||||
router.use(passport.initialize());
|
||||
|
||||
// dynamically load available authentication provider routes
|
||||
providers.forEach((provider) => {
|
||||
if (provider.enabled) {
|
||||
router.use("/", provider.router.routes());
|
||||
}
|
||||
AuthenticationHelper.providers.forEach((provider) => {
|
||||
router.use("/", provider.router.routes());
|
||||
});
|
||||
|
||||
router.get("/redirect", auth(), async (ctx: APIContext) => {
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
# Authentication Providers
|
||||
|
||||
A new auth provider can be added with the addition of a single file in this
|
||||
folder, and (optionally) a matching logo in `/app/components/AuthLogo/index.js`
|
||||
that will appear on the signin button.
|
||||
|
||||
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 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
|
||||
@@ -1,144 +0,0 @@
|
||||
import passport from "@outlinewiki/koa-passport";
|
||||
import { Strategy as AzureStrategy } from "@outlinewiki/passport-azure-ad-oauth2";
|
||||
import jwt from "jsonwebtoken";
|
||||
import type { Context } from "koa";
|
||||
import Router from "koa-router";
|
||||
import { Profile } from "passport";
|
||||
import { slugifyDomain } from "@shared/utils/domains";
|
||||
import accountProvisioner from "@server/commands/accountProvisioner";
|
||||
import env from "@server/env";
|
||||
import { MicrosoftGraphError } from "@server/errors";
|
||||
import passportMiddleware from "@server/middlewares/passport";
|
||||
import { User } from "@server/models";
|
||||
import { AuthenticationResult } from "@server/types";
|
||||
import {
|
||||
StateStore,
|
||||
request,
|
||||
getTeamFromContext,
|
||||
getClientFromContext,
|
||||
} from "@server/utils/passport";
|
||||
|
||||
const router = new Router();
|
||||
const providerName = "azure";
|
||||
const scopes: string[] = [];
|
||||
|
||||
export const config = {
|
||||
name: "Microsoft",
|
||||
enabled: !!env.AZURE_CLIENT_ID,
|
||||
};
|
||||
|
||||
if (env.AZURE_CLIENT_ID && env.AZURE_CLIENT_SECRET) {
|
||||
const strategy = new AzureStrategy(
|
||||
{
|
||||
clientID: env.AZURE_CLIENT_ID,
|
||||
clientSecret: env.AZURE_CLIENT_SECRET,
|
||||
callbackURL: `${env.URL}/auth/azure.callback`,
|
||||
useCommonEndpoint: true,
|
||||
passReqToCallback: true,
|
||||
resource: env.AZURE_RESOURCE_APP_ID,
|
||||
// @ts-expect-error StateStore
|
||||
store: new StateStore(),
|
||||
scope: scopes,
|
||||
},
|
||||
async function (
|
||||
ctx: Context,
|
||||
accessToken: string,
|
||||
refreshToken: string,
|
||||
params: { expires_in: number; id_token: string },
|
||||
_profile: Profile,
|
||||
done: (
|
||||
err: Error | null,
|
||||
user: User | null,
|
||||
result?: AuthenticationResult
|
||||
) => void
|
||||
) {
|
||||
try {
|
||||
// see docs for what the fields in profile represent here:
|
||||
// https://docs.microsoft.com/en-us/azure/active-directory/develop/access-tokens
|
||||
const profile = jwt.decode(params.id_token) as jwt.JwtPayload;
|
||||
|
||||
const [profileResponse, organizationResponse] = await Promise.all([
|
||||
// Load the users profile from the Microsoft Graph API
|
||||
// https://docs.microsoft.com/en-us/graph/api/resources/users?view=graph-rest-1.0
|
||||
request(`https://graph.microsoft.com/v1.0/me`, accessToken),
|
||||
// Load the organization profile from the Microsoft Graph API
|
||||
// https://docs.microsoft.com/en-us/graph/api/organization-get?view=graph-rest-1.0
|
||||
request(`https://graph.microsoft.com/v1.0/organization`, accessToken),
|
||||
]);
|
||||
|
||||
if (!profileResponse) {
|
||||
throw MicrosoftGraphError(
|
||||
"Unable to load user profile from Microsoft Graph API"
|
||||
);
|
||||
}
|
||||
|
||||
if (!organizationResponse) {
|
||||
throw MicrosoftGraphError(
|
||||
"Unable to load organization info from Microsoft Graph API"
|
||||
);
|
||||
}
|
||||
|
||||
const organization = organizationResponse.value[0];
|
||||
|
||||
// Note: userPrincipalName is last here for backwards compatibility with
|
||||
// previous versions of Outline that did not include it.
|
||||
const email =
|
||||
profile.email ||
|
||||
profileResponse.mail ||
|
||||
profileResponse.userPrincipalName;
|
||||
|
||||
if (!email) {
|
||||
throw MicrosoftGraphError(
|
||||
"'email' property is required but could not be found in user profile."
|
||||
);
|
||||
}
|
||||
|
||||
const team = await getTeamFromContext(ctx);
|
||||
const client = getClientFromContext(ctx);
|
||||
|
||||
const domain = email.split("@")[1];
|
||||
const subdomain = slugifyDomain(domain);
|
||||
|
||||
const teamName = organization.displayName;
|
||||
const result = await accountProvisioner({
|
||||
ip: ctx.ip,
|
||||
team: {
|
||||
teamId: team?.id,
|
||||
name: teamName,
|
||||
domain,
|
||||
subdomain,
|
||||
},
|
||||
user: {
|
||||
name: profile.name,
|
||||
email,
|
||||
avatarUrl: profile.picture,
|
||||
},
|
||||
authenticationProvider: {
|
||||
name: providerName,
|
||||
providerId: profile.tid,
|
||||
},
|
||||
authentication: {
|
||||
providerId: profile.oid,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresIn: params.expires_in,
|
||||
scopes,
|
||||
},
|
||||
});
|
||||
return done(null, result.user, { ...result, client });
|
||||
} catch (err) {
|
||||
return done(err, null);
|
||||
}
|
||||
}
|
||||
);
|
||||
passport.use(strategy);
|
||||
|
||||
router.get(
|
||||
"azure",
|
||||
passport.authenticate(providerName, { prompt: "select_account" })
|
||||
);
|
||||
|
||||
router.get("azure.callback", passportMiddleware(providerName));
|
||||
}
|
||||
|
||||
export default router;
|
||||
@@ -1,221 +0,0 @@
|
||||
import sharedEnv from "@shared/env";
|
||||
import SigninEmail from "@server/emails/templates/SigninEmail";
|
||||
import WelcomeEmail from "@server/emails/templates/WelcomeEmail";
|
||||
import env from "@server/env";
|
||||
import { buildUser, buildGuestUser, buildTeam } from "@server/test/factories";
|
||||
import { getTestServer } from "@server/test/support";
|
||||
|
||||
const server = getTestServer();
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it("should respond with redirect location when user is SSO enabled", async () => {
|
||||
const spy = jest.spyOn(WelcomeEmail, "schedule");
|
||||
const user = await buildUser();
|
||||
const res = await server.post("/auth/email", {
|
||||
body: {
|
||||
email: user.email,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.redirect).toMatch("slack");
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("should not send email when user is on another subdomain but respond with success", async () => {
|
||||
env.URL = sharedEnv.URL = "http://localoutline.com";
|
||||
env.SUBDOMAINS_ENABLED = sharedEnv.SUBDOMAINS_ENABLED = true;
|
||||
env.DEPLOYMENT = "hosted";
|
||||
|
||||
const user = await buildUser();
|
||||
const spy = jest.spyOn(WelcomeEmail, "schedule");
|
||||
await buildTeam({
|
||||
subdomain: "example",
|
||||
});
|
||||
const res = await server.post("/auth/email", {
|
||||
body: {
|
||||
email: user.email,
|
||||
},
|
||||
headers: {
|
||||
host: "example.localoutline.com",
|
||||
},
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.success).toEqual(true);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("should respond with success and email to be sent when user is not SSO enabled", async () => {
|
||||
const spy = jest.spyOn(SigninEmail, "schedule");
|
||||
const team = await buildTeam({
|
||||
subdomain: "example",
|
||||
});
|
||||
const user = await buildGuestUser({
|
||||
teamId: team.id,
|
||||
});
|
||||
const res = await server.post("/auth/email", {
|
||||
body: {
|
||||
email: user.email,
|
||||
},
|
||||
headers: {
|
||||
host: "example.localoutline.com",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.success).toEqual(true);
|
||||
expect(spy).toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("should respond with success regardless of whether successful to prevent crawling email logins", async () => {
|
||||
const spy = jest.spyOn(WelcomeEmail, "schedule");
|
||||
await buildTeam({
|
||||
subdomain: "example",
|
||||
});
|
||||
const res = await server.post("/auth/email", {
|
||||
body: {
|
||||
email: "user@example.com",
|
||||
},
|
||||
headers: {
|
||||
host: "example.localoutline.com",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.success).toEqual(true);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
});
|
||||
describe("with multiple users matching email", () => {
|
||||
it("should default to current subdomain with SSO", async () => {
|
||||
const spy = jest.spyOn(SigninEmail, "schedule");
|
||||
env.URL = sharedEnv.URL = "http://localoutline.com";
|
||||
env.SUBDOMAINS_ENABLED = sharedEnv.SUBDOMAINS_ENABLED = true;
|
||||
const email = "sso-user@example.org";
|
||||
const team = await buildTeam({
|
||||
subdomain: "example",
|
||||
});
|
||||
await buildGuestUser({
|
||||
email,
|
||||
});
|
||||
await buildUser({
|
||||
email,
|
||||
teamId: team.id,
|
||||
});
|
||||
const res = await server.post("/auth/email", {
|
||||
body: {
|
||||
email,
|
||||
},
|
||||
headers: {
|
||||
host: "example.localoutline.com",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.redirect).toMatch("slack");
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("should default to current subdomain with guest email", async () => {
|
||||
const spy = jest.spyOn(SigninEmail, "schedule");
|
||||
env.URL = sharedEnv.URL = "http://localoutline.com";
|
||||
env.SUBDOMAINS_ENABLED = sharedEnv.SUBDOMAINS_ENABLED = true;
|
||||
const email = "guest-user@example.org";
|
||||
const team = await buildTeam({
|
||||
subdomain: "example",
|
||||
});
|
||||
await buildUser({
|
||||
email,
|
||||
});
|
||||
await buildGuestUser({
|
||||
email,
|
||||
teamId: team.id,
|
||||
});
|
||||
const res = await server.post("/auth/email", {
|
||||
body: {
|
||||
email,
|
||||
},
|
||||
headers: {
|
||||
host: "example.localoutline.com",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.success).toEqual(true);
|
||||
expect(spy).toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("should default to custom domain with SSO", async () => {
|
||||
const spy = jest.spyOn(WelcomeEmail, "schedule");
|
||||
const email = "sso-user-2@example.org";
|
||||
const team = await buildTeam({
|
||||
domain: "docs.mycompany.com",
|
||||
});
|
||||
await buildGuestUser({
|
||||
email,
|
||||
});
|
||||
await buildUser({
|
||||
email,
|
||||
teamId: team.id,
|
||||
});
|
||||
const res = await server.post("/auth/email", {
|
||||
body: {
|
||||
email,
|
||||
},
|
||||
headers: {
|
||||
host: "docs.mycompany.com",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.redirect).toMatch("slack");
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("should default to custom domain with guest email", async () => {
|
||||
const spy = jest.spyOn(SigninEmail, "schedule");
|
||||
const email = "guest-user-2@example.org";
|
||||
const team = await buildTeam({
|
||||
domain: "docs.mycompany.com",
|
||||
});
|
||||
await buildUser({
|
||||
email,
|
||||
});
|
||||
await buildGuestUser({
|
||||
email,
|
||||
teamId: team.id,
|
||||
});
|
||||
const res = await server.post("/auth/email", {
|
||||
body: {
|
||||
email,
|
||||
},
|
||||
headers: {
|
||||
host: "docs.mycompany.com",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.success).toEqual(true);
|
||||
expect(spy).toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,143 +0,0 @@
|
||||
import Router from "koa-router";
|
||||
import { find } from "lodash";
|
||||
import { Client } from "@shared/types";
|
||||
import { parseDomain } from "@shared/utils/domains";
|
||||
import InviteAcceptedEmail from "@server/emails/templates/InviteAcceptedEmail";
|
||||
import SigninEmail from "@server/emails/templates/SigninEmail";
|
||||
import WelcomeEmail from "@server/emails/templates/WelcomeEmail";
|
||||
import env from "@server/env";
|
||||
import { AuthorizationError } from "@server/errors";
|
||||
import { rateLimiter } from "@server/middlewares/rateLimiter";
|
||||
import { User, Team } from "@server/models";
|
||||
import { RateLimiterStrategy } from "@server/utils/RateLimiter";
|
||||
import { signIn } from "@server/utils/authentication";
|
||||
import { getUserForEmailSigninToken } from "@server/utils/jwt";
|
||||
import { assertEmail, assertPresent } from "@server/validation";
|
||||
|
||||
const router = new Router();
|
||||
|
||||
export const config = {
|
||||
name: "Email",
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
router.post(
|
||||
"email",
|
||||
rateLimiter(RateLimiterStrategy.TenPerHour),
|
||||
async (ctx) => {
|
||||
const { email, client } = ctx.request.body;
|
||||
assertEmail(email, "email is required");
|
||||
|
||||
const domain = parseDomain(ctx.request.hostname);
|
||||
|
||||
let team: Team | null | undefined;
|
||||
if (!env.isCloudHosted()) {
|
||||
team = await Team.scope("withAuthenticationProviders").findOne();
|
||||
} else if (domain.custom) {
|
||||
team = await Team.scope("withAuthenticationProviders").findOne({
|
||||
where: { domain: domain.host },
|
||||
});
|
||||
} else if (env.SUBDOMAINS_ENABLED && domain.teamSubdomain) {
|
||||
team = await Team.scope("withAuthenticationProviders").findOne({
|
||||
where: { subdomain: domain.teamSubdomain },
|
||||
});
|
||||
}
|
||||
|
||||
if (!team?.emailSigninEnabled) {
|
||||
throw AuthorizationError();
|
||||
}
|
||||
|
||||
const user = await User.scope("withAuthentications").findOne({
|
||||
where: {
|
||||
teamId: team.id,
|
||||
email: email.toLowerCase(),
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
ctx.body = {
|
||||
success: true,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
// If the user matches an email address associated with an SSO
|
||||
// provider then just forward them directly to that sign-in page
|
||||
if (user.authentications.length) {
|
||||
const authProvider = find(team.authenticationProviders, {
|
||||
id: user.authentications[0].authenticationProviderId,
|
||||
});
|
||||
if (authProvider?.enabled) {
|
||||
ctx.body = {
|
||||
redirect: `${team.url}/auth/${authProvider?.name}`,
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// send email to users registered address with a short-lived token
|
||||
await SigninEmail.schedule({
|
||||
to: user.email,
|
||||
token: user.getEmailSigninToken(),
|
||||
teamUrl: team.url,
|
||||
client: client === Client.Desktop ? Client.Desktop : Client.Web,
|
||||
});
|
||||
user.lastSigninEmailSentAt = new Date();
|
||||
await user.save();
|
||||
|
||||
// respond with success regardless of whether an email was sent
|
||||
ctx.body = {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
router.get("email.callback", async (ctx) => {
|
||||
const { token, client } = ctx.request.query;
|
||||
assertPresent(token, "token is required");
|
||||
|
||||
let user!: User;
|
||||
|
||||
try {
|
||||
user = await getUserForEmailSigninToken(token as string);
|
||||
} catch (err) {
|
||||
ctx.redirect(`/?notice=expired-token`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user.team.emailSigninEnabled) {
|
||||
return ctx.redirect("/?notice=auth-error");
|
||||
}
|
||||
|
||||
if (user.isSuspended) {
|
||||
return ctx.redirect("/?notice=suspended");
|
||||
}
|
||||
|
||||
if (user.isInvited) {
|
||||
await WelcomeEmail.schedule({
|
||||
to: user.email,
|
||||
teamUrl: user.team.url,
|
||||
});
|
||||
|
||||
const inviter = await user.$get("invitedBy");
|
||||
if (inviter) {
|
||||
await InviteAcceptedEmail.schedule({
|
||||
to: inviter.email,
|
||||
inviterId: inviter.id,
|
||||
invitedName: user.name,
|
||||
teamUrl: user.team.url,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// set cookies on response and redirect to team subdomain
|
||||
await signIn(ctx, "email", {
|
||||
user,
|
||||
team: user.team,
|
||||
isNewTeam: false,
|
||||
isNewUser: false,
|
||||
client: client === Client.Desktop ? Client.Desktop : Client.Web,
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,148 +0,0 @@
|
||||
import passport from "@outlinewiki/koa-passport";
|
||||
import type { Context } from "koa";
|
||||
import Router from "koa-router";
|
||||
import { capitalize } from "lodash";
|
||||
import { Profile } from "passport";
|
||||
import { Strategy as GoogleStrategy } from "passport-google-oauth2";
|
||||
import { slugifyDomain } from "@shared/utils/domains";
|
||||
import accountProvisioner from "@server/commands/accountProvisioner";
|
||||
import env from "@server/env";
|
||||
import {
|
||||
GmailAccountCreationError,
|
||||
TeamDomainRequiredError,
|
||||
} from "@server/errors";
|
||||
import passportMiddleware from "@server/middlewares/passport";
|
||||
import { User } from "@server/models";
|
||||
import { AuthenticationResult } from "@server/types";
|
||||
import {
|
||||
StateStore,
|
||||
getTeamFromContext,
|
||||
getClientFromContext,
|
||||
} from "@server/utils/passport";
|
||||
|
||||
const router = new Router();
|
||||
const GOOGLE = "google";
|
||||
const scopes = [
|
||||
"https://www.googleapis.com/auth/userinfo.profile",
|
||||
"https://www.googleapis.com/auth/userinfo.email",
|
||||
];
|
||||
|
||||
export const config = {
|
||||
name: "Google",
|
||||
enabled: !!env.GOOGLE_CLIENT_ID,
|
||||
};
|
||||
|
||||
type GoogleProfile = Profile & {
|
||||
email: string;
|
||||
picture: string;
|
||||
_json: {
|
||||
hd?: string;
|
||||
};
|
||||
};
|
||||
|
||||
if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {
|
||||
passport.use(
|
||||
new GoogleStrategy(
|
||||
{
|
||||
clientID: env.GOOGLE_CLIENT_ID,
|
||||
clientSecret: env.GOOGLE_CLIENT_SECRET,
|
||||
callbackURL: `${env.URL}/auth/google.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: GoogleProfile,
|
||||
done: (
|
||||
err: Error | null,
|
||||
user: User | null,
|
||||
result?: AuthenticationResult
|
||||
) => void
|
||||
) {
|
||||
try {
|
||||
// "domain" is the Google Workspaces domain
|
||||
const domain = profile._json.hd;
|
||||
const team = await getTeamFromContext(ctx);
|
||||
const client = getClientFromContext(ctx);
|
||||
|
||||
// No profile domain means personal gmail account
|
||||
// No team implies the request came from the apex domain
|
||||
// This combination is always an error
|
||||
if (!domain && !team) {
|
||||
const userExists = await User.count({
|
||||
where: { email: profile.email.toLowerCase() },
|
||||
});
|
||||
|
||||
// Users cannot create a team with personal gmail accounts
|
||||
if (!userExists) {
|
||||
throw GmailAccountCreationError();
|
||||
}
|
||||
|
||||
// To log-in with a personal account, users must specify a team subdomain
|
||||
throw TeamDomainRequiredError();
|
||||
}
|
||||
|
||||
// remove the TLD and form a subdomain from the remaining
|
||||
// subdomains of the form "foo.bar.com" are allowed as primary Google Workspaces domains
|
||||
// see https://support.google.com/nonprofits/thread/19685140/using-a-subdomain-as-a-primary-domain
|
||||
const subdomain = domain ? slugifyDomain(domain) : "";
|
||||
const teamName = capitalize(subdomain);
|
||||
|
||||
// Request a larger size profile picture than the default by tweaking
|
||||
// the query parameter.
|
||||
const avatarUrl = profile.picture.replace("=s96-c", "=s128-c");
|
||||
|
||||
// if a team can be inferred, we assume the user is only interested in signing into
|
||||
// that team in particular; otherwise, we will do a best effort at finding their account
|
||||
// or provisioning a new one (within AccountProvisioner)
|
||||
const result = await accountProvisioner({
|
||||
ip: ctx.ip,
|
||||
team: {
|
||||
teamId: team?.id,
|
||||
name: teamName,
|
||||
domain,
|
||||
subdomain,
|
||||
},
|
||||
user: {
|
||||
email: profile.email,
|
||||
name: profile.displayName,
|
||||
avatarUrl,
|
||||
},
|
||||
authenticationProvider: {
|
||||
name: GOOGLE,
|
||||
providerId: domain ?? "",
|
||||
},
|
||||
authentication: {
|
||||
providerId: profile.id,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresIn: params.expires_in,
|
||||
scopes,
|
||||
},
|
||||
});
|
||||
|
||||
return done(null, result.user, { ...result, client });
|
||||
} catch (err) {
|
||||
return done(err, null);
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
router.get(
|
||||
"google",
|
||||
passport.authenticate(GOOGLE, {
|
||||
accessType: "offline",
|
||||
prompt: "select_account consent",
|
||||
})
|
||||
);
|
||||
|
||||
router.get("google.callback", passportMiddleware(GOOGLE));
|
||||
}
|
||||
|
||||
export default router;
|
||||
@@ -1,80 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
import path from "path";
|
||||
import { glob } from "glob";
|
||||
import Router from "koa-router";
|
||||
import { sortBy } from "lodash";
|
||||
import env from "@server/env";
|
||||
import { requireDirectory } from "@server/utils/fs";
|
||||
|
||||
export type AuthenticationProviderConfig = {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
router: Router;
|
||||
};
|
||||
|
||||
const authenticationProviderConfigs: AuthenticationProviderConfig[] = [];
|
||||
|
||||
requireDirectory<{
|
||||
default: Router;
|
||||
config: { name: string; enabled: boolean };
|
||||
}>(__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) {
|
||||
authenticationProviderConfigs.push({
|
||||
id,
|
||||
name: config.name,
|
||||
enabled: config.enabled,
|
||||
router,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Temporarily also include plugins here until all auth methods are moved over.
|
||||
glob
|
||||
.sync(
|
||||
(env.ENVIRONMENT === "test" ? "" : "build/") +
|
||||
"plugins/*/server/auth/!(*.test).[jt]s"
|
||||
)
|
||||
.forEach((filePath: string) => {
|
||||
const authProvider = require(path.join(process.cwd(), filePath)).default;
|
||||
const id = filePath.replace("build/", "").split("/")[1];
|
||||
const config = require(path.join(
|
||||
process.cwd(),
|
||||
env.ENVIRONMENT === "test" ? "" : "build",
|
||||
"plugins",
|
||||
id,
|
||||
"plugin.json"
|
||||
));
|
||||
|
||||
// Test the all required env vars are set for the auth provider
|
||||
const enabled = (config.requiredEnvVars ?? []).every(
|
||||
(name: string) => !!env[name]
|
||||
);
|
||||
|
||||
authenticationProviderConfigs.push({
|
||||
id,
|
||||
name: config.name,
|
||||
enabled,
|
||||
router: authProvider,
|
||||
});
|
||||
});
|
||||
|
||||
export default sortBy(authenticationProviderConfigs, "id");
|
||||
@@ -1,147 +0,0 @@
|
||||
import passport from "@outlinewiki/koa-passport";
|
||||
import type { Context } from "koa";
|
||||
import Router from "koa-router";
|
||||
import { get } from "lodash";
|
||||
import { Strategy } from "passport-oauth2";
|
||||
import { slugifyDomain } from "@shared/utils/domains";
|
||||
import accountProvisioner from "@server/commands/accountProvisioner";
|
||||
import env from "@server/env";
|
||||
import {
|
||||
OIDCMalformedUserInfoError,
|
||||
AuthenticationError,
|
||||
} from "@server/errors";
|
||||
import passportMiddleware from "@server/middlewares/passport";
|
||||
import { User } from "@server/models";
|
||||
import { AuthenticationResult } from "@server/types";
|
||||
import {
|
||||
StateStore,
|
||||
request,
|
||||
getTeamFromContext,
|
||||
getClientFromContext,
|
||||
} from "@server/utils/passport";
|
||||
|
||||
const router = new Router();
|
||||
const providerName = "oidc";
|
||||
const OIDC_AUTH_URI = env.OIDC_AUTH_URI || "";
|
||||
const OIDC_TOKEN_URI = env.OIDC_TOKEN_URI || "";
|
||||
const OIDC_USERINFO_URI = env.OIDC_USERINFO_URI || "";
|
||||
|
||||
export const config = {
|
||||
name: env.OIDC_DISPLAY_NAME,
|
||||
enabled: !!env.OIDC_CLIENT_ID,
|
||||
};
|
||||
const scopes = env.OIDC_SCOPES.split(" ");
|
||||
|
||||
Strategy.prototype.userProfile = async function (accessToken, done) {
|
||||
try {
|
||||
const response = await request(OIDC_USERINFO_URI, accessToken);
|
||||
return done(null, response);
|
||||
} catch (err) {
|
||||
return done(err);
|
||||
}
|
||||
};
|
||||
|
||||
if (env.OIDC_CLIENT_ID && env.OIDC_CLIENT_SECRET) {
|
||||
passport.use(
|
||||
providerName,
|
||||
new Strategy(
|
||||
{
|
||||
authorizationURL: OIDC_AUTH_URI,
|
||||
tokenURL: OIDC_TOKEN_URI,
|
||||
clientID: env.OIDC_CLIENT_ID,
|
||||
clientSecret: env.OIDC_CLIENT_SECRET,
|
||||
callbackURL: `${env.URL}/auth/${providerName}.callback`,
|
||||
passReqToCallback: true,
|
||||
scope: env.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 (
|
||||
ctx: Context,
|
||||
accessToken: string,
|
||||
refreshToken: string,
|
||||
params: { expires_in: number },
|
||||
profile: Record<string, string>,
|
||||
done: (
|
||||
err: Error | null,
|
||||
user: User | null,
|
||||
result?: AuthenticationResult
|
||||
) => void
|
||||
) {
|
||||
try {
|
||||
if (!profile.email) {
|
||||
throw AuthenticationError(
|
||||
`An email field was not returned in the profile parameter, but is required.`
|
||||
);
|
||||
}
|
||||
if (!profile.name) {
|
||||
throw AuthenticationError(
|
||||
`A name field was not returned in the profile parameter, but is required.`
|
||||
);
|
||||
}
|
||||
const team = await getTeamFromContext(ctx);
|
||||
const client = getClientFromContext(ctx);
|
||||
|
||||
const parts = profile.email.toLowerCase().split("@");
|
||||
const domain = parts.length && parts[1];
|
||||
|
||||
if (!domain) {
|
||||
throw OIDCMalformedUserInfoError();
|
||||
}
|
||||
|
||||
// remove the TLD and form a subdomain from the remaining
|
||||
const subdomain = slugifyDomain(domain);
|
||||
|
||||
// Claim name can be overriden using an env variable.
|
||||
// Default is 'preferred_username' as per OIDC spec.
|
||||
const username = get(profile, env.OIDC_USERNAME_CLAIM);
|
||||
|
||||
const result = await accountProvisioner({
|
||||
ip: ctx.ip,
|
||||
team: {
|
||||
teamId: team?.id,
|
||||
// https://github.com/outline/outline/pull/2388#discussion_r681120223
|
||||
name: "Wiki",
|
||||
domain,
|
||||
subdomain,
|
||||
},
|
||||
user: {
|
||||
name: profile.name || username || profile.username,
|
||||
email: profile.email,
|
||||
avatarUrl: profile.picture,
|
||||
username,
|
||||
},
|
||||
authenticationProvider: {
|
||||
name: providerName,
|
||||
providerId: domain,
|
||||
},
|
||||
authentication: {
|
||||
providerId: profile.sub,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresIn: params.expires_in,
|
||||
scopes,
|
||||
},
|
||||
});
|
||||
return done(null, result.user, { ...result, client });
|
||||
} catch (err) {
|
||||
return done(err, null);
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
router.get(providerName, passport.authenticate(providerName));
|
||||
|
||||
router.get(`${providerName}.callback`, passportMiddleware(providerName));
|
||||
}
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user