mirror of
https://github.com/wassname/talk.git
synced 2026-07-29 11:28:24 +08:00
feat: passport impl
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
Reference in New Issue
Block a user