From caec45a0c68f8be53138885a1cb7480f03432742 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 17 Jul 2018 12:32:43 -0600 Subject: [PATCH] feat: passport impl --- .../app/middleware/passport/jwt.spec.ts | 65 +++++ .../server/app/middleware/passport/jwt.ts | 32 +++ .../app/middleware/passport/oidc.spec.ts | 87 +++++++ .../server/app/middleware/passport/oidc.ts | 56 ++++- .../app/middleware/passport/sso.spec.ts | 83 +++++++ .../server/app/middleware/passport/sso.ts | 223 +++++++++++++++++- src/core/server/app/router.ts | 1 + 7 files changed, 539 insertions(+), 8 deletions(-) create mode 100644 src/core/server/app/middleware/passport/jwt.spec.ts create mode 100644 src/core/server/app/middleware/passport/jwt.ts create mode 100644 src/core/server/app/middleware/passport/oidc.spec.ts create mode 100644 src/core/server/app/middleware/passport/sso.spec.ts diff --git a/src/core/server/app/middleware/passport/jwt.spec.ts b/src/core/server/app/middleware/passport/jwt.spec.ts new file mode 100644 index 000000000..22ea07405 --- /dev/null +++ b/src/core/server/app/middleware/passport/jwt.spec.ts @@ -0,0 +1,65 @@ +import sinon from "sinon"; + +import { + extractJWTFromRequest, + parseAuthHeader, +} from "talk-server/app/middleware/passport/jwt"; +import { Request } from "talk-server/types/express"; + +describe("parseAuthHeader", () => { + it("parses valid headers", () => { + const parsed = { + scheme: "bearer", + value: "token", + }; + + expect(parseAuthHeader("Bearer token")).toEqual(parsed); + + expect(parseAuthHeader("bearer token")).toEqual(parsed); + + expect(parseAuthHeader("bearer token")).toEqual(parsed); + }); + + it("parses invalid headers", () => { + expect(parseAuthHeader("this-is-a-wrong-header")).toEqual(null); + expect(parseAuthHeader("bearerthis-is-a-wrong-header")).toEqual(null); + }); +}); + +describe("extractJWTFromRequest", () => { + it("extracts the token from header", () => { + const req = { + get: sinon + .stub() + .withArgs("authorization") + .returns("Bearer token"), + }; + + expect(extractJWTFromRequest((req as any) as Request)).toEqual("token"); + expect(req.get.calledOnce).toBeTruthy(); + + req.get.reset(); + req.get.returns(null); + expect(extractJWTFromRequest((req as any) as Request)).toEqual(null); + expect(req.get.calledOnce).toBeTruthy(); + }); + + it("extracts the token from query string", () => { + const req = { + get: sinon + .stub() + .withArgs("authorization") + .returns(null), + query: { access_token: "token" }, + }; + + expect(extractJWTFromRequest((req as any) as Request)).toEqual("token"); + expect(req.get.calledOnce).toBeTruthy(); + + delete req.query.access_token; + + req.get.reset(); + expect(extractJWTFromRequest((req as any) as Request)).toEqual(null); + expect(req.get.calledOnce).toBeTruthy(); + }); +}); diff --git a/src/core/server/app/middleware/passport/jwt.ts b/src/core/server/app/middleware/passport/jwt.ts new file mode 100644 index 000000000..d0ec3d5ea --- /dev/null +++ b/src/core/server/app/middleware/passport/jwt.ts @@ -0,0 +1,32 @@ +import { Request } from "talk-server/types/express"; + +const re = /(\S+)\s+(\S+)/; + +export function parseAuthHeader(header: string) { + const matches = header.match(re); + if (!matches || matches.length < 3) { + return null; + } + + return { + scheme: matches[1].toLowerCase(), + value: matches[2], + }; +} + +export function extractJWTFromRequest(req: Request) { + const header = req.get("authorization"); + if (header) { + const parts = parseAuthHeader(header); + if (parts && parts.scheme === "bearer") { + return parts.value; + } + } + + const token: string | undefined | false = req.query && req.query.access_token; + if (token) { + return token; + } + + return null; +} diff --git a/src/core/server/app/middleware/passport/oidc.spec.ts b/src/core/server/app/middleware/passport/oidc.spec.ts new file mode 100644 index 000000000..6f10b1140 --- /dev/null +++ b/src/core/server/app/middleware/passport/oidc.spec.ts @@ -0,0 +1,87 @@ +import { + OIDCDisplayNameIDTokenSchema, + OIDCIDTokenSchema, +} from "talk-server/app/middleware/passport/oidc"; +import { validate } from "talk-server/app/request/body"; + +describe("OIDCIDTokenSchema", () => { + it("allows a valid payload", () => { + const token = { + sub: "sub", + iss: "iss", + aud: "aud", + email: "email", + email_verified: true, + }; + + expect(validate(OIDCIDTokenSchema, token)).toEqual(token); + }); + + it("allows an empty email_verified", () => { + const token = { + sub: "sub", + iss: "iss", + aud: "aud", + email: "email", + }; + + expect(validate(OIDCIDTokenSchema, token)).toEqual({ + ...token, + email_verified: false, + }); + }); + + it("allows an empty picture", () => { + const token = { + sub: "sub", + iss: "iss", + aud: "aud", + email: "email", + email_verified: true, + }; + + expect(validate(OIDCIDTokenSchema, token)).toEqual(token); + }); +}); + +describe("OIDCDisplayNameIDTokenSchema", () => { + it("allows a valid payload", () => { + const token = { + sub: "sub", + iss: "iss", + aud: "aud", + email: "email", + email_verified: true, + name: "name", + nickname: "nickname", + }; + + expect(validate(OIDCDisplayNameIDTokenSchema, token)).toEqual(token); + }); + + it("allows an empty name", () => { + const token = { + sub: "sub", + iss: "iss", + aud: "aud", + email: "email", + email_verified: false, + nickname: "nickname", + }; + + expect(validate(OIDCDisplayNameIDTokenSchema, token)).toEqual(token); + }); + + it("allows an empty nickname", () => { + const token = { + sub: "sub", + iss: "iss", + aud: "aud", + email: "email", + email_verified: false, + name: "name", + }; + + expect(validate(OIDCDisplayNameIDTokenSchema, token)).toEqual(token); + }); +}); diff --git a/src/core/server/app/middleware/passport/oidc.ts b/src/core/server/app/middleware/passport/oidc.ts index 07dd55543..35982e0a1 100644 --- a/src/core/server/app/middleware/passport/oidc.ts +++ b/src/core/server/app/middleware/passport/oidc.ts @@ -1,9 +1,11 @@ +import Joi from "joi"; import jwt from "jsonwebtoken"; import jwks, { JwksClient } from "jwks-rsa"; import { Db } from "mongodb"; import { Strategy as OAuth2Strategy, VerifyCallback } from "passport-oauth2"; import { Strategy } from "passport-strategy"; +import { validate } from "talk-server/app/request/body"; import { reconstructURL } from "talk-server/app/url"; import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types"; import { OIDCAuthIntegration, Tenant } from "talk-server/models/tenant"; @@ -28,6 +30,8 @@ export interface OIDCIDToken { email?: string; email_verified?: boolean; picture?: string; + name?: string; + nickname?: string; } export interface StrategyItem { @@ -95,17 +99,50 @@ function getEnabledIntegration(tenant: Tenant) { return integration; } +export const OIDCIDTokenSchema = Joi.object() + .keys({ + sub: Joi.string(), + iss: Joi.string(), + aud: Joi.string(), + email: Joi.string(), + email_verified: Joi.boolean().default(false), + picture: Joi.string().default(undefined), + }) + .optionalKeys(["picture", "email_verified"]); + +export const OIDCDisplayNameIDTokenSchema = OIDCIDTokenSchema.keys({ + name: Joi.string().default(undefined), + nickname: Joi.string().default(undefined), +}).optionalKeys(["name", "nickname"]); + export async function findOrCreateOIDCUser( db: Db, tenant: Tenant, token: OIDCIDToken ) { + // Unpack/validate the token content. + const { + sub, + iss, + aud, + email, + email_verified, + picture, + name, + nickname, + }: OIDCIDToken = validate( + tenant.auth.displayNameEnable + ? OIDCDisplayNameIDTokenSchema + : OIDCIDTokenSchema, + token + ); + // Construct the profile that will be used to query for the user. const profile: OIDCProfile = { type: "oidc", - id: token.sub, - issuer: token.iss, - audience: token.aud, + id: sub, + issuer: iss, + audience: aud, }; // Try to lookup user given their id provided in the `sub` claim. @@ -113,17 +150,24 @@ export async function findOrCreateOIDCUser( if (!user) { // FIXME: implement rules. + // Default the displayName. When it is disabled, Joi will strip the + // displayName fields from the token, so it will fallback to undefined. + const displayName = nickname || name || undefined; + // Create the new user, as one didn't exist before! user = await upsert(db, tenant, { username: null, + displayName, role: GQLUSER_ROLE.COMMENTER, - email: token.email, - email_verified: token.email_verified, - avatar: token.picture, + email, + email_verified, + avatar: picture, profiles: [profile], }); } + // TODO: (wyattjoh) possibly update the user profile if the remaining details mismatch? + return user; } diff --git a/src/core/server/app/middleware/passport/sso.spec.ts b/src/core/server/app/middleware/passport/sso.spec.ts new file mode 100644 index 000000000..f973f45b7 --- /dev/null +++ b/src/core/server/app/middleware/passport/sso.spec.ts @@ -0,0 +1,83 @@ +import { + isSSOToken, + SSODisplayNameUserProfileSchema, + SSOUserProfileSchema, +} from "talk-server/app/middleware/passport/sso"; +import { validate } from "talk-server/app/request/body"; + +describe("isSSOToken", () => { + it("understands valid sso tokens", () => { + const token = { user: { id: "id", email: "email", username: "username" } }; + expect(isSSOToken(token)).toBeTruthy(); + }); + + it("understands invalid sso tokens", () => { + expect(isSSOToken({ user: { id: "id", email: "email" } })).toBeFalsy(); + expect( + isSSOToken({ user: { id: "id", username: "username" } }) + ).toBeFalsy(); + expect( + isSSOToken({ user: { email: "email", username: "username" } }) + ).toBeFalsy(); + expect(isSSOToken({})).toBeFalsy(); + }); +}); + +describe("SSOUserProfileSchema", () => { + it("allows a valid payload", () => { + const profile = { + id: "id", + email: "email", + username: "username", + avatar: "avatar", + }; + + expect(validate(SSOUserProfileSchema, profile)).toEqual(profile); + }); + + it("allows an empty avatar", () => { + const profile = { + id: "id", + email: "email", + username: "username", + }; + + expect(validate(SSOUserProfileSchema, profile)).toEqual(profile); + }); +}); + +describe("SSODisplayNameUserProfileSchema", () => { + it("allows a valid payload", () => { + const profile = { + id: "id", + email: "email", + username: "username", + avatar: "avatar", + displayName: "displayName", + }; + + expect(validate(SSODisplayNameUserProfileSchema, profile)).toEqual(profile); + }); + + it("allows an empty avatar", () => { + const profile = { + id: "id", + email: "email", + username: "username", + displayName: "displayName", + }; + + expect(validate(SSODisplayNameUserProfileSchema, profile)).toEqual(profile); + }); + + it("allows an empty displayName", () => { + const profile = { + id: "id", + email: "email", + username: "username", + avatar: "avatar", + }; + + expect(validate(SSODisplayNameUserProfileSchema, profile)).toEqual(profile); + }); +}); diff --git a/src/core/server/app/middleware/passport/sso.ts b/src/core/server/app/middleware/passport/sso.ts index ecc883685..b5699114d 100644 --- a/src/core/server/app/middleware/passport/sso.ts +++ b/src/core/server/app/middleware/passport/sso.ts @@ -1,9 +1,228 @@ +import Joi from "joi"; +import jwt, { KeyFunctionCallback } from "jsonwebtoken"; +import { Db } from "mongodb"; import { Strategy } from "passport-strategy"; +import { extractJWTFromRequest } from "talk-server/app/middleware/passport/jwt"; +import { + findOrCreateOIDCUser, + isOIDCToken, + OIDCIDToken, +} from "talk-server/app/middleware/passport/oidc"; +import { validate } from "talk-server/app/request/body"; +import { GQLUSER_ROLE } 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 { Request } from "talk-server/types/express"; +export interface SSOStrategyOptions { + db: Db; +} + +export interface SSOUserProfile { + id: string; + email: string; + username: string; + avatar?: string; + displayName?: string; +} + +export interface SSOToken { + user: SSOUserProfile; +} + +export const SSOUserProfileSchema = Joi.object() + .keys({ + id: Joi.string(), + email: Joi.string(), + username: Joi.string(), + avatar: Joi.string().default(undefined), + }) + .optionalKeys(["avatar"]); + +export const SSODisplayNameUserProfileSchema = SSOUserProfileSchema.keys({ + displayName: Joi.string().default(undefined), +}).optionalKeys(["displayName"]); + +export async function findOrCreateSSOUser( + db: Db, + tenant: Tenant, + token: SSOToken +) { + if (!token.user) { + // TODO: (wyattjoh) replace with better error. + throw new Error("token is malformed, missing user claim"); + } + + // Unpack/validate the token content. + const { id, email, username, displayName, avatar }: SSOUserProfile = validate( + tenant.auth.displayNameEnable + ? SSODisplayNameUserProfileSchema + : SSOUserProfileSchema, + token.user + ); + + const profile: SSOProfile = { + type: "sso", + id, + }; + + // Try to lookup user given their id provided in the `sub` claim. + let user = await retrieveUserWithProfile(db, tenant.id, profile); + if (!user) { + // 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(db, tenant, { + username, + // When the displayName is disabled on the tenant, the displayName will + // never be set (or even stored in the database). + displayName, + role: GQLUSER_ROLE.COMMENTER, + email, + avatar, + profiles: [profile], + }); + } + + // TODO: (wyattjoh) possibly update the user profile if the remaining details mismatch? + + return user; +} + +/** + * isSSOUserProfile will check if the given profile is a SSOUserProfile. + * + * @param profile the profile to check for the type + */ +export function isSSOUserProfile( + profile: SSOUserProfile | object +): profile is SSOUserProfile { + return ( + typeof (profile as SSOUserProfile).id !== "undefined" && + typeof (profile as SSOUserProfile).email !== "undefined" && + typeof (profile as SSOUserProfile).username !== "undefined" + ); +} + +export function isSSOToken(token: SSOToken | object): token is SSOToken { + return ( + typeof (token as SSOToken).user === "object" && + isSSOUserProfile((token as SSOToken).user) + ); +} + export default class SSOStrategy extends Strategy { - public async authenticate(req: Request) { - return; + public name: string; + + private db: Db; + + constructor({ db }: SSOStrategyOptions) { + super(); + + this.name = "sso"; + this.db = db; + } + + /** + * retrieves the integration's secret to be used to verify the token. + */ + private getSigningSecretGetter = (tenant: Tenant) => async ( + headers: { kid?: string }, + done: KeyFunctionCallback + ) => { + const integration = tenant.auth.integrations.sso; + if (!integration) { + // TODO: (wyattjoh) return a better error. + return done(new Error("integration not found")); + } + + if (!integration.enabled) { + // TODO: (wyattjoh) return a better error. + return done(new Error("integration not enabled")); + } + + // TODO: (wyattjoh) do something with the kid... Lookup the secret or verify it matches what we have? + + return done(null, integration.key); + }; + + /** + * findOrCreateUser will interpret the token and use the correct strategy for + * retrieving/creating the user. + * + * @param tenant the tenant for the new/returning user + * @param token the token that was unpacked and validated from the sso strategy + */ + private async findOrCreateUser( + tenant: Tenant, + token: OIDCIDToken | SSOToken + ) { + if (isOIDCToken(token)) { + // The token provided for SSO contains an issuer claim. We're assuming + // that this request is associated with an OpenID Connect provider. + return findOrCreateOIDCUser(this.db, tenant, token); + } + + // Check to see if this token is a SSO Token or not, if it isn't error out. + if (!isSSOToken(token)) { + // TODO: (wyattjoh) return a better error. + throw new Error("token is invalid"); + } + + // The token provided does not confirm to the OpenID Connect provider + // spec, but id does conform to a SSOToken so we should expect the token to + // contain the user profile. + return findOrCreateSSOUser(this.db, tenant, token); + } + + /** + * wrapNewTokenHandler wraps the token handling with a promise. + */ + private wrapNewTokenHandler = (tenant: Tenant) => async ( + err: Error | undefined, + decoded: OIDCIDToken | SSOToken + ) => { + if (err) { + return this.fail(err, 401); + } + + try { + // Find or create the user based on the decoded token. + const user = await this.findOrCreateUser(tenant, decoded); + + // The user was found or created! + return this.success(user, null); + } catch (err) { + return this.error(err); + } + }; + + public authenticate(req: Request) { + const { tenant } = req; + if (!tenant) { + // TODO: (wyattjoh) return a better error. + return this.error(new Error("tenant not found")); + } + + // Lookup the token. + const token = extractJWTFromRequest(req); + if (!token) { + // TODO: (wyattjoh) return a better error. + return this.fail(new Error("no token on request"), 400); + } + + // Perform the JWT validation. + jwt.verify( + token, + this.getSigningSecretGetter(tenant), + { + // Force the use of the HS256 algorithm. We can explore switching this + // out in the future.. + algorithms: ["HS256"], // TODO: (wyattjoh) investigate replacing algorithm. + }, + this.wrapNewTokenHandler(tenant) + ); } } diff --git a/src/core/server/app/router.ts b/src/core/server/app/router.ts index ad862d574..b6af1564f 100644 --- a/src/core/server/app/router.ts +++ b/src/core/server/app/router.ts @@ -64,6 +64,7 @@ function createNewAuthRouter(app: AppOptions, options: RouterOptions) { express.json(), signupHandler({ db: app.mongo }) ); + router.post("/sso", authenticate(options.passport, "sso")); router.get("/oidc", authenticate(options.passport, "oidc")); router.get("/oidc/callback", authenticate(options.passport, "oidc")); // router.get("/google", options.passport.authenticate("google"));