From 2dbba52fbd0cd5aa117e2dc85c8d3e609f94cb39 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Thu, 7 Nov 2019 21:53:28 +0000 Subject: [PATCH] feat: added initial server sso key rotation semantics (#2696) --- .../passport/strategies/facebook.ts | 17 ++- .../middleware/passport/strategies/google.ts | 17 ++- .../app/middleware/passport/strategies/jwt.ts | 29 +++- .../passport/strategies/verifiers/sso.ts | 144 +++++++++++++++--- .../server/graph/tenant/mutators/Settings.ts | 3 +- .../tenant/resolvers/SSOAuthIntegration.ts | 30 ++++ .../server/graph/tenant/resolvers/index.ts | 13 +- .../graph/tenant/subscriptions/server.ts | 14 +- src/core/server/models/settings.ts | 46 +++++- src/core/server/models/tenant/helpers.ts | 15 +- src/core/server/models/tenant/tenant.ts | 50 ++++-- src/core/server/services/jwt/index.ts | 42 ++++- .../migrations/1573073491825_sso_tokens.ts | 67 ++++++++ src/core/server/services/tenant/index.ts | 42 ++++- 14 files changed, 441 insertions(+), 88 deletions(-) create mode 100644 src/core/server/graph/tenant/resolvers/SSOAuthIntegration.ts create mode 100644 src/core/server/services/migrate/migrations/1573073491825_sso_tokens.ts diff --git a/src/core/server/app/middleware/passport/strategies/facebook.ts b/src/core/server/app/middleware/passport/strategies/facebook.ts index b2d975662..74d999b25 100644 --- a/src/core/server/app/middleware/passport/strategies/facebook.ts +++ b/src/core/server/app/middleware/passport/strategies/facebook.ts @@ -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, + integration: Required, { id, photos, emails }: Profile, now = new Date() ) { @@ -94,7 +95,7 @@ export default class FacebookStrategy extends OAuth2Strategy< protected createStrategy( tenant: Tenant, - integration: Required + integration: Required ) { return new Strategy( { diff --git a/src/core/server/app/middleware/passport/strategies/google.ts b/src/core/server/app/middleware/passport/strategies/google.ts index fe41df1a4..96d81d093 100644 --- a/src/core/server/app/middleware/passport/strategies/google.ts +++ b/src/core/server/app/middleware/passport/strategies/google.ts @@ -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, + integration: Required, { id, photos, emails }: Profile, now = new Date() ) { @@ -93,7 +94,7 @@ export default class GoogleStrategy extends OAuth2Strategy< protected createStrategy( tenant: Tenant, - integration: Required + integration: Required ) { return new Strategy( { diff --git a/src/core/server/app/middleware/passport/strategies/jwt.ts b/src/core/server/app/middleware/passport/strategies/jwt.ts index 388cd9e2c..218637806 100644 --- a/src/core/server/app/middleware/passport/strategies/jwt.ts +++ b/src/core/server/app/middleware/passport/strategies/jwt.ts @@ -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 { tokenString: string, token: T, tenant: Tenant, - now: Date + now: Date, + kid?: string ) => Promise | 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) { diff --git a/src/core/server/app/middleware/passport/strategies/verifiers/sso.ts b/src/core/server/app/middleware/passport/strategies/verifiers/sso.ts index 0bb292139..bdb321f7c 100644 --- a/src/core/server/app/middleware/passport/strategies/verifiers/sso.ts +++ b/src/core/server/app/middleware/passport/strategies/verifiers/sso.ts @@ -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 { private mongo: Db; private redis: AugmentedRedis; @@ -176,7 +232,13 @@ export class SSOVerifier implements Verifier { 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 { 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, diff --git a/src/core/server/graph/tenant/mutators/Settings.ts b/src/core/server/graph/tenant/mutators/Settings.ts index 302fe87d8..9bbd57cfd 100644 --- a/src/core/server/graph/tenant/mutators/Settings.ts +++ b/src/core/server/graph/tenant/mutators/Settings.ts @@ -9,9 +9,10 @@ export const Settings = ({ tenantCache, tenant, config, + now, }: TenantContext) => ({ update: (input: GQLUpdateSettingsInput): Promise => update(mongo, redis, tenantCache, config, tenant, input.settings), regenerateSSOKey: (): Promise => - regenerateSSOKey(mongo, redis, tenantCache, tenant), + regenerateSSOKey(mongo, redis, tenantCache, tenant, now), }); diff --git a/src/core/server/graph/tenant/resolvers/SSOAuthIntegration.ts b/src/core/server/graph/tenant/resolvers/SSOAuthIntegration.ts new file mode 100644 index 000000000..fad9ad2fa --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/SSOAuthIntegration.ts @@ -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; + }, +}; diff --git a/src/core/server/graph/tenant/resolvers/index.ts b/src/core/server/graph/tenant/resolvers/index.ts index bd217c10f..4d67b809f 100644 --- a/src/core/server/graph/tenant/resolvers/index.ts +++ b/src/core/server/graph/tenant/resolvers/index.ts @@ -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; diff --git a/src/core/server/graph/tenant/subscriptions/server.ts b/src/core/server/graph/tenant/subscriptions/server.ts index f6f3c464c..a8bbca496 100644 --- a/src/core/server/graph/tenant/subscriptions/server.ts +++ b/src/core/server/graph/tenant/subscriptions/server.ts @@ -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>; +export type OnConnectOptions = RequireProperty< + Omit, + "signingConfig" +>; export function onConnect(options: OnConnectOptions): OnConnectFn { // Create the JWT verifiers that will be used to verify all the requests diff --git a/src/core/server/models/settings.ts b/src/core/server/models/settings.ts index 402c4e2d9..5af3c9e06 100644 --- a/src/core/server/models/settings.ts +++ b/src/core/server/models/settings.ts @@ -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; @@ -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; + +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; diff --git a/src/core/server/models/tenant/helpers.ts b/src/core/server/models/tenant/helpers.ts index be5a75881..c449c9e49 100644 --- a/src/core/server/models/tenant/helpers.ts +++ b/src/core/server/models/tenant/helpers.ts @@ -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 }; } diff --git a/src/core/server/models/tenant/tenant.ts b/src/core/server/models/tenant/tenant.ts index cc709b491..71050814c 100644 --- a/src/core/server/models/tenant/tenant.ts +++ b/src/core/server/models/tenant/tenant.ts @@ -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 = { - 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; +} diff --git a/src/core/server/services/jwt/index.ts b/src/core/server/services/jwt/index.ts index cb7691574..bcdb643d7 100644 --- a/src/core/server/services/jwt/index.ts +++ b/src/core/server/services/jwt/index.ts @@ -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 = {} ) { diff --git a/src/core/server/services/migrate/migrations/1573073491825_sso_tokens.ts b/src/core/server/services/migrate/migrations/1573073491825_sso_tokens.ts new file mode 100644 index 000000000..87f9103f1 --- /dev/null +++ b/src/core/server/services/migrate/migrations/1573073491825_sso_tokens.ts @@ -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(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": "", + }, + } + ); + } +} diff --git a/src/core/server/services/tenant/index.ts b/src/core/server/services/tenant/index.ts index d2223c2b7..9df8b6df7 100644 --- a/src/core/server/services/tenant/index.ts +++ b/src/core/server/services/tenant/index.ts @@ -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; }