[CORL-437] SSO Token Documentation + Updates (#2390)

* feat: updated README, added more SSO functionality

* fix: lint

* fix: lint

* fix: lint

* fix: typos
This commit is contained in:
Wyatt Johnson
2019-07-05 21:49:41 +00:00
committed by GitHub
parent 0754ceb803
commit da1fa9c9fc
12 changed files with 2018 additions and 1807 deletions
@@ -19,6 +19,7 @@ it("renders fully", () => {
},
accessToken: "access-token",
accessTokenJTI: "JTI",
accessTokenExp: 1562172094,
},
viewer: null,
settings: {
@@ -77,6 +78,7 @@ it("renders without logout button", () => {
},
accessToken: "access-token",
accessTokenJTI: null,
accessTokenExp: null,
},
viewer: null,
settings: {
@@ -135,6 +137,7 @@ it("renders sso only", () => {
},
accessToken: "access-token",
accessTokenJTI: "JTI",
accessTokenExp: 1562172094,
},
viewer: null,
settings: {
@@ -193,6 +196,7 @@ it("renders sso only without logout button", () => {
},
accessToken: "access-token",
accessTokenJTI: "JTI",
accessTokenExp: 1562172094,
},
viewer: null,
settings: {
@@ -251,6 +255,7 @@ it("renders without register button", () => {
},
accessToken: "access-token",
accessTokenJTI: "JTI",
accessTokenExp: 1562172094,
},
viewer: null,
settings: {
@@ -46,7 +46,9 @@ export class UserBoxContainer extends Component<Props> {
private get supportsLogout() {
return Boolean(
!this.props.local.accessToken || this.props.local.accessTokenJTI
!this.props.local.accessToken ||
(this.props.local.accessTokenJTI !== null &&
this.props.local.accessTokenExp !== null)
);
}
@@ -129,6 +131,7 @@ const enhanced = withSignOutMutation(
}
accessToken
accessTokenJTI
accessTokenExp
}
`
)(
@@ -59,14 +59,16 @@ export function createPassport(
}
interface LogoutToken {
jti: string;
exp: number;
jti?: string;
exp?: number;
}
const LogoutTokenSchema = Joi.object().keys({
jti: Joi.string(),
exp: Joi.number(),
});
const LogoutTokenSchema = Joi.object()
.keys({
jti: Joi.string().default(undefined),
exp: Joi.number().default(undefined),
})
.optionalKeys(["jti", "exp"]);
export async function handleLogout(redis: Redis, req: Request, res: Response) {
// Extract the token from the request.
@@ -88,13 +90,14 @@ export async function handleLogout(redis: Redis, req: Request, res: Response) {
// Grab the JTI from the decoded token.
const { jti, exp }: LogoutToken = validate(LogoutTokenSchema, decoded);
// Compute the number of seconds that the token will be valid for.
const validFor = exp - now.valueOf() / 1000;
if (validFor > 0) {
// Invalidate the token, the expiry is in the future and it needs to be
// revoked.
await revokeJWT(redis, jti, validFor, now);
if (jti && exp) {
// Compute the number of seconds that the token will be valid for.
const validFor = exp - now.valueOf() / 1000;
if (validFor > 0) {
// Invalidate the token, the expiry is in the future and it needs to be
// revoked.
await revokeJWT(redis, jti, validFor, now);
}
}
// Clear the cookie.
@@ -28,55 +28,6 @@ describe("SSOUserProfileSchema", () => {
id: "id",
email: "email",
username: "username",
avatar: "avatar",
};
expect(validate(SSOUserProfileSchema, profile)).toEqual(profile);
expect(isSSOToken({ user: profile })).toEqual(true);
});
it("allows an empty avatar", () => {
const profile = {
id: "id",
email: "email",
username: "username",
};
expect(validate(SSOUserProfileSchema, profile)).toEqual(profile);
expect(isSSOToken({ user: profile })).toEqual(true);
});
it("allows a valid payload", () => {
const profile = {
id: "id",
email: "email",
username: "username",
avatar: "avatar",
displayName: "displayName",
};
expect(validate(SSOUserProfileSchema, profile)).toEqual(profile);
expect(isSSOToken({ user: profile })).toEqual(true);
});
it("allows an empty avatar", () => {
const profile = {
id: "id",
email: "email",
username: "username",
displayName: "displayName",
};
expect(validate(SSOUserProfileSchema, profile)).toEqual(profile);
expect(isSSOToken({ user: profile })).toEqual(true);
});
it("allows an empty displayName", () => {
const profile = {
id: "id",
email: "email",
username: "username",
avatar: "avatar",
};
expect(validate(SSOUserProfileSchema, profile)).toEqual(profile);
@@ -9,13 +9,24 @@ import {
GQLUSER_ROLE,
} from "coral-server/graph/tenant/schema/__generated__/types";
import { Tenant } from "coral-server/models/tenant";
import { retrieveUserWithProfile, SSOProfile } from "coral-server/models/user";
import {
retrieveUserWithProfile,
SSOProfile,
updateUserFromSSO,
} from "coral-server/models/user";
import { insert } from "coral-server/services/users";
import {
getSSOProfile,
needsSSOUpdate,
} from "coral-server/models/user/helpers";
import {
isJWTRevoked,
SymmetricSigningAlgorithm,
verifyJWT,
} from "coral-server/services/jwt";
import { AugmentedRedis } from "coral-server/services/redis";
import { DateTime } from "luxon";
import { Verifier } from "../jwt";
export interface SSOStrategyOptions {
@@ -26,29 +37,40 @@ export interface SSOUserProfile {
id: string;
email: string;
username: string;
avatar?: string;
}
export interface SSOToken {
jti?: string;
exp?: number;
iat?: number;
user: SSOUserProfile;
}
export const SSOUserProfileSchema = Joi.object()
.keys({
id: Joi.string().required(),
email: Joi.string().required(),
username: Joi.string().required(),
avatar: Joi.string().default(undefined),
displayName: Joi.string().default(undefined),
})
.optionalKeys(["avatar", "displayName"]);
export function isSSOToken(token: SSOToken | object): token is SSOToken {
const { error } = Joi.validate(token, SSOTokenSchema);
return isNil(error);
}
export const SSOTokenSchema = Joi.object().keys({
user: SSOUserProfileSchema.required(),
export const SSOUserProfileSchema = Joi.object().keys({
id: Joi.string().required(),
email: Joi.string()
.lowercase()
.required(),
username: Joi.string().required(),
});
export const SSOTokenSchema = Joi.object()
.keys({
jti: Joi.string().default(undefined),
exp: Joi.number().default(undefined),
iat: Joi.number().default(undefined),
user: SSOUserProfileSchema.required(),
})
.optionalKeys(["jti", "exp", "iat"]);
export async function findOrCreateSSOUser(
mongo: Db,
redis: AugmentedRedis,
tenant: Tenant,
integration: GQLSSOAuthIntegration,
token: SSOToken,
@@ -59,19 +81,31 @@ export async function findOrCreateSSOUser(
throw new Error("token is malformed, missing user claim");
}
// Unpack/validate the token content.
const { id, email, username, avatar }: SSOUserProfile = validate(
SSOUserProfileSchema,
token.user
);
// Validate the token content.
const decodedToken: SSOToken = validate(SSOTokenSchema, token);
const profile: SSOProfile = {
type: "sso",
id,
};
// Unpack the token.
const {
jti,
exp,
user: { id, email, username },
iat,
} = decodedToken;
// If the token has a JTI and EXP claim, then it can be logged out. Check to
// see if it was revoked.
if (jti && exp && (await isJWTRevoked(redis, jti))) {
return null;
}
// Compute the last issued at time stamp.
const lastIssuedAt = iat ? DateTime.fromSeconds(iat).toJSDate() : now;
// Try to lookup user given their id provided in the `sub` claim.
let user = await retrieveUserWithProfile(mongo, tenant.id, profile);
let user = await retrieveUserWithProfile(mongo, tenant.id, {
type: "sso",
id,
});
if (!user) {
if (!integration.allowRegistration) {
// Registration is disabled, so we can't create the user user here.
@@ -80,6 +114,12 @@ export async function findOrCreateSSOUser(
// FIXME: (wyattjoh) implement rules! Not all users should be able to create an account via this method.
const profile: SSOProfile = {
type: "sso",
id,
lastIssuedAt,
};
// Create the new user, as one didn't exist before!
user = await insert(
mongo,
@@ -88,32 +128,42 @@ export async function findOrCreateSSOUser(
username,
role: GQLUSER_ROLE.COMMENTER,
email,
avatar,
profiles: [profile],
},
now
);
} else if (iat && needsSSOUpdate(decodedToken.user, user)) {
// Get the SSO Profile.
const profile = getSSOProfile(user);
if (profile && profile.lastIssuedAt < lastIssuedAt) {
// The token presented to us has a newer issue date than the one
// associated with this profile, we should update the user with new
// details.
user = await updateUserFromSSO(
mongo,
tenant.id,
user.id,
{ email, username },
lastIssuedAt
);
}
}
// TODO: (wyattjoh) possibly update the user profile if the remaining details mismatch?
return user;
}
export function isSSOToken(token: SSOToken | object): token is SSOToken {
const { error } = Joi.validate(token, SSOTokenSchema);
return isNil(error);
}
export interface SSOVerifierOptions {
mongo: Db;
redis: AugmentedRedis;
}
export class SSOVerifier implements Verifier<SSOToken> {
private mongo: Db;
private redis: AugmentedRedis;
constructor({ mongo }: SSOVerifierOptions) {
constructor({ mongo, redis }: SSOVerifierOptions) {
this.mongo = mongo;
this.redis = redis;
}
public supports(token: SSOToken | object, tenant: Tenant): token is SSOToken {
@@ -147,6 +197,13 @@ export class SSOVerifier implements Verifier<SSOToken> {
now
);
return findOrCreateSSOUser(this.mongo, tenant, integration, token, now);
return findOrCreateSSOUser(
this.mongo,
this.redis,
tenant,
integration,
token,
now
);
}
}
@@ -1,5 +1,5 @@
import { LocalProfile, SSOProfile } from "coral-server/models/user";
import { getLocalProfile, hasLocalProfile } from "./users";
import { getLocalProfile, hasLocalProfile } from "./helpers";
const localProfile: LocalProfile = {
type: "local",
@@ -10,6 +10,7 @@ const localProfile: LocalProfile = {
const ssoProfile: SSOProfile = {
type: "sso",
id: "sso-id",
lastIssuedAt: new Date(0),
};
it("will get the local profile", () => {
+52 -2
View File
@@ -1,7 +1,7 @@
import { GQLUSER_ROLE } from "coral-server/graph/tenant/schema/__generated__/types";
import { STAFF_ROLES } from "coral-server/models/user/constants";
import { User } from ".";
import { STAFF_ROLES } from "./constants";
import { LocalProfile, SSOProfile, User } from "./user";
export function roleIsStaff(role: GQLUSER_ROLE) {
if (STAFF_ROLES.includes(role)) {
@@ -14,3 +14,53 @@ export function roleIsStaff(role: GQLUSER_ROLE) {
export function userIsStaff(user: Pick<User, "role">) {
return roleIsStaff(user.role);
}
export function getSSOProfile(user: Pick<User, "profiles">) {
return user.profiles.find(profile => profile.type === "sso") as
| SSOProfile
| undefined;
}
export function needsSSOUpdate(
token: Pick<User, "email" | "username">,
user: Pick<User, "email" | "username">
) {
return user.email !== token.email || user.username !== token.username;
}
/**
* getLocalProfile will get the LocalProfile from the User if it exists.
*
* @param user the User to pull the LocalProfile out of
*/
export function getLocalProfile(
user: Pick<User, "profiles">
): LocalProfile | undefined {
return user.profiles.find(({ type }) => type === "local") as
| LocalProfile
| undefined;
}
/**
* hasLocalProfile will return true if the User has a LocalProfile, optionally
* checking the email on it as well.
*
* @param user the User to pull the LocalProfile out of
* @param withEmail when specified, will ensure that the LocalProfile has the
* specific email provided
*/
export function hasLocalProfile(
user: Pick<User, "profiles">,
withEmail?: string
): boolean {
const profile = getLocalProfile(user);
if (!profile) {
return false;
}
if (withEmail && profile.id !== withEmail) {
return false;
}
return true;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -10,7 +10,6 @@ import {
TokenInvalidError,
UserNotFoundError,
} from "coral-server/errors";
import { getLocalProfile } from "coral-server/helpers/users";
import { Tenant } from "coral-server/models/tenant";
import {
createOrRetrieveUserPasswordResetID,
@@ -18,6 +17,7 @@ import {
retrieveUser,
User,
} from "coral-server/models/user";
import { getLocalProfile } from "coral-server/models/user/helpers";
import {
JWTSigningConfig,
signString,
+4 -1
View File
@@ -14,7 +14,6 @@ import {
UserNotFoundError,
} from "coral-server/errors";
import { GQLUSER_ROLE } from "coral-server/graph/tenant/schema/__generated__/types";
import { getLocalProfile, hasLocalProfile } from "coral-server/helpers/users";
import { Tenant } from "coral-server/models/tenant";
import {
banUser,
@@ -40,6 +39,10 @@ import {
updateUserUsername,
User,
} from "coral-server/models/user";
import {
getLocalProfile,
hasLocalProfile,
} from "coral-server/models/user/helpers";
import { userIsStaff } from "coral-server/models/user/helpers";
import { MailerQueue } from "coral-server/queue/tasks/mailer";
import { JWTSigningConfig, signPATString } from "coral-server/services/jwt";