From 45cbac797278c273c150a8e3c438342f6ddf486b Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 24 Oct 2018 16:54:07 -0600 Subject: [PATCH] feat: added OIDC management mutations --- .../passport/strategies/oidc/discover.ts | 3 + .../passport/strategies/oidc/index.ts | 92 +++++--- src/core/server/app/router/api/auth.ts | 7 +- src/core/server/app/url.ts | 13 ++ .../server/graph/tenant/mutators/settings.ts | 31 +++ .../server/graph/tenant/resolvers/index.ts | 4 +- .../server/graph/tenant/resolvers/mutation.ts | 12 + .../tenant/resolvers/oidc_auth_integration.ts | 26 +++ .../server/graph/tenant/schema/schema.graphql | 217 +++++++++++++++--- src/core/server/models/tenant.ts | 101 +++++++- src/core/server/services/tenant/index.ts | 87 ++++++- 11 files changed, 524 insertions(+), 69 deletions(-) create mode 100644 src/core/server/graph/tenant/resolvers/oidc_auth_integration.ts diff --git a/src/core/server/app/middleware/passport/strategies/oidc/discover.ts b/src/core/server/app/middleware/passport/strategies/oidc/discover.ts index afc14afc6..ab12fa59c 100644 --- a/src/core/server/app/middleware/passport/strategies/oidc/discover.ts +++ b/src/core/server/app/middleware/passport/strategies/oidc/discover.ts @@ -5,6 +5,7 @@ import { URL } from "url"; * Configuration that Talk is expecting. */ export interface DiscoveryConfiguration { + issuer: string; authorizationURL?: string; tokenURL?: string; jwksURI?: string; @@ -16,6 +17,7 @@ export interface DiscoveryConfiguration { * https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata */ interface DiscoveryRawConfiguration { + issuer: string; authorization_endpoint?: string; token_endpoint?: string; jwks_uri?: string; @@ -40,6 +42,7 @@ export async function discover(issuer: URL): Promise { const meta: DiscoveryRawConfiguration = await res.json(); return { + issuer: meta.issuer, authorizationURL: meta.authorization_endpoint, tokenURL: meta.token_endpoint, jwksURI: meta.jwks_uri, diff --git a/src/core/server/app/middleware/passport/strategies/oidc/index.ts b/src/core/server/app/middleware/passport/strategies/oidc/index.ts index f4d07a108..4405e3697 100644 --- a/src/core/server/app/middleware/passport/strategies/oidc/index.ts +++ b/src/core/server/app/middleware/passport/strategies/oidc/index.ts @@ -32,23 +32,26 @@ export interface OIDCIDToken { iss: string; sub: string; exp: number; // TODO: use this as the source for how long an OIDC user can be logged in for - email?: string; + email: string; email_verified?: boolean; picture?: string; name?: string; nickname?: string; } -export interface StrategyItem { - strategy: OAuth2Strategy; - jwksClient?: JwksClient; -} +export type StrategyItem = Record< + string, + { + strategy: OAuth2Strategy; + jwksClient?: JwksClient; + } +>; export function isOIDCToken(token: OIDCIDToken | object): token is OIDCIDToken { if ( (token as OIDCIDToken).iss && (token as OIDCIDToken).sub && - (token as OIDCIDToken).email + (token as OIDCIDToken).aud ) { return true; } @@ -85,9 +88,18 @@ const signingKeyFactory = (client: jwks.JwksClient): jwt.KeyFunction => ( }; function getEnabledIntegration( - tenant: Tenant + tenant: Tenant, + oidcID: string ): Required { - const integration = tenant.auth.integrations.oidc; + if (!tenant.auth.integrations.oidc) { + // TODO: return a better error. + throw new Error("integration not found"); + } + + // Grab the OIDC Integration from the list of integrations. + const integration = tenant.auth.integrations.oidc.find( + ({ id }) => id === oidcID + ); if (!integration) { // TODO: return a better error. throw new Error("integration not found"); @@ -135,6 +147,7 @@ export const OIDCDisplayNameIDTokenSchema = OIDCIDTokenSchema.keys({ export async function findOrCreateOIDCUser( db: Db, tenant: Tenant, + integration: GQLOIDCAuthIntegration, token: OIDCIDToken ) { // Unpack/validate the token content. @@ -148,7 +161,7 @@ export async function findOrCreateOIDCUser( name, nickname, }: OIDCIDToken = validate( - tenant.auth.integrations.oidc!.displayNameEnable + integration.displayNameEnable ? OIDCDisplayNameIDTokenSchema : OIDCIDTokenSchema, token @@ -215,34 +228,45 @@ export default class OIDCStrategy extends Strategy { req: Request, tenantID: string, oidc: Required - ) { - let entry = this.cache.get(tenantID); - if (!entry) { + ): jwks.JwksClient { + let tenantIntegrations = this.cache.get(tenantID); + if (!tenantIntegrations || !tenantIntegrations[oidc.id]) { const strategy = this.createStrategy(req, oidc); // Create the entry. - entry = { - strategy, + tenantIntegrations = { + [oidc.id]: { + strategy, + }, + ...(tenantIntegrations || {}), }; // We don't reset the entry in the cache here because if we just created // it, we'll be creating the jwksClient anyways, so we'll update it there. } - if (!entry.jwksClient) { + const tenantIntegration = tenantIntegrations[oidc.id]; + + if (!tenantIntegration.jwksClient) { // Create the new JWKS client. const jwksClient = jwks({ jwksUri: oidc.jwksURI, }); // Set the jwksClient on the entry. - entry.jwksClient = jwksClient; + tenantIntegration.jwksClient = jwksClient; // Update the cached entry. - this.cache.set(tenantID, entry); + this.cache.set(tenantID, { + [oidc.id]: { + ...tenantIntegration, + jwksClient, + }, + ...tenantIntegrations, + }); } - return entry.jwksClient; + return tenantIntegration.jwksClient; } private userAuthenticatedCallback = ( @@ -268,11 +292,14 @@ export default class OIDCStrategy extends Strategy { return done(new Error("tenant not found")); } + // Grab the OIDC ID from the request. + const { oidcID }: { oidcID: string } = req.params; + // Get the integration from the tenant. If needed, it will be used to create // a new strategy. let integration: Required; try { - integration = getEnabledIntegration(tenant); + integration = getEnabledIntegration(tenant, oidcID); } catch (err) { // TODO: wrap error? return done(err); @@ -298,6 +325,7 @@ export default class OIDCStrategy extends Strategy { const user = await findOrCreateOIDCUser( this.mongo, tenant, + integration, decoded as OIDCIDToken ); return done(null, user); @@ -332,39 +360,45 @@ export default class OIDCStrategy extends Strategy { ); } - private async lookupStrategy(req: Request) { + private lookupStrategy(req: Request): OAuth2Strategy { const { tenant } = req.talk!; if (!tenant) { // TODO: return a better error. throw new Error("tenant not found"); } + // Get the OIDC ID. + const { oidcID }: { oidcID: string } = req.params; + // Get the integration from the tenant. If needed, it will be used to create // a new strategy. - const integration = getEnabledIntegration(tenant); + const integration = getEnabledIntegration(tenant, oidcID); // Try to get the Tenant's cached integrations. - let entry = this.cache.get(tenant.id); - if (!entry) { + let tenantIntegrations = this.cache.get(tenant.id); + if (!tenantIntegrations || !tenantIntegrations[oidcID]) { // Create the strategy. const strategy = this.createStrategy(req, integration); // Reset the entry. - entry = { - strategy, + tenantIntegrations = { + [oidcID]: { + strategy, + }, + ...(tenantIntegrations || {}), }; // Update the cached integrations value. - this.cache.set(tenant.id, entry); + this.cache.set(tenant.id, tenantIntegrations); } - return entry.strategy; + return tenantIntegrations[oidcID].strategy; } - public async authenticate(req: Request) { + public authenticate(req: Request) { try { // Lookup the strategy. - const strategy = await this.lookupStrategy(req); + const strategy = this.lookupStrategy(req); if (!strategy) { throw new Error("strategy not found"); } diff --git a/src/core/server/app/router/api/auth.ts b/src/core/server/app/router/api/auth.ts index df7f7a05b..37a1ddfff 100644 --- a/src/core/server/app/router/api/auth.ts +++ b/src/core/server/app/router/api/auth.ts @@ -29,9 +29,12 @@ export function createNewAuthRouter(app: AppOptions, options: RouterOptions) { signupHandler({ db: app.mongo, signingConfig: app.signingConfig }) ); - router.get("/oidc", wrapAuthn(options.passport, app.signingConfig, "oidc")); router.get( - "/oidc/callback", + "/oidc/:oidcID", + wrapAuthn(options.passport, app.signingConfig, "oidc") + ); + router.get( + "/oidc/:oidcID/callback", wrapAuthn(options.passport, app.signingConfig, "oidc") ); diff --git a/src/core/server/app/url.ts b/src/core/server/app/url.ts index 3654c4c46..a37def2c5 100644 --- a/src/core/server/app/url.ts +++ b/src/core/server/app/url.ts @@ -1,3 +1,4 @@ +import { Tenant } from "talk-server/models/tenant"; import { Request } from "talk-server/types/express"; import { URL } from "url"; @@ -11,6 +12,18 @@ export function reconstructURL(req: Request, path: string = "/"): string { return url.href; } +/** + * reconstructTenantURL will reconstruct a URL based off of the Tenant's domain. + */ +export function reconstructTenantURL( + tenant: Pick, + path: string = "/" +): string { + const url = new URL(path, `https://${tenant.domain}`); + + return url.href; +} + export function doesRequireSchemePrefixing(url: string) { return !url.startsWith("http"); } diff --git a/src/core/server/graph/tenant/mutators/settings.ts b/src/core/server/graph/tenant/mutators/settings.ts index 079a9389e..d1ceaaef3 100644 --- a/src/core/server/graph/tenant/mutators/settings.ts +++ b/src/core/server/graph/tenant/mutators/settings.ts @@ -2,15 +2,21 @@ import { isNull, omitBy } from "lodash"; import TenantContext from "talk-server/graph/tenant/context"; import { + GQLCreateOIDCAuthIntegrationInput, + GQLDeleteOIDCAuthIntegrationInput, GQLDiscoverOIDCConfigurationInput, GQLOIDCConfiguration, GQLSettingsInput, + GQLUpdateOIDCAuthIntegrationInput, } from "talk-server/graph/tenant/schema/__generated__/types"; import { Tenant } from "talk-server/models/tenant"; import { + createOIDCAuthIntegration, + deleteOIDCAuthIntegration, discoverOIDCConfiguration, regenerateSSOKey, update, + updateOIDCAuthIntegration, } from "talk-server/services/tenant"; export default ({ mongo, redis, tenantCache, tenant }: TenantContext) => ({ @@ -21,4 +27,29 @@ export default ({ mongo, redis, tenantCache, tenant }: TenantContext) => ({ discoverOIDCConfiguration: ( input: GQLDiscoverOIDCConfigurationInput ): Promise => discoverOIDCConfiguration(input.issuer), + createOIDCAuthIntegration: ( + input: GQLCreateOIDCAuthIntegrationInput + ): Promise => + createOIDCAuthIntegration( + mongo, + redis, + tenantCache, + tenant, + input.configuration + ), + updateOIDCAuthIntegration: ( + input: GQLUpdateOIDCAuthIntegrationInput + ): Promise => + updateOIDCAuthIntegration( + mongo, + redis, + tenantCache, + tenant, + input.id, + input.configuration + ), + deleteOIDCAuthIntegration: ( + input: GQLDeleteOIDCAuthIntegrationInput + ): Promise => + deleteOIDCAuthIntegration(mongo, redis, tenantCache, tenant, input.id), }); diff --git a/src/core/server/graph/tenant/resolvers/index.ts b/src/core/server/graph/tenant/resolvers/index.ts index 8081b892b..036a35857 100644 --- a/src/core/server/graph/tenant/resolvers/index.ts +++ b/src/core/server/graph/tenant/resolvers/index.ts @@ -8,6 +8,7 @@ import AuthIntegrations from "./auth_integrations"; import Comment from "./comment"; import CommentCounts from "./comment_counts"; import Mutation from "./mutation"; +import OIDCAuthIntegration from "./oidc_auth_integration"; import Profile from "./profile"; import Query from "./query"; import User from "./user"; @@ -19,10 +20,11 @@ const Resolvers: GQLResolver = { CommentCounts, Cursor, Mutation, + OIDCAuthIntegration, Profile, Query, - User, Time, + User, }; export default Resolvers; diff --git a/src/core/server/graph/tenant/resolvers/mutation.ts b/src/core/server/graph/tenant/resolvers/mutation.ts index 7255071d7..7781d7689 100644 --- a/src/core/server/graph/tenant/resolvers/mutation.ts +++ b/src/core/server/graph/tenant/resolvers/mutation.ts @@ -55,6 +55,18 @@ const Mutation: GQLMutationTypeResolver = { configuration: await ctx.mutators.Settings.discoverOIDCConfiguration(input), clientMutationId: input.clientMutationId, }), + createOIDCAuthIntegration: async (source, { input }, ctx) => ({ + settings: await ctx.mutators.Settings.createOIDCAuthIntegration(input), + clientMutationId: input.clientMutationId, + }), + updateOIDCAuthIntegration: async (source, { input }, ctx) => ({ + settings: await ctx.mutators.Settings.updateOIDCAuthIntegration(input), + clientMutationId: input.clientMutationId, + }), + deleteOIDCAuthIntegration: async (source, { input }, ctx) => ({ + settings: await ctx.mutators.Settings.deleteOIDCAuthIntegration(input), + clientMutationId: input.clientMutationId, + }), }; export default Mutation; diff --git a/src/core/server/graph/tenant/resolvers/oidc_auth_integration.ts b/src/core/server/graph/tenant/resolvers/oidc_auth_integration.ts new file mode 100644 index 000000000..9e2890222 --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/oidc_auth_integration.ts @@ -0,0 +1,26 @@ +import { reconstructTenantURL, reconstructURL } from "talk-server/app/url"; +import { + GQLOIDCAuthIntegration, + GQLOIDCAuthIntegrationTypeResolver, +} from "talk-server/graph/tenant/schema/__generated__/types"; + +const OIDCAuthIntegration: GQLOIDCAuthIntegrationTypeResolver< + GQLOIDCAuthIntegration +> = { + callbackURL: (integration, args, ctx) => { + const path = `/api/tenant/auth/oidc/${integration.id}`; + + // If the request is available, then prefer it over building from the tenant + // as the tenant does not include the port number. This should only really + // be a problem if the graph API is called internally. + if (ctx.req) { + return reconstructURL(ctx.req, path); + } + + // Note that when constructing the callback url with the tenant, the port + // information is lost. + return reconstructTenantURL(ctx.tenant, path); + }, +}; + +export default OIDCAuthIntegration; diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index 65cde5509..b29921796 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -317,6 +317,14 @@ OIDCAuthIntegration provides a way to store Open ID Connect credentials. This will be used in the admin to provide staff logins for users. """ type OIDCAuthIntegration { + """ + id is the identifier of the OIDCAuthIntegration. + """ + id: ID! + + """ + enabled, when true, allows the integration to be enabled. + """ enabled: Boolean! """ @@ -330,12 +338,20 @@ type OIDCAuthIntegration { name is the label assigned to reference the provider of the OIDC integration. """ name: String - clientID: String @auth(roles: [ADMIN]) - clientSecret: String @auth(roles: [ADMIN]) - authorizationURL: String @auth(roles: [ADMIN]) - tokenURL: String @auth(roles: [ADMIN]) - jwksURI: String @auth(roles: [ADMIN]) - issuer: String @auth(roles: [ADMIN]) + + """ + callbackURL is the URL that the user should be redirected to in order to start + an authentication flow with the given integration. This field is not stored, + and is instead computed from the Tenant. + """ + callbackURL: String! + + clientID: String! @auth(roles: [ADMIN]) + clientSecret: String! @auth(roles: [ADMIN]) + authorizationURL: String! @auth(roles: [ADMIN]) + tokenURL: String! @auth(roles: [ADMIN]) + jwksURI: String! @auth(roles: [ADMIN]) + issuer: String! @auth(roles: [ADMIN]) """ displayNameEnable when enabled, will allow Users to set and view their @@ -383,7 +399,7 @@ type FacebookAuthIntegration { type AuthIntegrations { local: LocalAuthIntegration! sso: SSOAuthIntegration! - oidc: OIDCAuthIntegration! + oidc: [OIDCAuthIntegration!]! google: GoogleAuthIntegration! facebook: FacebookAuthIntegration! } @@ -1349,33 +1365,33 @@ input SettingsSSOAuthIntegrationInput { displayNameEnable: Boolean } -input SettingsOIDCAuthIntegrationInput { - enabled: Boolean +# input SettingsOIDCAuthIntegrationInput { +# enabled: Boolean - """ - targetFilter will restrict where the authentication integration should be - displayed. If the value of targetFilter is null, then the authentication - integration should be displayed in all targets. - """ - targetFilter: SettingsAuthenticationTargetFilterInput +# """ +# targetFilter will restrict where the authentication integration should be +# displayed. If the value of targetFilter is null, then the authentication +# integration should be displayed in all targets. +# """ +# targetFilter: SettingsAuthenticationTargetFilterInput - """ - name is the label assigned to reference the provider of the OIDC integration. - """ - name: String - clientID: String - clientSecret: String - authorizationURL: String - tokenURL: String - jwksURI: String - issuer: String +# """ +# name is the label assigned to reference the provider of the OIDC integration. +# """ +# name: String +# clientID: String +# clientSecret: String +# authorizationURL: String +# tokenURL: String +# jwksURI: String +# issuer: String - """ - displayNameEnable when enabled, will allow Users to set and view their - displayName's. - """ - displayNameEnable: Boolean -} +# """ +# displayNameEnable when enabled, will allow Users to set and view their +# displayName's. +# """ +# displayNameEnable: Boolean +# } input SettingsGoogleAuthIntegrationInput { enabled: Boolean @@ -1408,7 +1424,6 @@ input SettingsFacebookAuthIntegrationInput { input SettingsAuthIntegrationsInput { local: SettingsLocalAuthIntegrationInput sso: SettingsSSOAuthIntegrationInput - oidc: SettingsOIDCAuthIntegrationInput google: SettingsGoogleAuthIntegrationInput facebook: SettingsFacebookAuthIntegrationInput } @@ -1917,6 +1932,7 @@ input DiscoverOIDCConfigurationInput { } type OIDCConfiguration { + issuer: String! authorizationURL: String tokenURL: String jwksURI: String @@ -1935,6 +1951,117 @@ type DiscoverOIDCConfigurationPayload { clientMutationId: String! } +################## +# createOIDCAuthIntegration +################## + +input CreateOIDCAuthIntegrationConfigurationInput { + name: String! + clientID: String! + clientSecret: String! + authorizationURL: String! + tokenURL: String! + jwksURI: String! + issuer: String! +} + +input CreateOIDCAuthIntegrationInput { + """ + configuration contains the configuration to be used to create the auth integration. + """ + configuration: CreateOIDCAuthIntegrationConfigurationInput! + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +type CreateOIDCAuthIntegrationPayload { + """ + settings is the Settings that the OIDCAuthIntegration was created on. + """ + settings: Settings + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +################## +# updateOIDCAuthIntegration +################## + +input UpdateOIDCAuthIntegrationConfigurationInput { + enabled: Boolean + name: String + clientID: String + clientSecret: String + authorizationURL: String + tokenURL: String + jwksURI: String + issuer: String +} + +input UpdateOIDCAuthIntegrationInput { + """ + id is the ID of the specific OpenID Connect integration that we are updating. + """ + id: ID! + + """ + configuration contains the configuration to be used to update the OpenID + Connect integration. + """ + configuration: UpdateOIDCAuthIntegrationConfigurationInput! + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +type UpdateOIDCAuthIntegrationPayload { + """ + settings is the Settings that the OIDCAuthIntegration was updated on. + """ + settings: Settings + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +################## +# deleteOIDCAuthIntegration +################## + +input DeleteOIDCAuthIntegrationInput { + """ + id is the ID of the specific OpenID Connect integration that we are deleting. + """ + id: ID! + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +type DeleteOIDCAuthIntegrationPayload { + """ + settings is the Settings that the OIDCAuthIntegration was deleted on. + """ + settings: Settings + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + ################## ## Mutation ################## @@ -1964,10 +2091,36 @@ type Mutation { regenerateSSOKey(input: RegenerateSSOKeyInput!): RegenerateSSOKeyPayload @auth(roles: [ADMIN]) + """ + discoverOIDCConfiguration will discover the OpenID Connect configuration based + on the provided input. + """ discoverOIDCConfiguration( input: DiscoverOIDCConfigurationInput! ): DiscoverOIDCConfigurationPayload @auth(roles: [ADMIN]) + """ + createOIDCAuthIntegration will create a OpenID Connect auth integration. + """ + createOIDCAuthIntegration( + input: CreateOIDCAuthIntegrationInput! + ): CreateOIDCAuthIntegrationPayload @auth(roles: [ADMIN]) + + """ + updateOIDCAuthIntegration will update a given OpenID Connect auth integration. + """ + updateOIDCAuthIntegration( + input: UpdateOIDCAuthIntegrationInput! + ): UpdateOIDCAuthIntegrationPayload @auth(roles: [ADMIN]) + + """ + deleteOIDCAuthIntegration will delete the specified OpenID Connect auth + integration. + """ + deleteOIDCAuthIntegration( + input: DeleteOIDCAuthIntegrationInput! + ): DeleteOIDCAuthIntegrationPayload @auth(roles: [ADMIN]) + """ createCommentReaction will create a Reaction authored by the current logged in User on a Comment. diff --git a/src/core/server/models/tenant.ts b/src/core/server/models/tenant.ts index aa66d8229..e17fa1e5f 100644 --- a/src/core/server/models/tenant.ts +++ b/src/core/server/models/tenant.ts @@ -4,7 +4,10 @@ import uuid from "uuid"; import { DeepPartial, Omit, Sub } from "talk-common/types"; import { dotize } from "talk-common/utils/dotize"; -import { GQLMODERATION_MODE } from "talk-server/graph/tenant/schema/__generated__/types"; +import { + GQLMODERATION_MODE, + GQLOIDCAuthIntegration, +} from "talk-server/graph/tenant/schema/__generated__/types"; import { Settings } from "talk-server/models/settings"; function collection(db: Db) { @@ -87,9 +90,7 @@ export async function createTenant(mongo: Db, input: CreateTenantInput) { sso: { enabled: false, }, - oidc: { - enabled: false, - }, + oidc: [], google: { enabled: false, }, @@ -245,3 +246,95 @@ export async function regenerateTenantSSOKey(db: Db, id: string) { return result.value || null; } + +export type CreateTenantOIDCAuthIntegrationInput = Omit< + GQLOIDCAuthIntegration, + "id" | "callbackURL" +>; + +export async function createTenantOIDCAuthIntegration( + mongo: Db, + id: string, + input: CreateTenantOIDCAuthIntegrationInput +) { + // Add the ID to the integration. + const integration = { + id: uuid.v4(), + ...input, + }; + + const result = await collection(mongo).findOneAndUpdate( + { id }, + // Serialize the deep update into the Tenant. + { + $push: { "auth.integrations.oidc": integration }, + }, + // False to return the updated document instead of the original + // document. + { returnOriginal: false } + ); + + return result.value || null; +} + +export type UpdateTenantOIDCAuthIntegrationInput = Partial< + Omit +>; + +export async function updateTenantOIDCAuthIntegration( + mongo: Db, + id: string, + oidcID: string, + input: UpdateTenantOIDCAuthIntegrationInput +) { + const result = await collection(mongo).findOneAndUpdate( + { id }, + { + // $set: dotize({ + // "auth.integrations.oidc.$[oidc]": input, + // }), + // FIXME: replace with the above one once the types are updated. + $set: dotize({ + "auth.integrations.oidc.$[]": input, + }), + }, + { + // Add an ArrayFilter to only update one of the OpenID Connect + // integrations. + // arrayFilters: [{ "oidc.id": oidcID }], // FIXME: add back when we got the mongo fixes in place + // False to return the updated document instead of the original + // document. + returnOriginal: false, + } + ); + + return result.value || null; +} + +/** + * deleteTenantOIDCAuthIntegration will delete the specific OpenID Connect Auth + * Integration on the Tenant. + * + * @param mongo MongoDB Database handle + * @param id the id of the Tenant + * @param oidcID the id of the OpenID Connect Auth Integration we're deleting + */ +export async function deleteTenantOIDCAuthIntegration( + mongo: Db, + id: string, + oidcID: string +) { + const result = await collection(mongo).findOneAndUpdate( + { id }, + { + $pull: { "auth.integrations.oidc": { id: oidcID } }, + }, + { + // False to return the updated document instead of the original + // document. + returnOriginal: false, + } + ); + + return result.value || null; +} diff --git a/src/core/server/services/tenant/index.ts b/src/core/server/services/tenant/index.ts index 2d0f634a9..da73c651d 100644 --- a/src/core/server/services/tenant/index.ts +++ b/src/core/server/services/tenant/index.ts @@ -2,13 +2,20 @@ import { Redis } from "ioredis"; import { Db } from "mongodb"; import { URL } from "url"; -import { GQLSettingsInput } from "talk-server/graph/tenant/schema/__generated__/types"; +import { + GQLCreateOIDCAuthIntegrationConfigurationInput, + GQLSettingsInput, + GQLUpdateOIDCAuthIntegrationConfigurationInput, +} from "talk-server/graph/tenant/schema/__generated__/types"; import { createTenant, CreateTenantInput, + createTenantOIDCAuthIntegration, + deleteTenantOIDCAuthIntegration, regenerateTenantSSOKey, Tenant, updateTenant, + updateTenantOIDCAuthIntegration, } from "talk-server/models/tenant"; import { discover } from "talk-server/app/middleware/passport/strategies/oidc/discover"; @@ -116,3 +123,81 @@ export async function discoverOIDCConfiguration(issuerString: string) { // Discover the configuration. return discover(issuer); } + +export type CreateOIDCAuthIntegration = GQLCreateOIDCAuthIntegrationConfigurationInput; + +export async function createOIDCAuthIntegration( + mongo: Db, + redis: Redis, + cache: TenantCache, + tenant: Tenant, + input: CreateOIDCAuthIntegration +) { + // Create the integration. By default, the integration is disabled. + const updatedTenant = await createTenantOIDCAuthIntegration( + mongo, + tenant.id, + { + enabled: false, + ...input, + } + ); + if (!updatedTenant) { + return null; + } + + // Update the tenant cache. + await cache.update(redis, updatedTenant); + + return updatedTenant; +} + +export type UpdateOIDCAuthIntegration = GQLUpdateOIDCAuthIntegrationConfigurationInput; + +export async function updateOIDCAuthIntegration( + mongo: Db, + redis: Redis, + cache: TenantCache, + tenant: Tenant, + oidcID: string, + input: UpdateOIDCAuthIntegration +) { + // Update the integration. By default, the integration is disabled. + const updatedTenant = await updateTenantOIDCAuthIntegration( + mongo, + tenant.id, + oidcID, + input + ); + if (!updatedTenant) { + return null; + } + + // Update the tenant cache. + await cache.update(redis, updatedTenant); + + return updatedTenant; +} + +export async function deleteOIDCAuthIntegration( + mongo: Db, + redis: Redis, + cache: TenantCache, + tenant: Tenant, + oidcID: string +) { + // Delete the integration. By default, the integration is disabled. + const updatedTenant = await deleteTenantOIDCAuthIntegration( + mongo, + tenant.id, + oidcID + ); + if (!updatedTenant) { + return null; + } + + // Update the tenant cache. + await cache.update(redis, updatedTenant); + + return updatedTenant; +}