Files
outline/plugins/google/server/auth/google.ts
T

196 lines
6.5 KiB
TypeScript

import passport from "@outlinewiki/koa-passport";
import type { Context } from "koa";
import Router from "koa-router";
import { capitalize } from "es-toolkit/compat";
import type { Profile } from "passport";
import { Strategy as GoogleStrategy } from "passport-google-oauth2";
import { languages } from "@shared/i18n";
import { slugifyDomain } from "@shared/utils/domains";
import accountProvisioner from "@server/commands/accountProvisioner";
import {
GmailAccountCreationError,
TeamDomainRequiredError,
} from "@server/errors";
import passportMiddleware from "@server/middlewares/passport";
import { AuthenticationProvider, User } from "@server/models";
import type { AuthenticationResult } from "@server/types";
import {
StateStore,
getTeamFromContext,
getClientFromOAuthState,
getUserFromOAuthState,
startOAuthFlow,
} from "@server/utils/passport";
import config from "../../plugin.json";
import env from "../env";
import { createContext } from "@server/context";
const router = new Router();
const scopes = [
"https://www.googleapis.com/auth/userinfo.profile",
"https://www.googleapis.com/auth/userinfo.email",
];
type GoogleProfile = Profile & {
email: string;
picture: string;
_json: {
hd?: string;
locale?: 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/${config.id}.callback`,
passReqToCallback: true,
// @ts-expect-error StateStore
store: new StateStore(),
scope: scopes,
},
async function (
context: Context,
accessToken: string,
refreshToken: string,
params: { expires_in: number; scope?: string },
profile: GoogleProfile,
done: (
err: Error | null,
user: User | null,
result?: AuthenticationResult
) => void
) {
try {
// "domain" is the Google Workspaces domain
const domain = profile._json.hd;
let team = await getTeamFromContext(context);
const client = getClientFromOAuthState(context);
const user =
context.state?.auth?.user ?? (await getUserFromOAuthState(context));
// No profile domain means a personal gmail account, and no team means
// the request came from the apex domain rather than a workspace
// subdomain. We can't infer the workspace from the domain, so resolve
// it from the verified email's existing accounts instead.
if (!domain && !team) {
const existingAccounts = await User.findAll({
attributes: ["id", "teamId"],
where: { email: profile.email.toLowerCase() },
include: [
{
association: "team",
required: true,
},
],
});
const teamIds = new Set(
existingAccounts.map((account) => account.teamId)
);
// A personal gmail account cannot be used to create a new workspace.
if (teamIds.size === 0) {
throw GmailAccountCreationError();
}
// When the email belongs to more than one workspace it is ambiguous
// which to sign into, so the user must start from its subdomain.
if (teamIds.size > 1) {
throw TeamDomainRequiredError();
}
// Belongs to exactly one workspace — resolve it and sign in there.
team = existingAccounts[0].team;
}
// 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");
const locale = profile._json.locale;
const language = locale
? languages.find((l) => l.startsWith(locale))
: undefined;
// 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 ctx = createContext({
ip: context.ip,
user,
authType: context.state?.auth?.type,
});
const result = await accountProvisioner(ctx, {
team: {
teamId: team?.id,
name: teamName,
domain,
subdomain,
},
user: {
email: profile.email,
// Google only returns confirmed workspace email addresses.
emailVerified: true,
name: profile.displayName,
language,
avatarUrl,
},
authenticationProvider: {
name: config.id,
providerId: domain ?? "",
},
authentication: {
providerId: profile.id,
accessToken,
refreshToken,
expiresIn: params.expires_in,
scopes: params.scope ? params.scope.split(" ") : scopes,
},
});
return done(null, result.user, { ...result, client });
} catch (err) {
return done(err, null);
}
}
)
);
router.get(config.id, startOAuthFlow, async (ctx, next) => {
const team = await getTeamFromContext(ctx, {
includeHostQueryParam: true,
});
let extraScopes: string[] = [];
if (team) {
const authProvider = await AuthenticationProvider.findOne({
where: { name: config.id, teamId: team.id },
});
if (authProvider?.settings?.groupSyncEnabled) {
extraScopes = authProvider.settings.groupSyncScopes ?? [
"https://www.googleapis.com/auth/admin.directory.group.readonly",
];
}
}
return passport.authenticate(config.id, {
accessType: "offline",
prompt: "select_account consent",
scope: [...scopes, ...extraScopes],
})(ctx, next);
});
router.get(`${config.id}.callback`, passportMiddleware(config.id));
}
export default router;