mirror of
https://github.com/wassname/talk.git
synced 2026-08-18 12:30:39 +08:00
[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:
@@ -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", () => {
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user