[next] Auth (#2257)

* feat: improved auth features + performance

* fix: auth check logic

* fix: tests
This commit is contained in:
Wyatt Johnson
2019-04-15 19:46:55 +02:00
committed by Kiwi
parent 3b31e3b02d
commit b63c00f26f
18 changed files with 245 additions and 306 deletions
@@ -14,7 +14,7 @@ import {
FacebookProfile,
retrieveUserWithProfile,
} from "talk-server/models/user";
import { upsert } from "talk-server/services/users";
import { insert } from "talk-server/services/users";
export type FacebookStrategyOptions = OAuth2StrategyOptions;
@@ -71,7 +71,7 @@ export default class FacebookStrategy extends OAuth2Strategy<
emailVerified = false;
}
user = await upsert(this.mongo, tenant, {
user = await insert(this.mongo, tenant, {
username: displayName,
role: GQLUSER_ROLE.COMMENTER,
email,
@@ -14,7 +14,7 @@ import {
GoogleProfile,
retrieveUserWithProfile,
} from "talk-server/models/user";
import { upsert } from "talk-server/services/users";
import { insert } from "talk-server/services/users";
export type GoogleStrategyOptions = OAuth2StrategyOptions;
@@ -70,7 +70,7 @@ export default class GoogleStrategy extends OAuth2Strategy<
emailVerified = false;
}
user = await upsert(this.mongo, tenant, {
user = await insert(this.mongo, tenant, {
username: displayName,
role: GQLUSER_ROLE.COMMENTER,
email,
@@ -2,35 +2,31 @@ import jwt from "jsonwebtoken";
import { Strategy } from "passport-strategy";
import { AppOptions } from "talk-server/app";
import {
JWTToken,
JWTVerifier,
} from "talk-server/app/middleware/passport/strategies/verifiers/jwt";
import {
SSOToken,
SSOVerifier,
} from "talk-server/app/middleware/passport/strategies/verifiers/sso";
import { TenantNotFoundError, TokenInvalidError } from "talk-server/errors";
import { Tenant } from "talk-server/models/tenant";
import { User } from "talk-server/models/user";
import { extractJWTFromRequest } from "talk-server/services/jwt";
import { Request } from "talk-server/types/express";
import { JWTToken, JWTVerifier } from "./verifiers/jwt";
import { OIDCIDToken, OIDCVerifier } from "./verifiers/oidc";
import { SSOToken, SSOVerifier } from "./verifiers/sso";
export type JWTStrategyOptions = Pick<
AppOptions,
"signingConfig" | "mongo" | "redis"
"signingConfig" | "mongo" | "redis" | "tenantCache"
>;
/**
* Token is the various forms of the Token that can be verified.
*/
type Token = SSOToken | JWTToken | object | string | null;
type Token = OIDCIDToken | SSOToken | JWTToken | object | string | null;
/**
* Verifier allows different implementations to offer ways to verify a given
* Token.
*/
interface Verifier<T> {
export interface Verifier<T = Token> {
/**
* verify will perform the verification and return a User.
*/
@@ -50,18 +46,16 @@ interface Verifier<T> {
export class JWTStrategy extends Strategy {
public name = "jwt";
private verifiers: {
sso: Verifier<SSOToken>;
jwt: Verifier<JWTToken>;
};
private verifiers: Verifier[];
constructor(options: JWTStrategyOptions) {
super();
this.verifiers = {
sso: new SSOVerifier(options),
jwt: new JWTVerifier(options),
};
this.verifiers = [
new OIDCVerifier(options),
new SSOVerifier(options),
new JWTVerifier(options),
];
}
private async verify(tokenString: string, tenant: Tenant) {
@@ -70,22 +64,11 @@ export class JWTStrategy extends Strategy {
throw new TokenInvalidError(tokenString, "token could not be decoded");
}
// TODO: add OIDC support.
// At the moment, OpenID Connect tokens are not supported here directly,
// instead, the default implementation redirects the user to the
// authorization endpoint where they login, and a redirection occurs
// yielding the token to us via the Authorization Code Flow. We then issue a
// Talk Token for that request, that the client uses after.
// Handle SSO integrations.
if (this.verifiers.sso.supports(token, tenant)) {
return this.verifiers.sso.verify(tokenString, token, tenant);
}
// Handle the raw JWT token.
if (this.verifiers.jwt.supports(token, tenant)) {
// Verify the token with the JWT verification strategy.
return this.verifiers.jwt.verify(tokenString, token, tenant);
// Try to verify the token.
for (const verifier of this.verifiers) {
if (verifier.supports(token, tenant)) {
return verifier.verify(tokenString, token, tenant);
}
}
// No verifier could be found.
@@ -7,15 +7,18 @@ import { Strategy } from "passport-strategy";
import { validate } from "talk-server/app/request/body";
import { reconstructURL } from "talk-server/app/url";
import {
GQLOIDCAuthIntegration,
GQLUSER_ROLE,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types";
import logger from "talk-server/logger";
import { OIDCAuthIntegration } from "talk-server/models/settings";
import { Tenant } from "talk-server/models/tenant";
import { OIDCProfile, retrieveUserWithProfile } from "talk-server/models/user";
import {
OIDCProfile,
retrieveUserWithProfile,
User,
} 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 { insert } from "talk-server/services/users";
import { Request } from "talk-server/types/express";
export interface Params {
@@ -85,11 +88,9 @@ const signingKeyFactory = (client: jwks.JwksClient): jwt.KeyFunction => (
});
};
function getEnabledIntegration(
tenant: Tenant
): Required<GQLOIDCAuthIntegration> {
// Grab the OIDC Integration.
const integration = tenant.auth.integrations.oidc;
export function getEnabledIntegration(
integration: OIDCAuthIntegration
): Required<OIDCAuthIntegration> {
if (!integration.enabled) {
// TODO: return a better error.
throw new Error("integration not enabled");
@@ -109,7 +110,7 @@ function getEnabledIntegration(
}
// TODO: (wyattjoh) for some reason, type guards above to not allow coercion to this required type.
return integration as Required<GQLOIDCAuthIntegration>;
return integration as Required<OIDCAuthIntegration>;
}
export const OIDCIDTokenSchema = Joi.object()
@@ -135,9 +136,9 @@ export const OIDCIDTokenSchema = Joi.object()
export async function findOrCreateOIDCUser(
mongo: Db,
tenant: Tenant,
integration: GQLOIDCAuthIntegration,
integration: OIDCAuthIntegration,
token: OIDCIDToken
) {
): Promise<Readonly<User> | null> {
// Unpack/validate the token content.
const {
sub,
@@ -160,15 +161,11 @@ export async function findOrCreateOIDCUser(
};
// Try to lookup user given their id provided in the `sub` claim.
let user = await retrieveUserWithProfile(mongo, tenant.id, {
// NOTE: (wyattjoh) as the current requirements do not allow multiple OIDC integrations, we are only getting the profile based on the OIDC provider.
type: "oidc",
id: sub,
});
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;
return null;
}
// FIXME: implement rules.
@@ -177,7 +174,7 @@ export async function findOrCreateOIDCUser(
const username = preferred_username || nickname || name;
// Create the new user, as one didn't exist before!
user = await upsert(mongo, tenant, {
user = await insert(mongo, tenant, {
username,
role: GQLUSER_ROLE.COMMENTER,
email,
@@ -192,6 +189,47 @@ export async function findOrCreateOIDCUser(
return user;
}
export function findOrCreateOIDCUserWithToken(
mongo: Db,
tenant: Tenant,
client: JwksClient,
integration: OIDCAuthIntegration,
token: string
) {
return new Promise<Readonly<User> | null>((resolve, reject) => {
logger.trace({ tenantID: tenant.id }, "verifying oidc id_token");
jwt.verify(
token,
signingKeyFactory(client),
{
issuer: integration.issuer,
},
async (err, decoded) => {
logger.trace(
{ tenantID: tenant.id },
"finished verifying oidc id_token"
);
if (err) {
// TODO: wrap error?
return reject(err);
}
try {
const user = await findOrCreateOIDCUser(
mongo,
tenant,
integration,
decoded as OIDCIDToken
);
return resolve(user);
} catch (err) {
return reject(err);
}
}
);
});
}
/**
* OIDC_SCOPE is the set of scopes requested for users signing up via OIDC.
*/
@@ -218,7 +256,7 @@ export default class OIDCStrategy extends Strategy {
private lookupJWKSClient(
req: Request,
tenantID: string,
oidc: Required<GQLOIDCAuthIntegration>
oidc: Required<OIDCAuthIntegration>
): jwks.JwksClient {
let tenantIntegration = this.cache.get(tenantID);
if (!tenantIntegration) {
@@ -249,7 +287,7 @@ export default class OIDCStrategy extends Strategy {
return tenantIntegration.jwksClient;
}
private userAuthenticatedCallback = (
private userAuthenticatedCallback = async (
req: Request,
accessToken: string, // ignore the access token, we don't use it.
refreshToken: string, // ignore the refresh token, we don't use it.
@@ -274,9 +312,9 @@ export default class OIDCStrategy extends Strategy {
// Get the integration from the tenant. If needed, it will be used to create
// a new strategy.
let integration: Required<GQLOIDCAuthIntegration>;
let integration: Required<OIDCAuthIntegration>;
try {
integration = getEnabledIntegration(tenant);
integration = getEnabledIntegration(tenant.auth.integrations.oidc);
} catch (err) {
// TODO: wrap error?
return done(err);
@@ -286,36 +324,23 @@ export default class OIDCStrategy extends Strategy {
const client = this.lookupJWKSClient(req, tenant.id, integration);
// Verify that the id_token is valid or not.
jwt.verify(
id_token,
signingKeyFactory(client),
{
issuer: integration.issuer,
},
async (err, decoded) => {
if (err) {
// TODO: wrap error?
return done(err);
}
try {
const user = await findOrCreateOIDCUser(
this.mongo,
tenant,
integration,
decoded as OIDCIDToken
);
return done(null, user);
} catch (err) {
return done(err);
}
}
);
try {
const user = await findOrCreateOIDCUserWithToken(
this.mongo,
tenant,
client,
integration,
id_token
);
return done(null, user || undefined);
} catch (err) {
return done(err);
}
};
private createStrategy(
req: Request,
integration: Required<GQLOIDCAuthIntegration>
integration: Required<OIDCAuthIntegration>
): OAuth2Strategy {
const { clientID, clientSecret, authorizationURL, tokenURL } = integration;
@@ -346,7 +371,7 @@ export default class OIDCStrategy extends Strategy {
// Get the integration from the tenant. If needed, it will be used to create
// a new strategy.
const integration = getEnabledIntegration(tenant);
const integration = getEnabledIntegration(tenant.auth.integrations.oidc);
// Try to get the Tenant's cached integrations.
let tenantIntegration = this.cache.get(tenant.id);
@@ -8,6 +8,8 @@ import { Tenant } from "talk-server/models/tenant";
import { retrieveUser } from "talk-server/models/user";
import { checkJWTRevoked, JWTSigningConfig } from "talk-server/services/jwt";
import { Verifier } from "../jwt";
export interface JWTToken {
/**
* jti is the Token identifier. With normal login tokens, this is a randomly
@@ -72,7 +74,7 @@ export interface JWTVerifierOptions {
redis: Redis;
}
export class JWTVerifier {
export class JWTVerifier implements Verifier<JWTToken> {
private signingConfig: JWTSigningConfig;
private mongo: Db;
private redis: Redis;
@@ -0,0 +1,69 @@
import jwks, { JwksClient } from "jwks-rsa";
import { Db } from "mongodb";
import { AppOptions } from "talk-server/app";
import { Tenant } from "talk-server/models/tenant";
import { TenantCacheAdapter } from "talk-server/services/tenant/cache/adapter";
import logger from "talk-server/logger";
import { Verifier } from "../jwt";
import {
findOrCreateOIDCUserWithToken,
getEnabledIntegration,
isOIDCToken,
OIDCIDToken,
} from "../oidc";
export type OIDCIDToken = OIDCIDToken;
export type OIDCVerifierOptions = Pick<
AppOptions,
"mongo" | "redis" | "tenantCache"
>;
export class OIDCVerifier implements Verifier<OIDCIDToken> {
private mongo: Db;
private cache: TenantCacheAdapter<JwksClient>;
constructor({ mongo, tenantCache }: OIDCVerifierOptions) {
this.mongo = mongo;
this.cache = new TenantCacheAdapter(tenantCache);
}
public async verify(tokenString: string, token: OIDCIDToken, tenant: Tenant) {
// Ensure that the integration is enabled.
const integration = getEnabledIntegration(tenant.auth.integrations.oidc);
// Grab the JWKS client to verify the SSO ID token.
let client = this.cache.get(tenant.id);
if (!client) {
logger.trace({ tenantID: tenant.id }, "jwks client not cached");
client = jwks({
jwksUri: integration.jwksURI,
});
this.cache.set(tenant.id, client);
} else {
logger.trace({ tenantID: tenant.id }, "jwks client cached");
}
return findOrCreateOIDCUserWithToken(
this.mongo,
tenant,
client,
integration,
tokenString
);
}
public supports(
token: OIDCIDToken | object,
tenant: Tenant
): token is OIDCIDToken {
return (
tenant.auth.integrations.oidc.enabled &&
Boolean(tenant.auth.integrations.oidc.jwksURI) &&
isOIDCToken(token)
);
}
}
@@ -9,7 +9,9 @@ import {
} from "talk-server/graph/tenant/schema/__generated__/types";
import { Tenant } from "talk-server/models/tenant";
import { retrieveUserWithProfile, SSOProfile } from "talk-server/models/user";
import { upsert } from "talk-server/services/users";
import { insert } from "talk-server/services/users";
import { Verifier } from "../jwt";
export interface SSOStrategyOptions {
mongo: Db;
@@ -69,7 +71,7 @@ export async function findOrCreateSSOUser(
// FIXME: (wyattjoh) implement rules! Not all users should be able to create an account via this method.
// Create the new user, as one didn't exist before!
user = await upsert(mongo, tenant, {
user = await insert(mongo, tenant, {
username,
role: GQLUSER_ROLE.COMMENTER,
email,
@@ -109,7 +111,7 @@ export interface SSOVerifierOptions {
mongo: Db;
}
export class SSOVerifier {
export class SSOVerifier implements Verifier<SSOToken> {
private mongo: Db;
constructor({ mongo }: SSOVerifierOptions) {