feat: created oauth2 abstract class

This commit is contained in:
Wyatt Johnson
2018-10-29 15:13:10 -06:00
parent c47032f257
commit 58e51b299d
2 changed files with 195 additions and 173 deletions
@@ -1,14 +1,11 @@
import { Db } from "mongodb";
import {
Profile,
Strategy as FacebookPassportStrategy,
} from "passport-facebook";
import { VerifyCallback } from "passport-oauth2";
import { Strategy } from "passport-strategy";
import { Profile, Strategy } from "passport-facebook";
import { Config } from "talk-common/config";
import OAuth2Strategy from "talk-server/app/middleware/passport/strategies/oauth2";
import { reconstructTenantURL } from "talk-server/app/url";
import {
GQLAuthIntegrations,
GQLFacebookAuthIntegration,
GQLUSER_ROLE,
} from "talk-server/graph/tenant/schema/__generated__/types";
@@ -18,85 +15,7 @@ import {
retrieveUserWithProfile,
} from "talk-server/models/user";
import TenantCache from "talk-server/services/tenant/cache";
import { TenantCacheAdapter } from "talk-server/services/tenant/cache/adapter";
import { upsert } from "talk-server/services/users";
import { Request } from "talk-server/types/express";
async function findOrCreateFacebookUser(
mongo: Db,
tenant: Tenant,
integration: GQLFacebookAuthIntegration,
{ id, photos, emails, displayName }: Profile
) {
// Create the user profile that will be used to lookup the User.
const profile: FacebookProfile = {
type: "facebook",
id,
};
let user = await retrieveUserWithProfile(mongo, tenant.id, profile);
if (!user) {
if (!integration.allowRegistration) {
// Registration is disabled, so we can't create the user user here.
return;
}
// FIXME: implement rules.
// Try to get the avatar.
let avatar: string | undefined;
if (photos && photos.length > 0) {
avatar = photos[0].value;
}
// Try to get the email address.
let email: string | undefined;
let emailVerified: boolean | undefined;
if (emails && emails.length > 0) {
email = emails[0].value;
emailVerified = false;
}
user = await upsert(mongo, tenant, {
username: null,
displayName,
role: GQLUSER_ROLE.COMMENTER,
email,
email_verified: emailVerified,
avatar,
profiles: [profile],
});
}
return user;
}
function getEnabledIntegration(
tenant: Tenant
): Required<GQLFacebookAuthIntegration> {
const integration = tenant.auth.integrations.facebook;
// Handle when the integration is enabled/disabled.
if (!integration.enabled) {
// TODO: return a better error.
throw new Error("integration not enabled");
}
if (!integration.enabled) {
// TODO: return a better error.
throw new Error("integration not enabled");
}
if (!integration.clientID) {
throw new Error("clientID is missing in configuration");
}
if (!integration.clientSecret) {
throw new Error("clientSecret is missing in configuration");
}
// TODO: (wyattjoh) for some reason, type guards above to not allow coercion to this required type.
return integration as Required<GQLFacebookAuthIntegration>;
}
export interface FacebookStrategyOptions {
config: Config;
@@ -104,100 +23,81 @@ export interface FacebookStrategyOptions {
tenantCache: TenantCache;
}
export default class FacebookStrategy extends Strategy {
export default class FacebookStrategy extends OAuth2Strategy<
GQLFacebookAuthIntegration,
Strategy
> {
public name = "facebook";
private config: Config;
private mongo: Db;
private cache: TenantCacheAdapter<FacebookPassportStrategy>;
protected getIntegration = (integrations: GQLAuthIntegrations) =>
integrations.facebook;
constructor({ config, mongo, tenantCache }: FacebookStrategyOptions) {
super();
protected async findOrCreateUser(
tenant: Tenant,
integration: Required<GQLFacebookAuthIntegration>,
{ id, photos, emails, displayName }: Profile
) {
// Create the user profile that will be used to lookup the User.
const profile: FacebookProfile = {
type: "facebook",
id,
};
this.config = config;
this.mongo = mongo;
this.cache = new TenantCacheAdapter(tenantCache);
let user = await retrieveUserWithProfile(this.mongo, tenant.id, profile);
if (!user) {
if (!integration.allowRegistration) {
// Registration is disabled, so we can't create the user user here.
return;
}
// FIXME: implement rules.
// Try to get the avatar.
let avatar: string | undefined;
if (photos && photos.length > 0) {
avatar = photos[0].value;
}
// Try to get the email address.
let email: string | undefined;
let emailVerified: boolean | undefined;
if (emails && emails.length > 0) {
email = emails[0].value;
emailVerified = false;
}
user = await upsert(this.mongo, tenant, {
username: null,
displayName,
role: GQLUSER_ROLE.COMMENTER,
email,
email_verified: emailVerified,
avatar,
profiles: [profile],
});
}
return user;
}
private userAuthenticatedCallback = async (
req: Request,
accessToken: string,
refreshToken: string,
profile: Profile,
done: VerifyCallback
) => {
const { tenant } = req.talk!;
if (!tenant) {
// TODO: return a better error.
throw new Error("tenant not found");
}
// Get the integration from the tenant. If needed, it will be used to create
// a new strategy.
let integration: Required<GQLFacebookAuthIntegration>;
try {
integration = getEnabledIntegration(tenant);
} catch (err) {
// TODO: wrap error?
return done(err);
}
try {
const user = await findOrCreateFacebookUser(
this.mongo,
tenant,
integration,
profile
);
return done(null, user);
} catch (err) {
return done(err);
}
};
public authenticate(req: Request) {
try {
const { tenant } = req.talk!;
if (!tenant) {
// TODO: return a better error.
throw new Error("tenant not found");
}
// Get the integration (it will throw if the integration is not enabled).
const integration = getEnabledIntegration(tenant);
let strategy = this.cache.get(tenant.id);
if (!strategy) {
strategy = new FacebookPassportStrategy(
{
clientID: integration.clientID,
clientSecret: integration.clientSecret,
callbackURL: reconstructTenantURL(
this.config,
tenant,
"/api/tenant/auth/facebook/callback"
),
profileFields: ["id", "displayName", "photos", "email"],
enableProof: true,
passReqToCallback: true,
},
this.userAuthenticatedCallback
);
this.cache.set(tenant.id, strategy);
}
// Augment the strategy with the request method bindings.
strategy.error = this.error.bind(this);
strategy.fail = this.fail.bind(this);
strategy.pass = this.pass.bind(this);
strategy.redirect = this.redirect.bind(this);
strategy.success = this.success.bind(this);
strategy.authenticate(req, { session: false });
} catch (err) {
return this.error(err);
}
protected createStrategy(
tenant: Tenant,
integration: Required<GQLFacebookAuthIntegration>
) {
return new Strategy(
{
clientID: integration.clientID,
clientSecret: integration.clientSecret,
callbackURL: reconstructTenantURL(
this.config,
tenant,
"/api/tenant/auth/facebook/callback"
),
profileFields: ["id", "displayName", "photos", "email"],
enableProof: true,
passReqToCallback: true,
},
this.verifyCallback
);
}
}
@@ -0,0 +1,122 @@
import { Db } from "mongodb";
import { Strategy } from "passport-strategy";
import { Profile } from "passport";
import { VerifyCallback } from "passport-oauth2";
import { Config } from "talk-common/config";
import { GQLAuthIntegrations } from "talk-server/graph/tenant/schema/__generated__/types";
import { Tenant } from "talk-server/models/tenant";
import { User } from "talk-server/models/user";
import TenantCache from "talk-server/services/tenant/cache";
import { TenantCacheAdapter } from "talk-server/services/tenant/cache/adapter";
import { Request } from "talk-server/types/express";
interface OAuth2Integration {
enabled: boolean;
clientID?: string;
clientSecret?: string;
}
export interface OAuth2StrategyOptions {
config: Config;
mongo: Db;
tenantCache: TenantCache;
}
export default abstract class OAuth2Strategy<
T extends OAuth2Integration,
U extends Strategy
> extends Strategy {
protected config: Config;
protected mongo: Db;
protected cache: TenantCacheAdapter<U>;
constructor({ config, mongo, tenantCache }: OAuth2StrategyOptions) {
super();
this.config = config;
this.mongo = mongo;
this.cache = new TenantCacheAdapter(tenantCache);
}
protected abstract getIntegration(integrations: GQLAuthIntegrations): T;
protected abstract createStrategy(
tenant: Tenant,
integration: Required<T>
): U;
protected abstract findOrCreateUser(
tenant: Tenant,
integration: Required<T>,
profile: Profile
): Promise<User | undefined>;
protected verifyCallback = async (
req: Request,
accessToken: string,
refreshToken: string,
profile: Profile,
done: VerifyCallback
) => {
try {
// Talk is defined at this point.
const tenant = req.talk!.tenant!;
// Get the integration.
const integration = this.getIntegration(tenant.auth.integrations);
// Get the user.
const user = await this.findOrCreateUser(
tenant,
integration as Required<T>,
profile
);
return done(null, user);
} catch (err) {
return done(err);
}
};
public authenticate(req: Request) {
try {
// Talk is defined at this point.
const tenant = req.talk!.tenant!;
// Get the integration.
const integration = this.getIntegration(tenant.auth.integrations);
// Check to see if the integration is enabled.
if (!integration.enabled) {
// TODO: return a better error.
throw new Error("integration not enabled");
}
if (!integration.clientID) {
throw new Error("clientID is missing in configuration");
}
if (!integration.clientSecret) {
throw new Error("clientSecret is missing in configuration");
}
let strategy = this.cache.get(tenant.id);
if (!strategy) {
strategy = this.createStrategy(tenant, integration as Required<T>);
this.cache.set(tenant.id, strategy);
}
// Augment the strategy with the request method bindings.
strategy.error = this.error.bind(this);
strategy.fail = this.fail.bind(this);
strategy.pass = this.pass.bind(this);
strategy.redirect = this.redirect.bind(this);
strategy.success = this.success.bind(this);
strategy.authenticate(req, { session: false });
} catch (err) {
return this.error(err);
}
}
}