diff --git a/README.md b/README.md index 6b8a33107..8b7b2cc8e 100644 --- a/README.md +++ b/README.md @@ -89,9 +89,15 @@ Valid environment variables for the .env file. See [.env.example](/.env.example) - `DATASET_CREATION_VALIDATION_REGEX` [string] Regular expression validation for new dataset request. Default value: "" - `PROPOSAL_GROUPS` [string] _Optional_ Comma separated list of proposal groups with permission to create any proposals. Example: "proposaladmin, proposalingestor". For more details check: [Scicat Documentation](https://scicatproject.github.io/documentation/Development/v4.x/backend/authorization.html) - `SAMPLE_GROUPS` [string] _Optional_ Comma separated list of sample groups with permission to create any samples. Example: "sampleadmin, sampleingestor". For more details check: [Scicat Documentation](https://scicatproject.github.io/documentation/Development/v4.x/backend/authorization.html) -- `ACCESS_GROUPS_STATIC_VALUES` [string] _Optional_ Comma separated list of access groups automatically assigned to all users. Example: "scicat, user" + +- `ACCESS_GROUPS_GRAPHQL_ENABLED` [string] _Optional_ Flag to enable/disable the graphql service get access groups. In order to use this service following variables needs to be configured: `ACCESS_GROUP_SERVICE_TOKEN`, `ACCESS_GROUP_SERVICE_API_URL` and `ACCESS_GROUP_SERVICE_HANDLER` respectively. Values true or false. Defaults to true. - `ACCESS_GROUPS_SERVICE_TOKEN` [string] _Optional_ Authentication token used if access groups are obtained from a third party service. This value is not used by the vanilla installation, but only if the instance is customized to use an external service to provide user groups, like the ESS example - `ACCESS_GROUP_SERVICE_API_URL` [string] _Optional_ URL of the service providing the users' access groups. This value is not used by the vanilla installation, but only if the instance is customized to use an external service to provide user groups, like the ESS example +- `ACCESS_GROUP_SERVICE_HANDLER` [string] _Optional_ Configuration property that points to the source of a module. This module provides a specific responseProcessor function for handling GraphQL responses and a query template for making GraphQL requests +- `ACCESS_GROUPS_STATIC_ENABLED` [string] _Optional_ Flag to enable/disable automatic assignment of predefined access groups to all users. Values true or false. Defaults to true. +- `ACCESS_GROUPS_STATIC_VALUES` [string] _Optional_ Comma separated list of access groups automatically assigned to all users. Example: "scicat, user" +- `ACCESS_GROUPS_OIDCPAYLOAD_ENABLED` [string] _Optional_ Flag to enable/disable fetching access groups directly from OIDC response. Requires specifying a field via `OIDC_ACCESS_GROUPS_PROPERTY` to extract access groups. Defaults to false + - `DOI_PREFIX` [string] The facility DOI prefix, with trailing slash. - `EXPRESS_SESSION_SECRET` [string] _Optional_ Secret used to set up express session. - `HTTP_MAX_REDIRECTS` [number] _Optional_ Max redirects for http requests. Defaults to 5. @@ -110,6 +116,18 @@ Valid environment variables for the .env file. See [.env.example](/.env.example) - `OIDC_SCOPE` [string] _Optional_ Space separated list of the info returned by the oidc service. Example: "openid profile email" - `OIDC_SUCCESS_URL` [string] _Optional_ SciCat Frontend auth-callback URL. Required in order to pass user credentials to SciCat Frontend after OIDC login. Example: https://myscicatfrontend/auth-callback - `OIDC_ACCESS_GROUPS` [string] _Optional_ Functionality is still unclear. +- `OIDC_ACCESS_GROUPS_PROPERTY` [string] _Optional_ Target field to get the access groups value from OIDC response. +- `OIDC_USERINFO_MAPPING_FIELD_USERNAME` [string] _Optional_ comma-separated list. Specifies the fields from the OIDC response to concatenate and use as the user's profile username. For example, setting `OIDC_USERINFO_MAPPING_FIELD_USERNAME="iss, sub"` combines the iss (issuer) and sub (subject) values from the OIDC response, resulting in a username like `myIssuer_myUserName`. This allows for customizable username definitions based on OIDC response attributes. Defaults to "preferred_username" || "name" +- `OIDC_USERINFO_MAPPING_FIELD_DISPLAYNAME` [string] _Optional_ Specifies the fields from the OIDC response and use as the user's profile displayname. For example, setting `OIDC_USERINFO_MAPPING_FIELD_DISPLAYNAME="preferred_username"` use displayName value from the OIDC response, resulting in a displayname like `myPreferredName`. This allows for customizable displayname definitions based on OIDC response attributes. Defaults to "name" +- `OIDC_USERINFO_MAPPING_FIELD_EMAIL` [string] _Optional_ Same as `OIDC_USERINFO_MAPPING_FIELD_DISPLAYNAME`. Defaults to "email" +- `OIDC_USERINFO_MAPPING_FIELD_FAMILYNAME` [string] _Optional_ Same as `OIDC_USERINFO_MAPPING_FIELD_DISPLAYNAME`. Defaults to "family_name" +- `OIDC_USERINFO_MAPPING_FIELD_ID` [string] _Optional_ Same as `OIDC_USERINFO_MAPPING_FIELD_DISPLAYNAME`. Defaults to "sub" || "user_id" +- `OIDC_USERINFO_MAPPING_FIELD_THUMBNAILPHOTO` [string] _Optional_ Same as `OIDC_USERINFO_MAPPING_FIELD_DISPLAYNAME`. Defaults to "thumbnailPhoto" +- `OIDC_USERINFO_MAPPING_FIELD_PROVIDER` [string] _Optional_ Same as `OIDC_USERINFO_MAPPING_FIELD_DISPLAYNAME`. Defaults to "iss" +- `OIDC_USERINFO_MAPPING_FIELD_GROUP` [string] _Optional_ Same as `OIDC_USERINFO_MAPPING_FIELD_DISPLAYNAME`. Defaults to "groups" +- `OIDC_USERQUERY_OPERATOR` [string] _Optional_ Specifies the operator ("or" or "and") for UserModel.findOne queries, determining the logic used to match fields like "username" or "email". Example: `UserModel.findOne({$or: {"username":"testUser", "email":"test@test.com"}})`. Defaults to "or" +- `OIDC_USERQUERY_FILTER` [string] _Optional_ Defines key-value pairs for UserModel.findOne queries, using a "key:value" format. Values should correspond to fields in the userProfile object. For instance,` OIDC_USERQUERY_FILTER="username:sub, email:email"` maps to `userProfile.sub` and `userProfile.email` respectively. Defaults to "username:username, email:email" + - `LOGBOOK_ENABLED` [string] _Optional_ Flag to enable/disable the Logbook endpoints. Values "yes" or "no". Defaults to "no". - `LOGBOOK_BASE_URL` [string] _Optional_ The base URL to the Logbook API. Only required if Logbook is enabled. diff --git a/src/auth/access-group-provider/access-group-from-graphql-api-call.service.ts b/src/auth/access-group-provider/access-group-from-graphql-api-call.service.ts index c27e47ce9..0d29f28f2 100644 --- a/src/auth/access-group-provider/access-group-from-graphql-api-call.service.ts +++ b/src/auth/access-group-provider/access-group-from-graphql-api-call.service.ts @@ -5,7 +5,6 @@ import { Injectable, Logger } from "@nestjs/common"; import { UserPayload } from "../interfaces/userPayload.interface"; import { HttpService } from "@nestjs/axios"; import { firstValueFrom } from "rxjs"; - /** * This service is used to fetch access groups from a GraphQL API. */ @@ -27,14 +26,19 @@ export class AccessGroupFromGraphQLApiService extends AccessGroupService { ): Promise { const userId = userPayload.userId as string; const query = this.graphqlTemplateQuery.replace("{{userId}}", userId); - const response = await this.callGraphQLApi(query); - const accessGroups = this.responseProcessor(response); + try { + const response = await this.callGraphQLApi(query); + const accessGroups = this.responseProcessor(response); - Logger.log( - "Access groups from graphql api call getAccessGroups: " + - accessGroups.join(","), - ); - return accessGroups; + Logger.log( + accessGroups, + "AccessGroupFromGraphQLApiService getAccessGroups:", + ); + return accessGroups; + } catch (error) { + Logger.error(error, "AccessGroupFromGraphQLApiService"); + return []; + } } async callGraphQLApi(query: string): Promise> { diff --git a/src/auth/access-group-provider/access-group-from-multiple-providers.service.ts b/src/auth/access-group-provider/access-group-from-multiple-providers.service.ts index a82bee056..64936eda5 100644 --- a/src/auth/access-group-provider/access-group-from-multiple-providers.service.ts +++ b/src/auth/access-group-provider/access-group-from-multiple-providers.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from "@nestjs/common"; +import { Injectable, Logger } from "@nestjs/common"; import { UserPayload } from "../interfaces/userPayload.interface"; import { AccessGroupService } from "./access-group.service"; diff --git a/src/auth/access-group-provider/access-group-from-payload.service.spec.ts b/src/auth/access-group-provider/access-group-from-payload.service.spec.ts index 661a9619c..bee789b95 100644 --- a/src/auth/access-group-provider/access-group-from-payload.service.spec.ts +++ b/src/auth/access-group-provider/access-group-from-payload.service.spec.ts @@ -7,9 +7,7 @@ describe("AccessGroupFromPayloadService", () => { let service: AccessGroupFromPayloadService; const mockConfigService = { - get: () => ({ - accessGroups: "accessGroups", - }), + get: () => "access_group_property", }; beforeEach(async () => { @@ -30,14 +28,15 @@ describe("AccessGroupFromPayloadService", () => { }); it("Should resolve access groups", async () => { - const expected = ["AAA", "BBB"]; - const actual = await service.getAccessGroups({ + const userPayload = { userId: "test_user", - accessGroupProperty: "access_group_property", + accessGroupProperty: "testGroups", payload: { - access_group_property: expected, + testGroups: ["AAA", "BBB"], }, - } as UserPayload); + }; + const expected = userPayload.payload.testGroups; + const actual = await service.getAccessGroups(userPayload as UserPayload); expect(actual).toEqual(expected); }); }); diff --git a/src/auth/access-group-provider/access-group-from-payload.service.ts b/src/auth/access-group-provider/access-group-from-payload.service.ts index ba661a0db..e36f5a87a 100644 --- a/src/auth/access-group-provider/access-group-from-payload.service.ts +++ b/src/auth/access-group-provider/access-group-from-payload.service.ts @@ -17,8 +17,7 @@ export class AccessGroupFromPayloadService extends AccessGroupService { //const defaultAccessGroups: string[] = []; let accessGroups: string[] = []; - const accessGroupsProperty = userPayload?.accessGroupProperty; - + const accessGroupsProperty = userPayload.accessGroupProperty; if (accessGroupsProperty) { const payload: Record | undefined = userPayload.payload; if ( @@ -30,11 +29,9 @@ export class AccessGroupFromPayloadService extends AccessGroupService { ? (payload[accessGroupsProperty] as string[]) : []; } + Logger.log(accessGroups, "AccessGroupFromPayloadService"); } - Logger.log( - "Access groups AccessGroupFromPayloadService : " + accessGroups.join(","), - ); return accessGroups; } } diff --git a/src/auth/access-group-provider/access-group-service-factory.ts b/src/auth/access-group-provider/access-group-service-factory.ts index 64aac7f9f..848470a2d 100644 --- a/src/auth/access-group-provider/access-group-service-factory.ts +++ b/src/auth/access-group-provider/access-group-service-factory.ts @@ -1,17 +1,79 @@ import { ConfigService } from "@nestjs/config"; import { AccessGroupFromStaticValuesService } from "./access-group-from-static-values.service"; import { AccessGroupService } from "./access-group.service"; - +import { AccessGroupFromGraphQLApiService } from "./access-group-from-graphql-api-call.service"; +import { AccessGroupFromPayloadService } from "./access-group-from-payload.service"; +import { HttpService } from "@nestjs/axios"; +import { AccessGroupFromMultipleProvidersService } from "./access-group-from-multiple-providers.service"; +import { Logger } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; /* * this is the default function which provides an empty array as groups */ export const accessGroupServiceFactory = { + imports: [ConfigModule], provide: AccessGroupService, useFactory: (configService: ConfigService) => { - const accessGroupsStaticValues = configService.get( - "accessGroupsStaticValues", + Logger.debug("Service factory starting", "accessGroupServiceFactory"); + const accessGroupsStaticConfig = configService.get( + "accessGroupsStaticConfig", + ); + const accessGroupsGraphQlConfig = configService.get( + "accessGroupsGraphQlConfig", ); - return new AccessGroupFromStaticValuesService(accessGroupsStaticValues); + const accessGroupsOIDCPayloadConfig = configService.get( + "accessGroupsOIDCPayloadConfig", + ); + + const accessGroupServices: AccessGroupService[] = []; + if (accessGroupsStaticConfig?.enabled == true) { + Logger.log( + JSON.stringify(accessGroupsStaticConfig), + "loading static processor", + ); + accessGroupServices.push( + new AccessGroupFromStaticValuesService(accessGroupsStaticConfig.value), + ); + } + if (accessGroupsOIDCPayloadConfig?.enabled == true) { + Logger.log( + JSON.stringify(accessGroupsOIDCPayloadConfig), + "loading oidc processor", + ); + accessGroupServices.push( + new AccessGroupFromPayloadService(configService), + ); + } + + if (accessGroupsGraphQlConfig?.enabled == true) { + Logger.log( + JSON.stringify(accessGroupsGraphQlConfig), + "loading graphql processor", + ); + + import(accessGroupsGraphQlConfig.responseProcessorSrc).then( + (rpModule) => { + const gh = rpModule.graphHandler; + const responseProcessor: ( + response: Record, + ) => string[] = gh.responseProcessor; + const graphqlTemplateQuery: string = gh.graphqlTemplateQuery; + accessGroupServices.push( + new AccessGroupFromGraphQLApiService( + graphqlTemplateQuery, + accessGroupsGraphQlConfig.apiUrl, + { + Authorization: `Bearer ${accessGroupsGraphQlConfig.token}`, + }, + responseProcessor, + new HttpService(), + ), + ); + }, + ); + } + + return new AccessGroupFromMultipleProvidersService(accessGroupServices); }, inject: [ConfigService], }; diff --git a/src/auth/interfaces/oidc-user.interface.ts b/src/auth/interfaces/oidc-user.interface.ts new file mode 100644 index 000000000..e4b83d033 --- /dev/null +++ b/src/auth/interfaces/oidc-user.interface.ts @@ -0,0 +1,16 @@ +export interface IOidcUserInfoMapping { + id: string; + username: string; + displayName: string; + familyName: string; + email: string; + thumbnailPhoto: string; + groups?: string[]; + provider?: string; + [key: string]: string | string[] | undefined; +} + +export interface IOidcUserQueryMapping { + operator: string; + filter: string[]; +} diff --git a/src/auth/strategies/oidc.strategy.ts b/src/auth/strategies/oidc.strategy.ts index 70aef336e..92a25fb45 100644 --- a/src/auth/strategies/oidc.strategy.ts +++ b/src/auth/strategies/oidc.strategy.ts @@ -8,14 +8,15 @@ import { PassportStrategy } from "@nestjs/passport"; import { FilterQuery } from "mongoose"; import { CreateUserIdentityDto } from "src/users/dto/create-user-identity.dto"; import { CreateUserDto } from "src/users/dto/create-user.dto"; -import { User, UserDocument } from "src/users/schemas/user.schema"; +import { User, UserDocument, UserSchema } from "src/users/schemas/user.schema"; import { UsersService } from "src/users/users.service"; import { Strategy, Client, - UserinfoResponse, TokenSet, Issuer, + IdTokenClaims, + UserinfoResponse, } from "openid-client"; import { AuthService } from "../auth.service"; import { Profile } from "passport"; @@ -23,6 +24,16 @@ import { UserProfile } from "src/users/schemas/user-profile.schema"; import { OidcConfig } from "src/config/configuration"; import { AccessGroupService } from "../access-group-provider/access-group.service"; import { UserPayload } from "../interfaces/userPayload.interface"; +import { + IOidcUserInfoMapping, + IOidcUserQueryMapping, +} from "../interfaces/oidc-user.interface"; + +type extendedIdTokenClaims = IdTokenClaims & + UserinfoResponse & { + groups?: string[]; + }; +type OidcProfile = Profile & UserProfile; export class BuildOpenIdClient { constructor(private configService: ConfigService) {} @@ -66,10 +77,12 @@ export class OidcStrategy extends PassportStrategy(Strategy, "oidc") { } async validate(tokenset: TokenSet): Promise> { - const userinfo: UserinfoResponse = await this.client.userinfo(tokenset); + const userinfo: extendedIdTokenClaims = tokenset.claims(); + const oidcConfig = this.configService.get("oidc"); const userProfile = this.parseUserInfo(userinfo); + const userPayload: UserPayload = { userId: userProfile.id, username: userProfile.username, @@ -80,13 +93,11 @@ export class OidcStrategy extends PassportStrategy(Strategy, "oidc") { userProfile.accessGroups = await this.accessGroupService.getAccessGroups(userPayload); - const userFilter: FilterQuery = { - $or: [ - { username: `oidc.${userProfile.username}` }, - { email: userProfile.email as string }, - ], - }; + const userFilter: FilterQuery = + this.parseQueryFilter(userProfile); + let user = await this.usersService.findOne(userFilter); + if (!user) { const createUser: CreateUserDto = { username: userProfile.username, @@ -107,7 +118,7 @@ export class OidcStrategy extends PassportStrategy(Strategy, "oidc") { credentials: {}, externalId: userProfile.id, profile: userProfile, - provider: "oidc", + provider: userProfile.provider || "oidc", userId: newUser._id, }; @@ -116,9 +127,15 @@ export class OidcStrategy extends PassportStrategy(Strategy, "oidc") { user = newUser; } else { + await this.usersService.updateUser( + { username: userProfile.username }, + user._id, + ); await this.usersService.updateUserIdentity( { profile: userProfile, + externalId: userProfile.id, + provider: userProfile.provider || "oidc", }, user._id, ); @@ -131,34 +148,119 @@ export class OidcStrategy extends PassportStrategy(Strategy, "oidc") { return returnUser; } - getUserPhoto(userinfo: UserinfoResponse) { - return userinfo.thumbnailPhoto + getUserPhoto(thumbnailPhoto: string) { + return thumbnailPhoto ? "data:image/jpeg;base64," + - Buffer.from(userinfo.thumbnailPhoto as string, "binary").toString( - "base64", - ) + Buffer.from(thumbnailPhoto, "binary").toString("base64") : "no photo"; } - parseUserInfo(userinfo: UserinfoResponse) { - type OidcProfile = Profile & UserProfile; + parseUserInfo(userinfo: extendedIdTokenClaims) { const profile = {} as OidcProfile; + const customUserInfoFields = this.configService.get( + "oidc.userInfoMapping", + ); + + // To dynamically map user info fields based on environment variables, + // set mappings like OIDC_USERINFO_MAPPING_FIELD_USERNAME=family_name. + // This assigns userinfo.family_name to oidcUser.username. + + const oidcUser: IOidcUserInfoMapping = { + id: userinfo["sub"] ?? (userinfo["user_id"] as string) ?? "", + username: userinfo["preferred_username"] ?? userinfo["name"] ?? "", + displayName: userinfo["name"] ?? "", + familyName: userinfo["family_name"] ?? "", + email: userinfo["email"] ?? "", + thumbnailPhoto: (userinfo["thumbnailPhoto"] as string) ?? "", + provider: userinfo["iss"] ?? "", + groups: userinfo["groups"] ?? [], + }; + + if (customUserInfoFields) { + Object.entries(customUserInfoFields).forEach( + ([sourceField, targetField]) => { + if (typeof targetField === "string" && targetField in userinfo) { + oidcUser[sourceField] = userinfo[targetField] as string; + } else if (Array.isArray(targetField) && targetField.length) { + const values = targetField + .filter((field) => field in userinfo) + .map((field) => userinfo[field] as string); + + if (values.length) { + oidcUser[sourceField] = values.join("_"); + } + } + }, + ); + } + // Prior to OpenID Connect Basic Client Profile 1.0 - draft 22, the "sub" // claim was named "user_id". Many providers still use the old name, so - // fallback to that. - const userId = userinfo.sub || (userinfo.user_id as string); - if (!userId) { + // fallback to that. https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims + + if (!oidcUser.id) { throw new Error("Could not find sub or user_id in userinfo response"); } - profile.id = userId; - profile.username = userinfo.preferred_username ?? userinfo.name ?? ""; - profile.displayName = userinfo.name ?? ""; - profile.emails = userinfo.email ? [{ value: userinfo.email }] : []; - profile.email = userinfo.email ?? ""; - profile.thumbnailPhoto = this.getUserPhoto(userinfo); + profile.emails = oidcUser.email ? [{ value: oidcUser.email }] : []; + profile.thumbnailPhoto = this.getUserPhoto(oidcUser.thumbnailPhoto); + profile.oidcClaims = userinfo; + + const oidcUserProfile = { ...oidcUser, ...profile }; + + return oidcUserProfile; + } + + parseQueryFilter(userProfile: OidcProfile) { + const userQuery = + this.configService.get("oidc.userQuery"); + const allowedOperators = ["and", "or"]; + const defaultFilter = + userQuery && allowedOperators.includes(userQuery.operator) + ? { + [`$${userQuery.operator}`]: [ + { username: userProfile.username }, + { email: userProfile.email }, + ], + } + : { + $or: [ + { username: userProfile.username }, + { email: userProfile.email }, + ], + }; + + if ( + !userQuery?.operator || + (userQuery?.filter && userQuery.filter.length < 1) + ) { + return defaultFilter; + } + const operator = "$" + userQuery.operator.toLowerCase(); + const filter = userQuery.filter.reduce( + (acc: Record[], mapping: string) => { + const [filterField, userProfileField] = mapping.split(":"); + if (userProfileField in userProfile && UserSchema.path(filterField)) { + acc.push({ + [filterField]: userProfile[userProfileField as keyof UserProfile], + }); + } + return acc; + }, + [], + ); + + if (filter.length === 0 || !allowedOperators.includes(userQuery.operator)) { + Logger.log( + `Executing default userQuery filter: $${JSON.stringify(defaultFilter)}`, + "OidcStrategy", + ); + return defaultFilter; + } - return profile; + const customFilter = { [operator]: filter }; + Logger.log(userQuery, "Executing custom userQuery filter", "OidcStrategy"); + return customFilter; } } diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 164bb327d..9f1ab1821 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -1,4 +1,8 @@ +import { Logger } from "@nestjs/common"; import * as fs from "fs"; +import { merge } from "lodash"; +import localconfiguration from "./localconfiguration"; +import { boolean } from "mathjs"; const configuration = () => { const accessGroupsStaticValues = @@ -22,6 +26,12 @@ const configuration = () => { const proposalGroups = process.env.PROPOSAL_GROUPS || ("" as string); const sampleGroups = process.env.SAMPLE_GROUPS || ("" as string); + const oidcUserQueryFilter = + process.env.OIDC_USERQUERY_FILTER || ("" as string); + + const oidcUsernameFieldMapping = + process.env.OIDC_USERINFO_MAPPING_FIELD_USERNAME || ("" as string); + const defaultLogger = { type: "DefaultLogger", modulePath: "./loggingProviders/defaultLogger", @@ -60,7 +70,7 @@ const configuration = () => { // Logger.log("- Create job groups : " + createJobGroups); // Logger.log("- Update job groups : " + updateJobGroups); - return { + const config = { loggerConfigs: jsonConfigMap.loggers || [defaultLogger], adminGroups: adminGroups.split(",").map((v) => v.trim()) ?? [], deleteGroups: deleteGroups.split(",").map((v) => v.trim()) ?? [], @@ -78,12 +88,21 @@ const configuration = () => { createJobGroups: createJobGroups, updateJobGroups: updateJobGroups, logoutURL: process.env.LOGOUT_URL ?? "", // Example: http://localhost:3000/ - accessGroupsStaticValues: - accessGroupsStaticValues.split(",").map((v) => v.trim()) ?? [], - accessGroupService: { + accessGroupsGraphQlConfig: { + enabled: boolean(process.env?.ACCESS_GROUPS_GRAPHQL_ENABLED || false), token: process.env.ACCESS_GROUP_SERVICE_TOKEN, apiUrl: process.env.ACCESS_GROUP_SERVICE_API_URL, + responseProcessorSrc: process.env.ACCESS_GROUP_SERVICE_HANDLER, // ts import defining the resposne processor and query + }, + accessGroupsStaticConfig: { + enabled: boolean(process.env?.ACCESS_GROUPS_STATIC_ENABLED || true), + value: accessGroupsStaticValues.split(",").map((v) => v.trim()) ?? [], + }, + accessGroupsOIDCPayloadConfig: { + enabled: boolean(process.env?.ACCESS_GROUPS_OIDCPAYLOAD_ENABLED || false), + accessGroupProperty: process.env?.OIDC_ACCESS_GROUPS_PROPERTY, // Example: groups }, + doiPrefix: process.env.DOI_PREFIX, expressSessionSecret: process.env.EXPRESS_SESSION_SECRET, functionalAccounts: [], @@ -118,6 +137,22 @@ const configuration = () => { accessGroupProperty: process.env.OIDC_ACCESS_GROUPS_PROPERTY, // Example: groups autoLogout: process.env.OIDC_AUTO_LOGOUT || false, returnURL: process.env.OIDC_RETURN_URL, + userInfoMapping: { + id: process.env.OIDC_USERINFO_MAPPING_FIELD_ID, + username: + oidcUsernameFieldMapping.split(",").map((v) => v.trim()) ?? [], // Example: "iss, username" + displayName: process.env.OIDC_USERINFO_MAPPING_FIELD_DISPLAYNAME, + familyName: process.env.OIDC_USERINFO_MAPPING_FIELD_FAMILYNAME, + emails: process.env.OIDC_USERINFO_MAPPING_FIELD_EMAILS, + email: process.env.OIDC_USERINFO_MAPPING_FIELD_EMAIL, + thumbnailPhoto: process.env.OIDC_USERINFO_MAPPING_FIELD_THUMBNAILPHOTO, + groups: process.env.OIDC_USERINFO_MAPPING_FIELD_GROUP, // Example: groups + provider: process.env.OIDC_USERINFO_MAPPING_FIELD_PROVIDER, + }, + userQuery: { + operator: process.env.OIDC_USERQUERY_OPERATOR || "or", // Example: "or" or "and" + filter: oidcUserQueryFilter.split(",").map((v) => v.trim()) ?? [], // Example: "username:username, email:email" + }, }, logbook: { enabled: @@ -174,6 +209,7 @@ const configuration = () => { policyRetentionShiftInYears: process.env.POLICY_RETENTION_SHIFT ?? -1, }, }; + return merge(config, localconfiguration); }; export type OidcConfig = ReturnType["oidc"]; diff --git a/src/config/graphqlHandler.ts b/src/config/graphqlHandler.ts new file mode 100644 index 000000000..bfc350f1e --- /dev/null +++ b/src/config/graphqlHandler.ts @@ -0,0 +1,32 @@ +type ResponseType = { + data: { + userByOIDCSub: { + proposals: { + proposalId: string; + }[]; + }; + }; +}; +/* +const responseProcessor = (response: Record) => { + const proposals = (response as ResponseType).data.userByOIDCSub?.proposals; + if (!proposals) return []; + return proposals.map((proposal) => proposal.proposalId); +}; +*/ + +export class graphHandler { + public static responseProcessor(response: Record): string[] { + const proposals = (response as ResponseType).data.userByOIDCSub?.proposals; + if (!proposals) return []; + return proposals.map((proposal) => proposal.proposalId); + } + public static graphqlTemplateQuery = ` + { + userByOIDCSub(oidcSub: "{{userId}}") { + proposals { + proposalId + } + } + }`; +} diff --git a/src/config/localconfiguration.ts b/src/config/localconfiguration.ts new file mode 100644 index 000000000..1d6ae8dc8 --- /dev/null +++ b/src/config/localconfiguration.ts @@ -0,0 +1,6 @@ +// Override this file for advanced local configuration +// -> everything that can not be done by environment variables + +const localconfiguration = {}; + +export default localconfiguration; diff --git a/src/loggers/loggingProviders/defaultLogger.ts b/src/loggers/loggingProviders/defaultLogger.ts index f9c3bb8e3..e8d3dd855 100644 --- a/src/loggers/loggingProviders/defaultLogger.ts +++ b/src/loggers/loggingProviders/defaultLogger.ts @@ -7,22 +7,22 @@ export default class DefaultLogger implements Logger { this.logger = new LocalLogger(); } log(message: string, context: Record): void { - this.logger.log(message, context); + this.logger.log(message, context ?? ""); } error(message: string, context: Record): void { - this.logger.error(message, context); + this.logger.error(message, context ?? ""); } warn(message: string, context: Record): void { - this.logger.error(message, context); + this.logger.warn(message, context ?? ""); } debug(message: string, context: Record): void { - this.logger.error(message, context); + this.logger.debug(message, context ?? ""); } exception( message: string, exception: unknown, context: Record, ): void { - this.logger.error(message, exception, context); + this.logger.error(message, exception, context ?? ""); } } diff --git a/src/users/dto/create-user-identity.dto.ts b/src/users/dto/create-user-identity.dto.ts index f12d2ed82..96171ca5c 100644 --- a/src/users/dto/create-user-identity.dto.ts +++ b/src/users/dto/create-user-identity.dto.ts @@ -5,12 +5,6 @@ export class CreateUserIdentityDto extends UpdateUserIdentityDto { @ApiProperty() readonly authStrategy?: string; - @ApiProperty() - readonly externalId?: string; - - @ApiProperty() - readonly provider?: string; - @ApiProperty() readonly userId: string; } diff --git a/src/users/dto/update-user-identity.dto.ts b/src/users/dto/update-user-identity.dto.ts index 2b52bfed7..57e166106 100644 --- a/src/users/dto/update-user-identity.dto.ts +++ b/src/users/dto/update-user-identity.dto.ts @@ -7,4 +7,10 @@ export class UpdateUserIdentityDto { @ApiProperty() readonly credentials?: Record; + + @ApiProperty() + readonly externalId?: string; + + @ApiProperty() + readonly provider?: string; } diff --git a/src/users/dto/update-user.dto.ts b/src/users/dto/update-user.dto.ts new file mode 100644 index 000000000..ef737eb86 --- /dev/null +++ b/src/users/dto/update-user.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from "@nestjs/swagger"; +import { CreateUserDto } from "./create-user.dto"; + +export class UpdateUserDto extends PartialType(CreateUserDto) {} diff --git a/src/users/schemas/user-profile.schema.ts b/src/users/schemas/user-profile.schema.ts index 4d490ba51..4b3f3da5e 100644 --- a/src/users/schemas/user-profile.schema.ts +++ b/src/users/schemas/user-profile.schema.ts @@ -25,6 +25,9 @@ export class UserProfile { @Prop({ type: [String] }) accessGroups: string[]; + + @Prop({ type: Object }) + oidcClaims?: Record; } export const UserProfileSchema = SchemaFactory.createForClass(UserProfile); diff --git a/src/users/users.service.ts b/src/users/users.service.ts index ef1f0c648..c9c4ec08f 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -30,6 +30,7 @@ import { UpdateUserIdentityDto } from "./dto/update-user-identity.dto"; import { UserPayload } from "src/auth/interfaces/userPayload.interface"; import { AccessGroupService } from "src/auth/access-group-provider/access-group.service"; import { ReturnedUserDto } from "./dto/returned-user.dto"; +import { UpdateUserDto } from "./dto/update-user.dto"; @Injectable() export class UsersService implements OnModuleInit { @@ -206,6 +207,13 @@ export class UsersService implements OnModuleInit { return user as ReturnedUserDto; } + async updateUser( + updateUserDto: UpdateUserDto, + id: string, + ): Promise { + return this.userModel.findOneAndUpdate({ _id: id }, updateUserDto).exec(); + } + async findById2JWTUser(id: string): Promise { const userIdentity = await this.userIdentityModel .findOne({ userId: id })