mirror of
https://github.com/wassname/talk.git
synced 2026-07-31 12:50:48 +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:
@@ -21,6 +21,7 @@ Preview Coral easily by running Coral via a Heroku App:
|
||||
- [Source](#source)
|
||||
- [Development](#development)
|
||||
- [Embed On Your Site](#embed-on-your-site)
|
||||
- [Single Sign On](#single-sign-on)
|
||||
- [Email](#email)
|
||||
- [Design Language System (UI Components)](#design-language-system-ui-components)
|
||||
- [Configuration](#configuration)
|
||||
@@ -185,6 +186,7 @@ npm run test
|
||||
```
|
||||
|
||||
#### Embed On Your Site
|
||||
|
||||
With Coral setup and running locally you can test embeding the comment stream with this sample embed script:
|
||||
|
||||
```
|
||||
@@ -205,7 +207,91 @@ With Coral setup and running locally you can test embeding the comment stream wi
|
||||
})();
|
||||
</script>
|
||||
```
|
||||
Replace the value of CORAL_DOMAIN_NAME with the location of your running instance of Coral.
|
||||
|
||||
> **NOTE:** Replace the value of `{{ CORAL_DOMAIN_NAME }}` with the location of your running instance of Coral.
|
||||
|
||||
#### Single Sign On
|
||||
|
||||
In order to allow seamless connection to an existing authentication system,
|
||||
Coral utilizes the industry standard [JWT Token](https://jwt.io/) to connect. To
|
||||
learn more about how to create a JWT token, see [this introduction](https://jwt.io/introduction/).
|
||||
|
||||
1. Visit: `https://{{ CORAL_DOMAIN_NAME }}/admin/configure/auth`
|
||||
2. Scroll to the `Login with Single Sign On` section
|
||||
3. Enable the Single Sign On Authentication Integration
|
||||
4. Enable `Allow Registration`
|
||||
5. Copy the string in the `Key` box
|
||||
6. Click Save
|
||||
|
||||
> **NOTE:** Replace the value of `{{ CORAL_DOMAIN_NAME }}` with the location of your running instance of Coral.
|
||||
|
||||
You will then have to generate a JWT with the following claims:
|
||||
|
||||
- `jti` (_optional_) - A unique ID for this particular JWT token. We recommend
|
||||
using a [UUID](https://en.wikipedia.org/wiki/Universally_unique_identifier)
|
||||
for this value. Without this parameter, the logout functionality inside the
|
||||
embed stream will not work and you will need to call logout on the embed
|
||||
itself.
|
||||
- `exp` (_optional_) - When the given SSO token should expire. This is
|
||||
specified as a unix time stamp in seconds. Once the token has expired, a new
|
||||
token should be generated and passed into Coral. Without this parameter, the
|
||||
logout functionality inside the embed stream will not work and you will need
|
||||
to call logout on the embed itself.
|
||||
- `iat` (_optional_) - When the given SSO token was issued. This is required to
|
||||
utilize the automatic user detail update system. If this time is newer than
|
||||
the time we received the last update, the contents of the token will be used
|
||||
to update the user.
|
||||
- `user.id` (**required**) - the ID of the user from your authentication system.
|
||||
This is required to connect the user in your system to allow a seamless
|
||||
connection to Coral.
|
||||
- `user.email` (**required**) - the email address of the user from your
|
||||
authentication system. This is required to facilitate notification email's
|
||||
about status changes on a user account such as bans or suspensions.
|
||||
- `user.username` (**required**) - the username that should be used when being
|
||||
presented inside Coral to moderators and other users.
|
||||
|
||||
An example of the claims for this token would be:
|
||||
|
||||
```json
|
||||
{
|
||||
"jti": "151c19fc-ad15-4f80-a49c-09f137789fbb",
|
||||
"exp": 1572172094,
|
||||
"iat": 1562172094,
|
||||
"user": {
|
||||
"id": "628bdc61-6616-4add-bfec-dd79156715d4",
|
||||
"email": "bob@example.com",
|
||||
"username": "bob"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
With the claims provided, you can sign them with the `Key` obtained from the
|
||||
Coral administration panel in the previous steps with a `HS256` algorithm. This
|
||||
token can be provided in the above mentioned embed code by adding it to the
|
||||
`createStreamEmbed` function:
|
||||
|
||||
```js
|
||||
Coral.createStreamEmbed({
|
||||
// Don't forget to include the parameters from the
|
||||
// "Embed On Your Site" section.
|
||||
accessToken: "{{ SSO_TOKEN }}",
|
||||
});
|
||||
```
|
||||
|
||||
Or by calling the `login/logout` method on the embed object:
|
||||
|
||||
```js
|
||||
var embed = Coral.createStreamEmbed({
|
||||
// Don't forget to include the parameters from the
|
||||
// "Embed On Your Site" section.
|
||||
});
|
||||
|
||||
// Login the current embed with the generated SSO token.
|
||||
embed.login("{{ SSO_TOKEN }}");
|
||||
|
||||
// Logout the user.
|
||||
embed.logout();
|
||||
```
|
||||
|
||||
#### Email
|
||||
|
||||
|
||||
@@ -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