mirror of
https://github.com/wassname/talk.git
synced 2026-06-30 01:41:13 +08:00
feat: added initial server sso key rotation semantics (#2696)
This commit is contained in:
committed by
Kim Gardner
parent
59b8dfccda
commit
2dbba52fbd
@@ -5,10 +5,9 @@ import OAuth2Strategy, {
|
||||
} from "coral-server/app/middleware/passport/strategies/oauth2";
|
||||
import { constructTenantURL } from "coral-server/app/url";
|
||||
import {
|
||||
GQLAuthIntegrations,
|
||||
GQLFacebookAuthIntegration,
|
||||
GQLUSER_ROLE,
|
||||
} from "coral-server/graph/tenant/schema/__generated__/types";
|
||||
AuthIntegrations,
|
||||
FacebookAuthIntegration,
|
||||
} from "coral-server/models/settings";
|
||||
import { Tenant } from "coral-server/models/tenant";
|
||||
import {
|
||||
FacebookProfile,
|
||||
@@ -16,10 +15,12 @@ import {
|
||||
} from "coral-server/models/user";
|
||||
import { findOrCreate } from "coral-server/services/users";
|
||||
|
||||
import { GQLUSER_ROLE } from "coral-server/graph/tenant/schema/__generated__/types";
|
||||
|
||||
export type FacebookStrategyOptions = OAuth2StrategyOptions;
|
||||
|
||||
export default class FacebookStrategy extends OAuth2Strategy<
|
||||
GQLFacebookAuthIntegration,
|
||||
FacebookAuthIntegration,
|
||||
Strategy
|
||||
> {
|
||||
public name = "facebook";
|
||||
@@ -34,12 +35,12 @@ export default class FacebookStrategy extends OAuth2Strategy<
|
||||
});
|
||||
}
|
||||
|
||||
protected getIntegration = (integrations: GQLAuthIntegrations) =>
|
||||
protected getIntegration = (integrations: AuthIntegrations) =>
|
||||
integrations.facebook;
|
||||
|
||||
protected async findOrCreateUser(
|
||||
tenant: Tenant,
|
||||
integration: Required<GQLFacebookAuthIntegration>,
|
||||
integration: Required<FacebookAuthIntegration>,
|
||||
{ id, photos, emails }: Profile,
|
||||
now = new Date()
|
||||
) {
|
||||
@@ -94,7 +95,7 @@ export default class FacebookStrategy extends OAuth2Strategy<
|
||||
|
||||
protected createStrategy(
|
||||
tenant: Tenant,
|
||||
integration: Required<GQLFacebookAuthIntegration>
|
||||
integration: Required<FacebookAuthIntegration>
|
||||
) {
|
||||
return new Strategy(
|
||||
{
|
||||
|
||||
@@ -5,10 +5,9 @@ import OAuth2Strategy, {
|
||||
} from "coral-server/app/middleware/passport/strategies/oauth2";
|
||||
import { constructTenantURL } from "coral-server/app/url";
|
||||
import {
|
||||
GQLAuthIntegrations,
|
||||
GQLGoogleAuthIntegration,
|
||||
GQLUSER_ROLE,
|
||||
} from "coral-server/graph/tenant/schema/__generated__/types";
|
||||
AuthIntegrations,
|
||||
GoogleAuthIntegration,
|
||||
} from "coral-server/models/settings";
|
||||
import { Tenant } from "coral-server/models/tenant";
|
||||
import {
|
||||
GoogleProfile,
|
||||
@@ -16,10 +15,12 @@ import {
|
||||
} from "coral-server/models/user";
|
||||
import { findOrCreate } from "coral-server/services/users";
|
||||
|
||||
import { GQLUSER_ROLE } from "coral-server/graph/tenant/schema/__generated__/types";
|
||||
|
||||
export type GoogleStrategyOptions = OAuth2StrategyOptions;
|
||||
|
||||
export default class GoogleStrategy extends OAuth2Strategy<
|
||||
GQLGoogleAuthIntegration,
|
||||
GoogleAuthIntegration,
|
||||
Strategy
|
||||
> {
|
||||
public name = "google";
|
||||
@@ -33,12 +34,12 @@ export default class GoogleStrategy extends OAuth2Strategy<
|
||||
});
|
||||
}
|
||||
|
||||
protected getIntegration = (integrations: GQLAuthIntegrations) =>
|
||||
protected getIntegration = (integrations: AuthIntegrations) =>
|
||||
integrations.google;
|
||||
|
||||
protected async findOrCreateUser(
|
||||
tenant: Tenant,
|
||||
integration: Required<GQLGoogleAuthIntegration>,
|
||||
integration: Required<GoogleAuthIntegration>,
|
||||
{ id, photos, emails }: Profile,
|
||||
now = new Date()
|
||||
) {
|
||||
@@ -93,7 +94,7 @@ export default class GoogleStrategy extends OAuth2Strategy<
|
||||
|
||||
protected createStrategy(
|
||||
tenant: Tenant,
|
||||
integration: Required<GQLGoogleAuthIntegration>
|
||||
integration: Required<GoogleAuthIntegration>
|
||||
) {
|
||||
return new Strategy(
|
||||
{
|
||||
|
||||
@@ -9,7 +9,10 @@ import {
|
||||
} from "coral-server/errors";
|
||||
import { Tenant } from "coral-server/models/tenant";
|
||||
import { User } from "coral-server/models/user";
|
||||
import { extractTokenFromRequest } from "coral-server/services/jwt";
|
||||
import {
|
||||
extractTokenFromRequest,
|
||||
StandardHeader,
|
||||
} from "coral-server/services/jwt";
|
||||
import { Request } from "coral-server/types/express";
|
||||
|
||||
import { JWTToken, JWTVerifier } from "./verifiers/jwt";
|
||||
@@ -38,14 +41,15 @@ export interface Verifier<T = Token> {
|
||||
tokenString: string,
|
||||
token: T,
|
||||
tenant: Tenant,
|
||||
now: Date
|
||||
now: Date,
|
||||
kid?: string
|
||||
) => Promise<Readonly<User> | null>;
|
||||
|
||||
/**
|
||||
* supports will perform type checking and ensure that the given Tenant
|
||||
* supports the requested verification type.
|
||||
*/
|
||||
supports: (token: T | object, tenant: Tenant) => token is T;
|
||||
supports: (token: T | object, tenant: Tenant, kid?: string) => token is T;
|
||||
}
|
||||
|
||||
export function createVerifiers(
|
||||
@@ -64,16 +68,29 @@ export async function verifyAndRetrieveUser(
|
||||
tokenString: string,
|
||||
now = new Date()
|
||||
) {
|
||||
const token: Token = jwt.decode(tokenString);
|
||||
if (!token || typeof token === "string") {
|
||||
// Decode the token into header and payload parts.
|
||||
const decoded = jwt.decode(tokenString, {
|
||||
complete: true,
|
||||
});
|
||||
if (!decoded || typeof decoded === "string") {
|
||||
throw new TokenInvalidError(tokenString, "token could not be decoded");
|
||||
}
|
||||
|
||||
// Pull the parts of the token apart.
|
||||
const header: StandardHeader = decoded.header;
|
||||
const token: Token = decoded.payload;
|
||||
|
||||
try {
|
||||
// Try to verify the token.
|
||||
for (const verifier of verifiers) {
|
||||
if (verifier.supports(token, tenant)) {
|
||||
return await verifier.verify(tokenString, token, tenant, now);
|
||||
return await verifier.verify(
|
||||
tokenString,
|
||||
token,
|
||||
tenant,
|
||||
now,
|
||||
header.kid
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
import Joi from "joi";
|
||||
import { isNil } from "lodash";
|
||||
import { DateTime } from "luxon";
|
||||
import { Db } from "mongodb";
|
||||
|
||||
import { validate } from "coral-server/app/request/body";
|
||||
import { IntegrationDisabled } from "coral-server/errors";
|
||||
import { IntegrationDisabled, TokenInvalidError } from "coral-server/errors";
|
||||
import {
|
||||
GQLSSOAuthIntegration,
|
||||
GQLUSER_ROLE,
|
||||
} from "coral-server/graph/tenant/schema/__generated__/types";
|
||||
RequiredSSOKey,
|
||||
SSOAuthIntegration,
|
||||
} from "coral-server/models/settings";
|
||||
import { Tenant } from "coral-server/models/tenant";
|
||||
import {
|
||||
retrieveUserWithProfile,
|
||||
SSOProfile,
|
||||
updateUserFromSSO,
|
||||
} from "coral-server/models/user";
|
||||
import { findOrCreate } from "coral-server/services/users";
|
||||
|
||||
import {
|
||||
getSSOProfile,
|
||||
needsSSOUpdate,
|
||||
@@ -26,7 +25,13 @@ import {
|
||||
verifyJWT,
|
||||
} from "coral-server/services/jwt";
|
||||
import { AugmentedRedis } from "coral-server/services/redis";
|
||||
import { DateTime } from "luxon";
|
||||
import { findOrCreate } from "coral-server/services/users";
|
||||
|
||||
import {
|
||||
GQLSSOAuthIntegration,
|
||||
GQLUSER_ROLE,
|
||||
} from "coral-server/graph/tenant/schema/__generated__/types";
|
||||
|
||||
import { Verifier } from "../jwt";
|
||||
|
||||
export interface SSOStrategyOptions {
|
||||
@@ -167,6 +172,57 @@ export interface SSOVerifierOptions {
|
||||
redis: AugmentedRedis;
|
||||
}
|
||||
|
||||
export function getRelevantSSOKeys(
|
||||
integration: SSOAuthIntegration,
|
||||
tokenString: string,
|
||||
now: Date,
|
||||
kid?: string
|
||||
): RequiredSSOKey[] {
|
||||
// Collect all the current valid keys.
|
||||
const keys = integration.keys.filter(k => {
|
||||
if (!k.secret) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (k.deletedAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (k.deprecateAt && now >= k.deprecateAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return k;
|
||||
}) as RequiredSSOKey[];
|
||||
|
||||
// If there is only one key, that's all we can use!
|
||||
if (keys.length === 1) {
|
||||
return keys;
|
||||
}
|
||||
|
||||
// There is more than one key that could work, lets see if the token has a
|
||||
// kid.
|
||||
if (kid) {
|
||||
// The token has a kid, so if we have a matching token, we should use it. If
|
||||
// we don't have a matching kid, we can't possibly verify it, so throw an
|
||||
// error.
|
||||
const key = keys.find(k => k.kid === kid);
|
||||
if (!key) {
|
||||
throw new TokenInvalidError(
|
||||
tokenString,
|
||||
"kid was specified but no matching keys were found"
|
||||
);
|
||||
}
|
||||
|
||||
return [key];
|
||||
}
|
||||
|
||||
// Because no matching kid's were found, we should now use all valid secrets
|
||||
// instead.
|
||||
// TODO: [CORL-755] (wyattjoh) remove when we are able to make a breaking change to require signing with a kid
|
||||
return keys;
|
||||
}
|
||||
|
||||
export class SSOVerifier implements Verifier<SSOToken> {
|
||||
private mongo: Db;
|
||||
private redis: AugmentedRedis;
|
||||
@@ -176,7 +232,13 @@ export class SSOVerifier implements Verifier<SSOToken> {
|
||||
this.redis = redis;
|
||||
}
|
||||
|
||||
public supports(token: SSOToken | object, tenant: Tenant): token is SSOToken {
|
||||
public supports(
|
||||
token: SSOToken | object,
|
||||
tenant: Tenant,
|
||||
kid?: string
|
||||
): token is SSOToken {
|
||||
// TODO: [CORL-755] (wyattjoh) check that the `kid` it provided and matches a given kid in a future release
|
||||
|
||||
return tenant.auth.integrations.sso.enabled && isSSOToken(token);
|
||||
}
|
||||
|
||||
@@ -184,28 +246,68 @@ export class SSOVerifier implements Verifier<SSOToken> {
|
||||
tokenString: string,
|
||||
token: SSOToken,
|
||||
tenant: Tenant,
|
||||
now: Date
|
||||
now: Date,
|
||||
kid?: string
|
||||
) {
|
||||
const integration = tenant.auth.integrations.sso;
|
||||
if (!integration.enabled) {
|
||||
throw new IntegrationDisabled("sso");
|
||||
}
|
||||
|
||||
if (!integration.key) {
|
||||
// check to see if there is at least one key associated with this
|
||||
// integration.
|
||||
if (integration.keys.length === 0) {
|
||||
throw new Error("integration key does not exist");
|
||||
}
|
||||
|
||||
verifyJWT(
|
||||
tokenString,
|
||||
{
|
||||
// Force the use of the HS256 algorithm. We can explore switching this
|
||||
// out in the future..
|
||||
// TODO: (wyattjoh) investigate replacing algorithm.
|
||||
algorithm: SymmetricSigningAlgorithm.HS256,
|
||||
secret: integration.key,
|
||||
},
|
||||
now
|
||||
);
|
||||
// Get the valid configurations for the given token and integration pair.
|
||||
const keys = getRelevantSSOKeys(integration, tokenString, now, kid);
|
||||
if (keys.length === 0) {
|
||||
throw new TokenInvalidError(
|
||||
tokenString,
|
||||
"no verification configuration can be matched"
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: [CORL-755] (wyattjoh) remove once we've added the requirement for `kid`.
|
||||
// While there are configs left to test...
|
||||
while (keys.length > 0) {
|
||||
// Grab the next config to test. We know that the shift operation will
|
||||
// return a config because we validated it's length in the loop predicate.
|
||||
const key = keys.shift()!;
|
||||
|
||||
try {
|
||||
verifyJWT(
|
||||
tokenString,
|
||||
{
|
||||
// TODO: (wyattjoh) investigate replacing algorithm.
|
||||
algorithm: SymmetricSigningAlgorithm.HS256,
|
||||
secret: key.secret,
|
||||
},
|
||||
now
|
||||
);
|
||||
} catch (err) {
|
||||
// If this is the last config to test, we need to rethrow the error
|
||||
// here.
|
||||
if (keys.length === 0) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
// There are still configs to test, continue.
|
||||
continue;
|
||||
}
|
||||
|
||||
// TODO: [CORL-754] (wyattjoh) reintroduce when we amend the front-end to display the kid
|
||||
// // The verification did not throw an error, which means the verification
|
||||
// // succeeded! Break out now.
|
||||
// if (!kid) {
|
||||
// logger.warn(
|
||||
// { tenantID: tenant.id, kid: config.kid },
|
||||
// "token without a `kid` matched key with known `kid`"
|
||||
// );
|
||||
// }
|
||||
break;
|
||||
}
|
||||
|
||||
return findOrCreateSSOUser(
|
||||
this.mongo,
|
||||
|
||||
@@ -9,9 +9,10 @@ export const Settings = ({
|
||||
tenantCache,
|
||||
tenant,
|
||||
config,
|
||||
now,
|
||||
}: TenantContext) => ({
|
||||
update: (input: GQLUpdateSettingsInput): Promise<Tenant | null> =>
|
||||
update(mongo, redis, tenantCache, config, tenant, input.settings),
|
||||
regenerateSSOKey: (): Promise<Tenant | null> =>
|
||||
regenerateSSOKey(mongo, redis, tenantCache, tenant),
|
||||
regenerateSSOKey(mongo, redis, tenantCache, tenant, now),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import * as settings from "coral-server/models/settings";
|
||||
|
||||
import { GQLSSOAuthIntegrationTypeResolver } from "coral-server/graph/tenant/schema/__generated__/types";
|
||||
|
||||
function getActiveSSOKey(keys: settings.SSOKey[]) {
|
||||
return keys.find(
|
||||
key => Boolean(key.secret) && !key.deletedAt && !key.deprecateAt
|
||||
);
|
||||
}
|
||||
|
||||
export const SSOAuthIntegration: GQLSSOAuthIntegrationTypeResolver<
|
||||
settings.SSOAuthIntegration
|
||||
> = {
|
||||
key: ({ keys }) => {
|
||||
const key = getActiveSSOKey(keys);
|
||||
if (key) {
|
||||
return key.secret;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
keyGeneratedAt: ({ keys }) => {
|
||||
const key = getActiveSSOKey(keys);
|
||||
if (key) {
|
||||
return key.createdAt;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import Cursor from "coral-server/graph/common/scalars/cursor";
|
||||
import Locale from "coral-server/graph/common/scalars/locale";
|
||||
import Time from "coral-server/graph/common/scalars/time";
|
||||
|
||||
import { GQLResolver } from "coral-server/graph/tenant/schema/__generated__/types";
|
||||
|
||||
import { ApproveCommentPayload } from "./ApproveCommentPayload";
|
||||
@@ -36,6 +37,7 @@ import { Profile } from "./Profile";
|
||||
import { Query } from "./Query";
|
||||
import { RecentCommentHistory } from "./RecentCommentHistory";
|
||||
import { RejectCommentPayload } from "./RejectCommentPayload";
|
||||
import { SSOAuthIntegration } from "./SSOAuthIntegration";
|
||||
import { Story } from "./Story";
|
||||
import { StorySettings } from "./StorySettings";
|
||||
import { Subscription } from "./Subscription";
|
||||
@@ -56,10 +58,10 @@ const Resolvers: GQLResolver = {
|
||||
Comment,
|
||||
CommentCounts,
|
||||
CommentCreatedPayload,
|
||||
CommentReleasedPayload,
|
||||
CommentEnteredModerationQueuePayload,
|
||||
CommentLeftModerationQueuePayload,
|
||||
CommentModerationAction,
|
||||
CommentReleasedPayload,
|
||||
CommentReplyCreatedPayload,
|
||||
CommentRevision,
|
||||
CommentStatusUpdatedPayload,
|
||||
@@ -71,8 +73,10 @@ const Resolvers: GQLResolver = {
|
||||
GoogleAuthIntegration,
|
||||
Invite,
|
||||
LiveConfiguration,
|
||||
Locale,
|
||||
ModerationQueue,
|
||||
ModerationQueues,
|
||||
ModeratorNote,
|
||||
Mutation,
|
||||
OIDCAuthIntegration,
|
||||
PremodStatus,
|
||||
@@ -81,19 +85,18 @@ const Resolvers: GQLResolver = {
|
||||
Query,
|
||||
RecentCommentHistory,
|
||||
RejectCommentPayload,
|
||||
SSOAuthIntegration,
|
||||
Story,
|
||||
StorySettings,
|
||||
Subscription,
|
||||
SuspensionStatus,
|
||||
SuspensionStatusHistory,
|
||||
UsernameHistory,
|
||||
Tag,
|
||||
Time,
|
||||
Locale,
|
||||
User,
|
||||
UserStatus,
|
||||
UsernameHistory,
|
||||
UsernameStatus,
|
||||
ModeratorNote,
|
||||
UserStatus,
|
||||
};
|
||||
|
||||
export default Resolvers;
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from "subscriptions-transport-ws";
|
||||
|
||||
import { ACCESS_TOKEN_PARAM, CLIENT_ID_PARAM } from "coral-common/constants";
|
||||
import { Omit } from "coral-common/types";
|
||||
import { Omit, RequireProperty } from "coral-common/types";
|
||||
import { AppOptions } from "coral-server/app";
|
||||
import { getHostname } from "coral-server/app/helpers/hostname";
|
||||
import {
|
||||
@@ -36,12 +36,13 @@ import {
|
||||
} from "coral-server/graph/common/extensions";
|
||||
import { getOperationMetadata } from "coral-server/graph/common/extensions/helpers";
|
||||
import { getPersistedQuery } from "coral-server/graph/common/persisted";
|
||||
import { GQLUSER_ROLE } from "coral-server/graph/tenant/schema/__generated__/types";
|
||||
import logger from "coral-server/logger";
|
||||
import { PersistedQuery } from "coral-server/models/queries";
|
||||
import { hasStaffRole } from "coral-server/models/user/helpers";
|
||||
import { extractTokenFromRequest } from "coral-server/services/jwt";
|
||||
|
||||
import { GQLUSER_ROLE } from "coral-server/graph/tenant/schema/__generated__/types";
|
||||
|
||||
import TenantContext, { TenantContextOptions } from "../context";
|
||||
|
||||
type OnConnectFn = (
|
||||
@@ -77,11 +78,10 @@ export function extractClientID(connectionParams: OperationMessagePayload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
export type OnConnectOptions = Omit<
|
||||
TenantContextOptions,
|
||||
"tenant" | "signingConfig" | "disableCaching"
|
||||
> &
|
||||
Required<Pick<TenantContextOptions, "signingConfig">>;
|
||||
export type OnConnectOptions = RequireProperty<
|
||||
Omit<TenantContextOptions, "tenant" | "disableCaching">,
|
||||
"signingConfig"
|
||||
>;
|
||||
|
||||
export function onConnect(options: OnConnectOptions): OnConnectFn {
|
||||
// Create the JWT verifiers that will be used to verify all the requests
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Omit } from "coral-common/types";
|
||||
import { Omit, RequireProperty } from "coral-common/types";
|
||||
|
||||
import {
|
||||
GQLAuth,
|
||||
GQLAuthenticationTargetFilter,
|
||||
GQLEmailConfiguration,
|
||||
GQLFacebookAuthIntegration,
|
||||
GQLGoogleAuthIntegration,
|
||||
@@ -9,7 +11,6 @@ import {
|
||||
GQLMODERATION_MODE,
|
||||
GQLOIDCAuthIntegration,
|
||||
GQLSettings,
|
||||
GQLSSOAuthIntegration,
|
||||
} from "coral-server/graph/tenant/schema/__generated__/types";
|
||||
|
||||
export type LiveConfiguration = Omit<GQLLiveConfiguration, "configurable">;
|
||||
@@ -37,13 +38,52 @@ export type FacebookAuthIntegration = Omit<
|
||||
"callbackURL" | "redirectURL"
|
||||
>;
|
||||
|
||||
export interface SSOKey {
|
||||
/**
|
||||
* kid is the identifier for the key used when verifying tokens issued by the
|
||||
* provider.
|
||||
*/
|
||||
kid: string;
|
||||
|
||||
/**
|
||||
* secret is the actual underlying secret used to verify the tokens with. When
|
||||
* this is not available, it indicates that the token secret was deleted.
|
||||
*/
|
||||
secret?: string;
|
||||
|
||||
/**
|
||||
* createdAt is the time that this key was created at.
|
||||
*/
|
||||
createdAt: Date;
|
||||
|
||||
/**
|
||||
* deprecateAt when provided is the time that the token should no longer be
|
||||
* valid at.
|
||||
*/
|
||||
deprecateAt?: Date;
|
||||
|
||||
/**
|
||||
* deletedAt is the timestamp that the token was revoked.
|
||||
*/
|
||||
deletedAt?: Date;
|
||||
}
|
||||
|
||||
export type RequiredSSOKey = RequireProperty<SSOKey, "secret">;
|
||||
|
||||
export interface SSOAuthIntegration {
|
||||
enabled: boolean;
|
||||
allowRegistration: boolean;
|
||||
targetFilter: GQLAuthenticationTargetFilter;
|
||||
keys: SSOKey[];
|
||||
}
|
||||
|
||||
/**
|
||||
* AuthIntegrations are the set of configurations for the variations of
|
||||
* authentication solutions.
|
||||
*/
|
||||
export interface AuthIntegrations {
|
||||
local: GQLLocalAuthIntegration;
|
||||
sso: GQLSSOAuthIntegration;
|
||||
sso: SSOAuthIntegration;
|
||||
oidc: OIDCAuthIntegration;
|
||||
google: GoogleAuthIntegration;
|
||||
facebook: FacebookAuthIntegration;
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
} from "coral-server/graph/tenant/schema/__generated__/types";
|
||||
import { translate } from "coral-server/services/i18n";
|
||||
|
||||
import { SSOKey } from "../settings";
|
||||
|
||||
export const getDefaultReactionConfiguration = (
|
||||
bundle: FluentBundle
|
||||
): GQLReactionConfiguration => ({
|
||||
@@ -28,10 +30,19 @@ export const getDefaultStaffConfiguration = (
|
||||
label: translate(bundle, "Staff", "staff-label"),
|
||||
});
|
||||
|
||||
export function generateSSOKey() {
|
||||
export function generateRandomString(size: number, drift = 5) {
|
||||
return crypto
|
||||
.randomBytes(size + Math.floor(Math.random() * drift))
|
||||
.toString("hex");
|
||||
}
|
||||
|
||||
export function generateSSOKey(createdAt: Date): SSOKey {
|
||||
// Generate a new key. We generate a key of minimum length 32 up to 37 bytes,
|
||||
// as 16 was the minimum length recommended.
|
||||
//
|
||||
// Reference: https://security.stackexchange.com/a/96176
|
||||
return crypto.randomBytes(32 + Math.floor(Math.random() * 5)).toString("hex");
|
||||
const secret = generateRandomString(32, 5);
|
||||
const kid = generateRandomString(8, 3);
|
||||
|
||||
return { kid, secret, createdAt };
|
||||
}
|
||||
|
||||
@@ -125,8 +125,8 @@ export async function createTenant(
|
||||
admin: true,
|
||||
stream: true,
|
||||
},
|
||||
key: generateSSOKey(),
|
||||
keyGeneratedAt: now,
|
||||
// TODO: [CORL-754] (wyattjoh) remove this in favor of generating this when needed
|
||||
keys: [generateSSOKey(now)],
|
||||
},
|
||||
oidc: {
|
||||
enabled: false,
|
||||
@@ -277,25 +277,17 @@ export async function updateTenant(
|
||||
* for the specified Tenant. All existing user sessions signed with the old
|
||||
* secret will be invalidated.
|
||||
*/
|
||||
export async function regenerateTenantSSOKey(mongo: Db, id: string) {
|
||||
// Construct the update.
|
||||
const update: DeepPartial<Tenant> = {
|
||||
auth: {
|
||||
integrations: {
|
||||
sso: {
|
||||
key: generateSSOKey(),
|
||||
keyGeneratedAt: new Date(),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
export async function createTenantSSOKey(mongo: Db, id: string, now: Date) {
|
||||
// Construct the new key.
|
||||
const key = generateSSOKey(now);
|
||||
|
||||
// Update the Tenant with this new key.
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{ id },
|
||||
// Serialize the deep update into the Tenant.
|
||||
{
|
||||
$set: dotize(update),
|
||||
$push: {
|
||||
"auth.integrations.sso.keys": key,
|
||||
},
|
||||
},
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
@@ -304,3 +296,29 @@ export async function regenerateTenantSSOKey(mongo: Db, id: string) {
|
||||
|
||||
return result.value || null;
|
||||
}
|
||||
|
||||
export async function deprecateTenantSSOKey(
|
||||
mongo: Db,
|
||||
id: string,
|
||||
kid: string,
|
||||
deprecateAt: Date
|
||||
) {
|
||||
// Update the tenant.
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{ id },
|
||||
{
|
||||
$set: {
|
||||
"auth.integrations.sso.keys.$[keys].deprecateAt": deprecateAt,
|
||||
},
|
||||
},
|
||||
{
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
// Add an ArrayFilter to only update one of the keys.
|
||||
arrayFilters: [{ "keys.kid": kid }],
|
||||
}
|
||||
);
|
||||
|
||||
return result.value || null;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import cookie from "cookie";
|
||||
import { DEFAULT_SESSION_LENGTH } from "coral-common/constants";
|
||||
import { IncomingMessage } from "http";
|
||||
import { Redis } from "ioredis";
|
||||
import Joi from "joi";
|
||||
import jwt, { SignOptions, VerifyOptions } from "jsonwebtoken";
|
||||
import jwt, { KeyFunction, SignOptions, VerifyOptions } from "jsonwebtoken";
|
||||
import { DateTime } from "luxon";
|
||||
import { Bearer, BearerOptions } from "permit";
|
||||
import uuid from "uuid/v4";
|
||||
|
||||
import { DEFAULT_SESSION_LENGTH } from "coral-common/constants";
|
||||
import { Omit } from "coral-common/types";
|
||||
import { Config } from "coral-server/config";
|
||||
import {
|
||||
@@ -20,7 +20,36 @@ import { User } from "coral-server/models/user";
|
||||
import { Request } from "coral-server/types/express";
|
||||
|
||||
/**
|
||||
* The following Claim Names are registered in the IANA "JSON Web Token
|
||||
* The following Header Parameter names for use in JWSs are registered
|
||||
* in the IANA "JSON Web Signature and Encryption Header Parameters"
|
||||
* registry established by Section 9.1, with meanings as defined in the
|
||||
* subsections below.
|
||||
*
|
||||
* As indicated by the common registry, JWSs and JWEs share a common
|
||||
* Header Parameter space; when a parameter is used by both
|
||||
* specifications, its usage must be compatible between the
|
||||
* specifications.
|
||||
*
|
||||
* https://tools.ietf.org/html/rfc7515#section-4.1
|
||||
*/
|
||||
export interface StandardHeader {
|
||||
/**
|
||||
* The "kid" (key ID) Header Parameter is a hint indicating which key
|
||||
* was used to secure the JWS. This parameter allows originators to
|
||||
* explicitly signal a change of key to recipients. The structure of
|
||||
* the "kid" value is unspecified. Its value MUST be a case-sensitive
|
||||
* string. Use of this Header Parameter is OPTIONAL.
|
||||
*
|
||||
* When used with a JWK, the "kid" value is used to match a JWK "kid"
|
||||
* parameter value.
|
||||
*
|
||||
* https://tools.ietf.org/html/rfc7515#section-4.1.4
|
||||
*/
|
||||
kid?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The following Claim Names are registered in the IANA "JSON Web Token
|
||||
* Claims" registry established by Section 10.1. None of the claims
|
||||
* defined below are intended to be mandatory to use or implement in all
|
||||
* cases, but rather they provide a starting point for a set of useful,
|
||||
@@ -157,6 +186,11 @@ export interface JWTSigningConfig {
|
||||
algorithm: JWTSigningAlgorithm;
|
||||
}
|
||||
|
||||
export interface JWTVerifyingConfig {
|
||||
secret: Buffer | string | KeyFunction;
|
||||
algorithm: JWTSigningAlgorithm;
|
||||
}
|
||||
|
||||
export function dateToSeconds(date: Date): number {
|
||||
return Math.round(DateTime.fromJSDate(date).toSeconds());
|
||||
}
|
||||
@@ -408,7 +442,7 @@ export async function checkJWTRevoked(redis: Redis, jti: string) {
|
||||
|
||||
export function verifyJWT(
|
||||
tokenString: string,
|
||||
{ algorithm, secret }: JWTSigningConfig,
|
||||
{ algorithm, secret }: JWTVerifyingConfig,
|
||||
now: Date,
|
||||
options: Omit<VerifyOptions, "algorithms" | "clockTimestamp"> = {}
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Db } from "mongodb";
|
||||
|
||||
import { SSOKey } from "coral-server/models/settings";
|
||||
import { generateSSOKey } from "coral-server/models/tenant";
|
||||
import Migration from "coral-server/services/migrate/migration";
|
||||
import collections from "coral-server/services/mongodb/collections";
|
||||
|
||||
import { GQLTime } from "coral-server/graph/tenant/schema/__generated__/types";
|
||||
|
||||
import { MigrationError } from "../error";
|
||||
|
||||
interface Tenant {
|
||||
auth: {
|
||||
integrations: {
|
||||
sso: {
|
||||
key?: string;
|
||||
keyGeneratedAt?: GQLTime;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export default class extends Migration {
|
||||
public async up(mongo: Db, tenantID: string) {
|
||||
// Get the Tenant so we can update it. We don't have to worry about two
|
||||
// migration operations conflicting here because we will lock one instance
|
||||
// to perform the operations.
|
||||
const tenant = await collections
|
||||
.tenants<Tenant>(mongo)
|
||||
.findOne({ id: tenantID });
|
||||
if (!tenant) {
|
||||
throw new MigrationError(tenantID, "could not find tenant", "tenants", [
|
||||
tenantID,
|
||||
]);
|
||||
}
|
||||
|
||||
// Store the keys in an array.
|
||||
const keys: SSOKey[] = [];
|
||||
|
||||
// Check to see if a key is set.
|
||||
const sso = tenant.auth.integrations.sso;
|
||||
if (sso.key && sso.keyGeneratedAt) {
|
||||
// Create the new SSOKey based on this data.
|
||||
const key = generateSSOKey(sso.keyGeneratedAt);
|
||||
|
||||
// Set the secret of the sso key to the secret of the current set key.
|
||||
key.secret = sso.key;
|
||||
|
||||
// Add this key to the set of keys.
|
||||
keys.push(key);
|
||||
}
|
||||
|
||||
// Update the tenant with the new sso keys.
|
||||
await collections.tenants(mongo).updateOne(
|
||||
{ id: tenantID },
|
||||
{
|
||||
$set: {
|
||||
"auth.integrations.sso.keys": keys,
|
||||
},
|
||||
$unset: {
|
||||
"auth.integrations.sso.key": "",
|
||||
"auth.integrations.sso.keyGeneratedAt": "",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,28 @@
|
||||
import { Redis } from "ioredis";
|
||||
import { isUndefined } from "lodash";
|
||||
import { DateTime } from "luxon";
|
||||
import { Db } from "mongodb";
|
||||
import { URL } from "url";
|
||||
|
||||
import { discover } from "coral-server/app/middleware/passport/strategies/oidc/discover";
|
||||
import { Config } from "coral-server/config";
|
||||
import { TenantInstalledAlreadyError } from "coral-server/errors";
|
||||
import {
|
||||
GQLSettingsInput,
|
||||
GQLSettingsWordListInput,
|
||||
} from "coral-server/graph/tenant/schema/__generated__/types";
|
||||
import logger from "coral-server/logger";
|
||||
import {
|
||||
createTenant,
|
||||
CreateTenantInput,
|
||||
regenerateTenantSSOKey,
|
||||
createTenantSSOKey,
|
||||
deprecateTenantSSOKey,
|
||||
Tenant,
|
||||
updateTenant,
|
||||
} from "coral-server/models/tenant";
|
||||
import { I18n } from "coral-server/services/i18n";
|
||||
|
||||
import {
|
||||
GQLSettingsInput,
|
||||
GQLSettingsWordListInput,
|
||||
} from "coral-server/graph/tenant/schema/__generated__/types";
|
||||
|
||||
import TenantCache from "./cache";
|
||||
|
||||
export type UpdateTenant = GQLSettingsInput;
|
||||
@@ -124,9 +127,34 @@ export async function regenerateSSOKey(
|
||||
mongo: Db,
|
||||
redis: Redis,
|
||||
cache: TenantCache,
|
||||
tenant: Tenant
|
||||
tenant: Tenant,
|
||||
now: Date
|
||||
) {
|
||||
const updatedTenant = await regenerateTenantSSOKey(mongo, tenant.id);
|
||||
// Deprecate the old Tenant SSO key if it exists.
|
||||
if (tenant.auth.integrations.sso.keys.length > 0) {
|
||||
// Get the old keys that are not deprecated.
|
||||
const keysToDeprecate = tenant.auth.integrations.sso.keys.filter(key => {
|
||||
return !key.deletedAt && !key.deprecateAt && key.secret;
|
||||
});
|
||||
|
||||
// Check to see if there are keys to deprecate.
|
||||
if (keysToDeprecate.length > 0) {
|
||||
// All the keys will be deprecated a month from now.
|
||||
// TODO: [CORL-754] (wyattjoh) take input for the deprecation duration later.
|
||||
const deprecateAt = DateTime.fromJSDate(now)
|
||||
.plus({ month: 1 })
|
||||
.toJSDate();
|
||||
|
||||
// Deprecate all the keys that are associated on the tenant that haven't
|
||||
// been done.
|
||||
for (const key of keysToDeprecate) {
|
||||
await deprecateTenantSSOKey(mongo, tenant.id, key.kid, deprecateAt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create the new Tenant.
|
||||
const updatedTenant = await createTenantSSOKey(mongo, tenant.id, now);
|
||||
if (!updatedTenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user