feat: passport impl

This commit is contained in:
Wyatt Johnson
2018-07-17 12:32:43 -06:00
parent 6f6bedb07a
commit caec45a0c6
7 changed files with 539 additions and 8 deletions
@@ -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();
});
});
@@ -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;
}
@@ -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);
});
});
@@ -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;
}
@@ -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);
});
});
+221 -2
View File
@@ -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)
);
}
}
+1
View File
@@ -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"));