From e184222fa9b807b28cce6b61d41b2ec7587a8646 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Sun, 25 Aug 2024 11:39:06 +0000 Subject: [PATCH 001/248] Initial bootstrap for bff --- apps/services/bff/.eslintrc.json | 18 +++++++ apps/services/bff/README.md | 16 ++++++ apps/services/bff/esbuild.json | 52 ++++++++++++++++++ apps/services/bff/project.json | 53 +++++++++++++++++++ apps/services/bff/src/app/app.module.ts | 26 +++++++++ .../src/app/modules/auth/auth.controller.ts | 26 +++++++++ .../bff/src/app/modules/auth/auth.module.ts | 7 +++ .../src/app/modules/user/user.controller.ts | 22 ++++++++ .../bff/src/app/modules/user/user.module.ts | 7 +++ .../bff/src/environment/environment.schema.ts | 17 ++++++ .../bff/src/environment/environment.ts | 17 ++++++ apps/services/bff/src/environment/index.ts | 1 + apps/services/bff/src/main.ts | 14 +++++ apps/services/bff/tsconfig.app.json | 9 ++++ apps/services/bff/tsconfig.json | 13 +++++ apps/services/bff/tsconfig.spec.json | 9 ++++ 16 files changed, 307 insertions(+) create mode 100644 apps/services/bff/.eslintrc.json create mode 100644 apps/services/bff/README.md create mode 100644 apps/services/bff/esbuild.json create mode 100644 apps/services/bff/project.json create mode 100644 apps/services/bff/src/app/app.module.ts create mode 100644 apps/services/bff/src/app/modules/auth/auth.controller.ts create mode 100644 apps/services/bff/src/app/modules/auth/auth.module.ts create mode 100644 apps/services/bff/src/app/modules/user/user.controller.ts create mode 100644 apps/services/bff/src/app/modules/user/user.module.ts create mode 100644 apps/services/bff/src/environment/environment.schema.ts create mode 100644 apps/services/bff/src/environment/environment.ts create mode 100644 apps/services/bff/src/environment/index.ts create mode 100644 apps/services/bff/src/main.ts create mode 100644 apps/services/bff/tsconfig.app.json create mode 100644 apps/services/bff/tsconfig.json create mode 100644 apps/services/bff/tsconfig.spec.json diff --git a/apps/services/bff/.eslintrc.json b/apps/services/bff/.eslintrc.json new file mode 100644 index 000000000000..3456be9b9036 --- /dev/null +++ b/apps/services/bff/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/apps/services/bff/README.md b/apps/services/bff/README.md new file mode 100644 index 000000000000..c7b7660fa7f0 --- /dev/null +++ b/apps/services/bff/README.md @@ -0,0 +1,16 @@ +# Bff + +## About + +This service is the BFF(Backend for frontend) for various clients, i.e. admin-portal, service-portal, etc. +It is responsible for handling authorization, authentication, and other business logic for those clients. +It authenticates with identity server. +TODO - Add more details + +## Getting started + +To start the service you run `yarn start services-bff`. This starts a server on `localhost:3333`. + +## Code owners and maintainers + +- [Aranja](https://github.com/orgs/island-is/teams/aranja/members) diff --git a/apps/services/bff/esbuild.json b/apps/services/bff/esbuild.json new file mode 100644 index 000000000000..3a039143f18e --- /dev/null +++ b/apps/services/bff/esbuild.json @@ -0,0 +1,52 @@ +{ + "platform": "node", + "external": [ + "fsevents", + "@nestjs/microservices", + "class-transformer", + "cache-manager", + "@nestjs/websockets/socket-module", + "class-validator", + "class-transformer", + "@nestjs/microservices/microservices-module", + "apollo-server-fastify", + "@elastic/elasticsearch", + "fastify-swagger", + "@nestjs/mongoose", + "@nestjs/typeorm", + "dd-trace", + "express", + "http-errors", + "graphql", + "pg", + "winston", + "util-deprecate", + "source-map-resolve", + "atob", + "logform", + "pg-native", + "form-data", + "bull", + "ioredis", + "p-map", + "lru-cache", + "pdfkit", + "tslib", + "sequelize", + "configcat-node", + "@protobufjs/aspromise", + "@protobufjs/base64", + "@protobufjs/codegen", + "@protobufjs/eventemitter", + "@protobufjs/fetch", + "@protobufjs/float", + "@protobufjs/inquire", + "@protobufjs/path", + "@protobufjs/pool", + "@protobufjs/utf8", + "pseudomap", + "safer-buffer", + "@mikro-orm/core" + ], + "keepNames": true +} diff --git a/apps/services/bff/project.json b/apps/services/bff/project.json new file mode 100644 index 000000000000..17f07ec6cef3 --- /dev/null +++ b/apps/services/bff/project.json @@ -0,0 +1,53 @@ +{ + "name": "services-bff", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "apps/services/bff/src", + "projectType": "application", + "prefix": "services-bff", + "tags": [], + "targets": { + "build": { + "executor": "./tools/executors/node:build", + "options": { + "outputPath": "dist/apps/services/bff", + "main": "apps/services/bff/src/main.ts", + "tsConfig": "apps/services/bff/tsconfig.app.json", + "maxWorkers": 2 + }, + "configurations": { + "production": { + "optimization": true, + "extractLicenses": true, + "inspect": false + } + }, + "outputs": ["{options.outputPath}"] + }, + "serve": { + "executor": "@nx/js:node", + "options": { + "buildTarget": "services-bff:build" + } + }, + "lint": { + "executor": "@nx/eslint:lint" + }, + "test": { + "executor": "@nx/jest:jest", + "options": { + "jestConfig": "apps/services/bff/jest.config.ts", + "runInBand": true + }, + "outputs": ["{workspaceRoot}/coverage/apps/services/bff"] + }, + "dev": { + "executor": "nx:run-commands", + "options": { + "commands": [ + "yarn start services-bff" + ], + "parallel": true + } + } + } +} diff --git a/apps/services/bff/src/app/app.module.ts b/apps/services/bff/src/app/app.module.ts new file mode 100644 index 000000000000..4718967234a1 --- /dev/null +++ b/apps/services/bff/src/app/app.module.ts @@ -0,0 +1,26 @@ +import { AuthModule as BaseAuthModule } from '@island.is/auth-nest-tools' +import { AuditModule } from '@island.is/nest/audit' +import { + ConfigModule, + IdsClientConfig, + XRoadConfig, +} from '@island.is/nest/config' +import { FeatureFlagConfig } from '@island.is/nest/feature-flags' +import { Module } from '@nestjs/common' +import { environment } from '../environment' +import { UserModule } from './modules/user/user.module' +import { AuthModule as ApplicationAuthModule } from './modules/auth/auth.module' + +@Module({ + imports: [ + AuditModule.forRoot(environment.audit), + BaseAuthModule.register(environment.auth), + ConfigModule.forRoot({ + isGlobal: true, + load: [XRoadConfig, FeatureFlagConfig, IdsClientConfig], + }), + UserModule, + ApplicationAuthModule, + ], +}) +export class AppModule {} diff --git a/apps/services/bff/src/app/modules/auth/auth.controller.ts b/apps/services/bff/src/app/modules/auth/auth.controller.ts new file mode 100644 index 000000000000..fa497e80d17a --- /dev/null +++ b/apps/services/bff/src/app/modules/auth/auth.controller.ts @@ -0,0 +1,26 @@ +import { IdsUserGuard, ScopesGuard } from '@island.is/auth-nest-tools' +import { + Controller, + Post, + Get, + UseGuards, + VERSION_NEUTRAL, +} from '@nestjs/common' + +@UseGuards(IdsUserGuard, ScopesGuard) +@Controller({ + version: [VERSION_NEUTRAL, '1'], +}) +export class AuthController { + constructor() {} + + @Post('login') + async login(): Promise { + return true + } + + @Get('logout') + async logout(): Promise { + return true + } +} diff --git a/apps/services/bff/src/app/modules/auth/auth.module.ts b/apps/services/bff/src/app/modules/auth/auth.module.ts new file mode 100644 index 000000000000..3135d2a3d55a --- /dev/null +++ b/apps/services/bff/src/app/modules/auth/auth.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common' +import { AuthController } from './auth.controller' + +@Module({ + controllers: [AuthController], +}) +export class AuthModule {} diff --git a/apps/services/bff/src/app/modules/user/user.controller.ts b/apps/services/bff/src/app/modules/user/user.controller.ts new file mode 100644 index 000000000000..a231dd97c9e7 --- /dev/null +++ b/apps/services/bff/src/app/modules/user/user.controller.ts @@ -0,0 +1,22 @@ +import { IdsUserGuard, Scopes, ScopesGuard } from '@island.is/auth-nest-tools' +import { ApiScope } from '@island.is/auth/scopes' +import { Audit } from '@island.is/nest/audit' +import { Controller, Get, UseGuards, VERSION_NEUTRAL } from '@nestjs/common' +import { ApiOkResponse } from '@nestjs/swagger' + +@UseGuards(IdsUserGuard, ScopesGuard) +@Controller({ + path: 'user', + version: [VERSION_NEUTRAL, '1'], +}) +export class UserController { + constructor() {} + + @Scopes('@admin.island.is/delegation-system') + @Get() + @Audit() + @ApiOkResponse({ type: Boolean }) + async getUser(): Promise { + return true + } +} diff --git a/apps/services/bff/src/app/modules/user/user.module.ts b/apps/services/bff/src/app/modules/user/user.module.ts new file mode 100644 index 000000000000..1b734f71f3ca --- /dev/null +++ b/apps/services/bff/src/app/modules/user/user.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common' +import { UserController } from './user.controller' + +@Module({ + controllers: [UserController], +}) +export class UserModule {} diff --git a/apps/services/bff/src/environment/environment.schema.ts b/apps/services/bff/src/environment/environment.schema.ts new file mode 100644 index 000000000000..2865383f758f --- /dev/null +++ b/apps/services/bff/src/environment/environment.schema.ts @@ -0,0 +1,17 @@ +import { z } from 'zod' + +export const environmentSchema = z.strictObject({ + production: z.boolean(), + port: z.number(), + audit: z.strictObject({ + defaultNamespace: z.string(), + groupName: z.string().optional(), + serviceName: z.string().optional(), + }), + auth: z.strictObject({ + issuer: z.string(), + audience: z.string().array(), + }), +}) + +export type BffEnvironmentSchema = z.infer diff --git a/apps/services/bff/src/environment/environment.ts b/apps/services/bff/src/environment/environment.ts new file mode 100644 index 000000000000..987beb4ead4e --- /dev/null +++ b/apps/services/bff/src/environment/environment.ts @@ -0,0 +1,17 @@ +import type { BffEnvironmentSchema } from './environment.schema' + +export const environment: BffEnvironmentSchema = { + production: process.env.NODE_ENV === 'production', + audit: { + groupName: process.env.AUDIT_GROUP_NAME, + defaultNamespace: '@island.is/bff', + serviceName: 'services-bff', + }, + port: 4444, + auth: { + issuer: + process.env.IDENTITY_SERVER_ISSUER_URL ?? + 'https://identity-server.dev01.devland.is', + audience: ['@admin.island.is'], + }, +} diff --git a/apps/services/bff/src/environment/index.ts b/apps/services/bff/src/environment/index.ts new file mode 100644 index 000000000000..7426df499a14 --- /dev/null +++ b/apps/services/bff/src/environment/index.ts @@ -0,0 +1 @@ +export { environment } from './environment' diff --git a/apps/services/bff/src/main.ts b/apps/services/bff/src/main.ts new file mode 100644 index 000000000000..92fcffe459e0 --- /dev/null +++ b/apps/services/bff/src/main.ts @@ -0,0 +1,14 @@ +import { bootstrap } from '@island.is/infra-nest-server' + +import { AppModule } from './app/app.module' +import { environment } from './environment' + +bootstrap({ + appModule: AppModule, + name: 'bff', + port: environment.port, + globalPrefix: 'bff', + healthCheck: { + timeout: 1000, + }, +}) diff --git a/apps/services/bff/tsconfig.app.json b/apps/services/bff/tsconfig.app.json new file mode 100644 index 000000000000..99bb983d6ee9 --- /dev/null +++ b/apps/services/bff/tsconfig.app.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "types": ["node"] + }, + "exclude": ["**/*.spec.ts", "**/*.test.ts", "**/test/**", "jest.config.ts,"], + "include": ["**/*.ts"] +} diff --git a/apps/services/bff/tsconfig.json b/apps/services/bff/tsconfig.json new file mode 100644 index 000000000000..25873177db3f --- /dev/null +++ b/apps/services/bff/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/apps/services/bff/tsconfig.spec.json b/apps/services/bff/tsconfig.spec.json new file mode 100644 index 000000000000..2068b86d4f4a --- /dev/null +++ b/apps/services/bff/tsconfig.spec.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": ["**/*.spec.ts", "**/*.d.ts", "jest.config.ts"] +} From 1b3b774fab2d3f4a0949f7f71a69755886b9afdb Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Sun, 25 Aug 2024 11:39:20 +0000 Subject: [PATCH 002/248] environment audit not optional --- apps/services/bff/src/environment/environment.schema.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/services/bff/src/environment/environment.schema.ts b/apps/services/bff/src/environment/environment.schema.ts index 2865383f758f..7e61230981df 100644 --- a/apps/services/bff/src/environment/environment.schema.ts +++ b/apps/services/bff/src/environment/environment.schema.ts @@ -5,8 +5,8 @@ export const environmentSchema = z.strictObject({ port: z.number(), audit: z.strictObject({ defaultNamespace: z.string(), - groupName: z.string().optional(), - serviceName: z.string().optional(), + groupName: z.string(), + serviceName: z.string(), }), auth: z.strictObject({ issuer: z.string(), From 040b77b4e2f78343640f5478f95a404df5ae7ffe Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Mon, 26 Aug 2024 11:32:18 +0000 Subject: [PATCH 003/248] Add infra file for admin-portal --- apps/services/bff/infra/admin-portal.infra.ts | 53 +++++++++++++++++++ apps/services/bff/src/app/app.module.ts | 7 +-- apps/services/bff/src/app/bff.config.ts | 32 +++++++++++ .../src/app/modules/user/user.controller.ts | 4 +- .../bff/src/environment/environment.schema.ts | 2 + .../bff/src/environment/environment.ts | 16 +++--- apps/services/bff/src/utils/env.ts | 11 ++++ 7 files changed, 114 insertions(+), 11 deletions(-) create mode 100644 apps/services/bff/infra/admin-portal.infra.ts create mode 100644 apps/services/bff/src/app/bff.config.ts create mode 100644 apps/services/bff/src/utils/env.ts diff --git a/apps/services/bff/infra/admin-portal.infra.ts b/apps/services/bff/infra/admin-portal.infra.ts new file mode 100644 index 000000000000..acfd8ded90a1 --- /dev/null +++ b/apps/services/bff/infra/admin-portal.infra.ts @@ -0,0 +1,53 @@ +import { json, service, ServiceBuilder } from '../../../../infra/src/dsl/dsl' +import { Base, Client, RskProcuring } from '../../../../infra/src/dsl/xroad' + +const createRedisUrl = (clusterId: string) => + `clustercfg.general-redis-cluster-group.${clusterId}.euw1.cache.amazonaws.com:6379` + +const BFF_REDIS_NODES = { + dev: createRedisUrl('5fzau3'), + staging: createRedisUrl('ab9ckb'), + prod: createRedisUrl('dnugi2'), +} + +export const serviceSetup = (): ServiceBuilder<'services-bff'> => + service('services-bff') + .namespace('services-bff') + .image('services-bff') + .env({ + PORT: '4444', + IDENTITY_SERVER_CLIENT_ID: '@admin.island.is/web', + IDENTITY_SERVER_ISSUER_URL: { + dev: 'https://identity-server.dev01.devland.is', + staging: 'https://identity-server.staging01.devland.is', + prod: 'https://innskra.island.is', + }, + BFF_REDIS_NODES, + IDENTITY_SERVER_CLIENT_SCOPES: json([ + '@admin.island.is/delegation-system', + ]), + }) + .secrets({ + IDENTITY_SERVER_CLIENT_SECRET: + '/k8s/services-auth/IDENTITY_SERVER_CLIENT_SECRET', + NATIONAL_REGISTRY_IDS_CLIENT_SECRET: + '/k8s/xroad/client/NATIONAL-REGISTRY/IDENTITYSERVER_SECRET', + }) + .xroad(Base, Client, RskProcuring) + .readiness('/health/check') + .liveness('/liveness') + .replicaCount({ + default: 2, + min: 2, + max: 10, + }) + .resources({ + limits: { + cpu: '400m', + memory: '512Mi', + }, + requests: { + cpu: '100m', + memory: '256Mi', + }, + }) diff --git a/apps/services/bff/src/app/app.module.ts b/apps/services/bff/src/app/app.module.ts index 4718967234a1..da6f29bed5ac 100644 --- a/apps/services/bff/src/app/app.module.ts +++ b/apps/services/bff/src/app/app.module.ts @@ -8,8 +8,9 @@ import { import { FeatureFlagConfig } from '@island.is/nest/feature-flags' import { Module } from '@nestjs/common' import { environment } from '../environment' +import { BffConfig } from './bff.config' +import { AuthModule as AppAuthModule } from './modules/auth/auth.module' import { UserModule } from './modules/user/user.module' -import { AuthModule as ApplicationAuthModule } from './modules/auth/auth.module' @Module({ imports: [ @@ -17,10 +18,10 @@ import { AuthModule as ApplicationAuthModule } from './modules/auth/auth.module' BaseAuthModule.register(environment.auth), ConfigModule.forRoot({ isGlobal: true, - load: [XRoadConfig, FeatureFlagConfig, IdsClientConfig], + load: [XRoadConfig, FeatureFlagConfig, IdsClientConfig, BffConfig], }), UserModule, - ApplicationAuthModule, + AppAuthModule, ], }) export class AppModule {} diff --git a/apps/services/bff/src/app/bff.config.ts b/apps/services/bff/src/app/bff.config.ts new file mode 100644 index 000000000000..ba31dcd10918 --- /dev/null +++ b/apps/services/bff/src/app/bff.config.ts @@ -0,0 +1,32 @@ +import { defineConfig } from '@island.is/nest/config' +import { z } from 'zod' +import { isProduction } from '../environment/environment' + +const BffConfigSchema = z.object({ + redis: z.object({ + nodes: z.array(z.string()), + ssl: z.boolean(), + }), + identityServerClientId: z.string(), +}) + +export const BffConfig = defineConfig({ + name: 'BffConfig', + schema: BffConfigSchema, + load(env) { + return { + redis: { + nodes: env.requiredJSON('BFF_REDIS_NODES', [ + 'localhost:7000', + 'localhost:7001', + 'localhost:7002', + 'localhost:7003', + 'localhost:7004', + 'localhost:7005', + ]), + ssl: isProduction, + }, + identityServerClientId: env.required('IDENTITY_SERVER_CLIENT_ID'), + } + }, +}) diff --git a/apps/services/bff/src/app/modules/user/user.controller.ts b/apps/services/bff/src/app/modules/user/user.controller.ts index a231dd97c9e7..7c0970ca636f 100644 --- a/apps/services/bff/src/app/modules/user/user.controller.ts +++ b/apps/services/bff/src/app/modules/user/user.controller.ts @@ -1,8 +1,8 @@ import { IdsUserGuard, Scopes, ScopesGuard } from '@island.is/auth-nest-tools' -import { ApiScope } from '@island.is/auth/scopes' import { Audit } from '@island.is/nest/audit' import { Controller, Get, UseGuards, VERSION_NEUTRAL } from '@nestjs/common' import { ApiOkResponse } from '@nestjs/swagger' +import { environment } from '../../../environment' @UseGuards(IdsUserGuard, ScopesGuard) @Controller({ @@ -12,7 +12,7 @@ import { ApiOkResponse } from '@nestjs/swagger' export class UserController { constructor() {} - @Scopes('@admin.island.is/delegation-system') + @Scopes(...environment.auth.scopes) @Get() @Audit() @ApiOkResponse({ type: Boolean }) diff --git a/apps/services/bff/src/environment/environment.schema.ts b/apps/services/bff/src/environment/environment.schema.ts index 7e61230981df..a7743ccd5972 100644 --- a/apps/services/bff/src/environment/environment.schema.ts +++ b/apps/services/bff/src/environment/environment.schema.ts @@ -11,6 +11,8 @@ export const environmentSchema = z.strictObject({ auth: z.strictObject({ issuer: z.string(), audience: z.string().array(), + clientId: z.string(), + scopes: z.string().array(), }), }) diff --git a/apps/services/bff/src/environment/environment.ts b/apps/services/bff/src/environment/environment.ts index 987beb4ead4e..1a242c51f877 100644 --- a/apps/services/bff/src/environment/environment.ts +++ b/apps/services/bff/src/environment/environment.ts @@ -1,17 +1,21 @@ +import { requiredString } from '../utils/env' import type { BffEnvironmentSchema } from './environment.schema' +export const isProduction = process.env.NODE_ENV === 'production' +const port = parseInt(process.env.PORT as string, 10) || 4444 + export const environment: BffEnvironmentSchema = { - production: process.env.NODE_ENV === 'production', + production: isProduction, audit: { - groupName: process.env.AUDIT_GROUP_NAME, + groupName: requiredString('AUDIT_GROUP_NAME'), defaultNamespace: '@island.is/bff', serviceName: 'services-bff', }, - port: 4444, + port, auth: { - issuer: - process.env.IDENTITY_SERVER_ISSUER_URL ?? - 'https://identity-server.dev01.devland.is', + issuer: requiredString('IDENTITY_SERVER_ISSUER_URL'), audience: ['@admin.island.is'], + clientId: requiredString('IDENTITY_SERVER_CLIENT_ID'), + scopes: JSON.parse(requiredString('IDENTITY_SERVER_CLIENT_SCOPES') ?? []), }, } diff --git a/apps/services/bff/src/utils/env.ts b/apps/services/bff/src/utils/env.ts new file mode 100644 index 000000000000..fa498fd82a03 --- /dev/null +++ b/apps/services/bff/src/utils/env.ts @@ -0,0 +1,11 @@ +export const requiredString = (key: string): string => { + const value = process.env[key] + + if (value === undefined || value === null) { + throw new Error( + `Environment variable ${key} is required but was not found.`, + ) + } + + return value +} From 62adda8ca185e047dc83365c73e734d5ad6fdc68 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Thu, 29 Aug 2024 21:43:00 +0000 Subject: [PATCH 004/248] Auth login controller and service implemented --- apps/services/bff/docker-compose.yml | 14 ++ apps/services/bff/infra/admin-portal.infra.ts | 39 ++- apps/services/bff/jest.config.ts | 17 ++ apps/services/bff/project.json | 7 + apps/services/bff/src/app/app.module.ts | 3 +- apps/services/bff/src/app/bff.config.ts | 8 +- .../src/app/modules/auth/auth.controller.ts | 41 +++- .../bff/src/app/modules/auth/auth.module.ts | 7 +- .../bff/src/app/modules/auth/auth.service.ts | 231 ++++++++++++++++++ .../auth/dto/callback-login-query.dto.ts | 15 ++ .../app/modules/auth/dto/login-query.dto.ts | 11 + .../src/app/modules/auth/pkce.service.spec.ts | 55 +++++ .../bff/src/app/modules/auth/pkce.service.ts | 75 ++++++ .../bff/src/app/modules/cache/cache.module.ts | 31 +++ .../bff/src/environment/environment.schema.ts | 27 +- .../bff/src/environment/environment.ts | 16 +- apps/services/bff/src/environment/index.ts | 2 +- apps/services/bff/src/main.ts | 2 +- apps/services/bff/src/utils/env.ts | 29 +++ libs/auth/react/src/lib/bff/BFFContext.tsx | 0 libs/auth/react/src/lib/bff/BFFProvider.tsx | 0 libs/shared/utils/src/index.ts | 1 + libs/shared/utils/src/lib/isString.ts | 2 + 23 files changed, 593 insertions(+), 40 deletions(-) create mode 100644 apps/services/bff/docker-compose.yml create mode 100644 apps/services/bff/jest.config.ts create mode 100644 apps/services/bff/src/app/modules/auth/auth.service.ts create mode 100644 apps/services/bff/src/app/modules/auth/dto/callback-login-query.dto.ts create mode 100644 apps/services/bff/src/app/modules/auth/dto/login-query.dto.ts create mode 100644 apps/services/bff/src/app/modules/auth/pkce.service.spec.ts create mode 100644 apps/services/bff/src/app/modules/auth/pkce.service.ts create mode 100644 apps/services/bff/src/app/modules/cache/cache.module.ts create mode 100644 libs/auth/react/src/lib/bff/BFFContext.tsx create mode 100644 libs/auth/react/src/lib/bff/BFFProvider.tsx create mode 100644 libs/shared/utils/src/lib/isString.ts diff --git a/apps/services/bff/docker-compose.yml b/apps/services/bff/docker-compose.yml new file mode 100644 index 000000000000..d6df1d4b1fc6 --- /dev/null +++ b/apps/services/bff/docker-compose.yml @@ -0,0 +1,14 @@ +version: '3.3' + +services: + redis-cluster: + container_name: bff_redis_cluster + image: docker.io/grokzen/redis-cluster:6.0.16 + privileged: true + environment: + - IP=0.0.0.0 + ports: + - '7000-7005:7000-7005' + +networks: + local: diff --git a/apps/services/bff/infra/admin-portal.infra.ts b/apps/services/bff/infra/admin-portal.infra.ts index acfd8ded90a1..a5ce67fe9fbd 100644 --- a/apps/services/bff/infra/admin-portal.infra.ts +++ b/apps/services/bff/infra/admin-portal.infra.ts @@ -5,9 +5,9 @@ const createRedisUrl = (clusterId: string) => `clustercfg.general-redis-cluster-group.${clusterId}.euw1.cache.amazonaws.com:6379` const BFF_REDIS_NODES = { - dev: createRedisUrl('5fzau3'), - staging: createRedisUrl('ab9ckb'), - prod: createRedisUrl('dnugi2'), + dev: createRedisUrl('dummy'), // TODO - change to correct cluster id + staging: createRedisUrl('dummy'), // TODO - change to correct cluster id + prod: createRedisUrl('dummy'), // TODO - change to correct cluster id } export const serviceSetup = (): ServiceBuilder<'services-bff'> => @@ -15,7 +15,6 @@ export const serviceSetup = (): ServiceBuilder<'services-bff'> => .namespace('services-bff') .image('services-bff') .env({ - PORT: '4444', IDENTITY_SERVER_CLIENT_ID: '@admin.island.is/web', IDENTITY_SERVER_ISSUER_URL: { dev: 'https://identity-server.dev01.devland.is', @@ -23,17 +22,39 @@ export const serviceSetup = (): ServiceBuilder<'services-bff'> => prod: 'https://innskra.island.is', }, BFF_REDIS_NODES, + BFF_CALLBACKS_LOGIN_REDIRECT_URI: { + dev: 'https://beta.dev01.devland.is/stjornbord/bff/callbacks/login', + staging: 'https://beta.staging01.devland.is/stjornbord/bff/callbacks/login', + prod: 'https://island.is/stjornbord/bff/callbacks/login', + }, IDENTITY_SERVER_CLIENT_SCOPES: json([ '@admin.island.is/delegation-system', + '@admin.island.is/ads', + '@admin.island.is/bff', + '@admin.island.is/ads:explicit', + '@admin.island.is/delegations', + '@admin.island.is/regulations', + '@admin.island.is/regulations:manage', + '@admin.island.is/icelandic-names-registry', + '@admin.island.is/document-provider', + '@admin.island.is/application-system:admin', + '@admin.island.is/application-system:institution', + '@admin.island.is/auth', + '@admin.island.is/auth:admin', + '@admin.island.is/petitions', + '@admin.island.is/service-desk', + '@admin.island.is/signature-collection:process', + '@admin.island.is/signature-collection:manage', + '@admin.island.is/form-system', + '@admin.island.is/form-system:admin', ]), + BFF_API_URL_PREFIX: 'stjornbord/bff', }) .secrets({ - IDENTITY_SERVER_CLIENT_SECRET: - '/k8s/services-auth/IDENTITY_SERVER_CLIENT_SECRET', - NATIONAL_REGISTRY_IDS_CLIENT_SECRET: - '/k8s/xroad/client/NATIONAL-REGISTRY/IDENTITYSERVER_SECRET', + BFF_IDENTITY_SERVER_SECRET: + 'TODO - add secret', }) - .xroad(Base, Client, RskProcuring) + .xroad(Base, Client) .readiness('/health/check') .liveness('/liveness') .replicaCount({ diff --git a/apps/services/bff/jest.config.ts b/apps/services/bff/jest.config.ts new file mode 100644 index 000000000000..07386b89f4f5 --- /dev/null +++ b/apps/services/bff/jest.config.ts @@ -0,0 +1,17 @@ +/* eslint-disable */ +export default { + preset: './jest.preset.js', + rootDir: '../../..', + roots: [__dirname], + transform: { + '^.+\\.[tj]sx?$': [ + 'ts-jest', + { tsconfig: `${__dirname}/tsconfig.spec.json` }, + ], + }, + testEnvironment: 'node', + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'html'], + coverageDirectory: '/coverage/apps/services/bff', + displayName: 'bff', + collectCoverageFrom: ['src/**/*.ts'], +} diff --git a/apps/services/bff/project.json b/apps/services/bff/project.json index 17f07ec6cef3..6b644d1869d2 100644 --- a/apps/services/bff/project.json +++ b/apps/services/bff/project.json @@ -40,6 +40,13 @@ }, "outputs": ["{workspaceRoot}/coverage/apps/services/bff"] }, + "dev-services": { + "executor": "nx:run-commands", + "options": { + "command": "docker compose up -d", + "cwd": "apps/services/bff" + } + }, "dev": { "executor": "nx:run-commands", "options": { diff --git a/apps/services/bff/src/app/app.module.ts b/apps/services/bff/src/app/app.module.ts index da6f29bed5ac..e9de6b2984c6 100644 --- a/apps/services/bff/src/app/app.module.ts +++ b/apps/services/bff/src/app/app.module.ts @@ -10,6 +10,7 @@ import { Module } from '@nestjs/common' import { environment } from '../environment' import { BffConfig } from './bff.config' import { AuthModule as AppAuthModule } from './modules/auth/auth.module' +import { CacheModule } from './modules/cache/cache.module' import { UserModule } from './modules/user/user.module' @Module({ @@ -24,4 +25,4 @@ import { UserModule } from './modules/user/user.module' AppAuthModule, ], }) -export class AppModule {} +export class AppModule { } diff --git a/apps/services/bff/src/app/bff.config.ts b/apps/services/bff/src/app/bff.config.ts index ba31dcd10918..092ee7b7638e 100644 --- a/apps/services/bff/src/app/bff.config.ts +++ b/apps/services/bff/src/app/bff.config.ts @@ -1,13 +1,15 @@ import { defineConfig } from '@island.is/nest/config' +import { authSchema } from '../environment/environment.schema' + import { z } from 'zod' -import { isProduction } from '../environment/environment' +import { isProduction, environment } from '../environment' const BffConfigSchema = z.object({ redis: z.object({ nodes: z.array(z.string()), ssl: z.boolean(), }), - identityServerClientId: z.string(), + auth: authSchema, }) export const BffConfig = defineConfig({ @@ -26,7 +28,7 @@ export const BffConfig = defineConfig({ ]), ssl: isProduction, }, - identityServerClientId: env.required('IDENTITY_SERVER_CLIENT_ID'), + auth: environment.auth, } }, }) diff --git a/apps/services/bff/src/app/modules/auth/auth.controller.ts b/apps/services/bff/src/app/modules/auth/auth.controller.ts index fa497e80d17a..c6ebb9f8098a 100644 --- a/apps/services/bff/src/app/modules/auth/auth.controller.ts +++ b/apps/services/bff/src/app/modules/auth/auth.controller.ts @@ -1,26 +1,43 @@ -import { IdsUserGuard, ScopesGuard } from '@island.is/auth-nest-tools' import { Controller, - Post, Get, - UseGuards, - VERSION_NEUTRAL, + Query, + Res, + ValidationPipe, + VERSION_NEUTRAL } from '@nestjs/common' +import { Response } from 'express' +import { AuthService } from './auth.service' +import { CallbackLoginQueryDto } from './dto/callback-login-query.dto' +import { LoginQueryDto } from './dto/login-query.dto' + +const authValidationPipe = new ValidationPipe({ + transform: true, + transformOptions: { enableImplicitConversion: true }, + forbidNonWhitelisted: true, +}) -@UseGuards(IdsUserGuard, ScopesGuard) @Controller({ version: [VERSION_NEUTRAL, '1'], }) export class AuthController { - constructor() {} + constructor(private authService: AuthService) { } - @Post('login') - async login(): Promise { - return true + @Get('login') + async login( + @Res() res: Response, + @Query(authValidationPipe) + query: LoginQueryDto, + ): Promise { + return this.authService.login(res, query) } - @Get('logout') - async logout(): Promise { - return true + @Get('callbacks/login') + async callback( + @Res() res: Response, + @Query(authValidationPipe) + query: CallbackLoginQueryDto, + ): Promise { + return this.authService.callback(res, query) } } diff --git a/apps/services/bff/src/app/modules/auth/auth.module.ts b/apps/services/bff/src/app/modules/auth/auth.module.ts index 3135d2a3d55a..82f629161ce7 100644 --- a/apps/services/bff/src/app/modules/auth/auth.module.ts +++ b/apps/services/bff/src/app/modules/auth/auth.module.ts @@ -1,7 +1,12 @@ import { Module } from '@nestjs/common' import { AuthController } from './auth.controller' +import { AuthService } from './auth.service' +import { CacheModule } from '../cache/cache.module' +import { PKCEService } from './pkce.service' @Module({ + imports: [CacheModule], controllers: [AuthController], + providers: [AuthService, PKCEService], }) -export class AuthModule {} +export class AuthModule { } diff --git a/apps/services/bff/src/app/modules/auth/auth.service.ts b/apps/services/bff/src/app/modules/auth/auth.service.ts new file mode 100644 index 000000000000..395449d852b4 --- /dev/null +++ b/apps/services/bff/src/app/modules/auth/auth.service.ts @@ -0,0 +1,231 @@ +import { Logger, LOGGER_PROVIDER } from '@island.is/logging' +import { BadRequestException, Inject, Injectable } from '@nestjs/common' +import { ConfigType } from '@nestjs/config' +import { Cache as CacheManager } from 'cache-manager' +import { Response } from 'express' + +import { CACHE_MANAGER } from '@nestjs/cache-manager' +import { uuid } from 'uuidv4' +import { BffConfig } from '../../bff.config' +import { CallbackLoginQueryDto } from './dto/callback-login-query.dto' +import { LoginQueryDto } from './dto/login-query.dto' +import { PKCEService } from './pkce.service' + +export type ParResponse = { + request_uri: string + expires_in: number +} + +@Injectable() +export class AuthService { + private readonly baseUrl + + constructor( + @Inject(LOGGER_PROVIDER) + private logger: Logger, + + @Inject(BffConfig.KEY) + private readonly config: ConfigType, + + @Inject(CACHE_MANAGER) + private readonly cacheManager: CacheManager, + + private readonly pkceService: PKCEService, + ) { + this.baseUrl = this.config.auth.issuer + } + + private async saveToCache({ + key, + value, + ttl + }: { + key: string + value: unknown + // Time to live in milliseconds + ttl?: number + }): Promise { + await this.cacheManager.set(key, value, ttl) + } + + private async getFromCache(key: string) { + const value = await this.cacheManager.get(key) + + if (!value) { + throw new BadRequestException('Not found') + } + + return value as Value + } + + /** + * Creates s unique key with session id. + * type is either 'attempt' or 'current' + * attempt represents the login attempt + * current represents the current login session + */ + private createSessionKeyType(type: 'attempt' | 'current', sid: string) { + return `${type}_${sid}` + } + + /** + * Validates the redirect URI to ensure blocking of external URLs. + */ + private async validateRedirectUri(uri: string, allowedUris: string[]) { + // Convert wildcard patterns to regular expressions + const regexPatterns = allowedUris.map((pattern) => { + // Escape special regex characters and replace '*' with a regex pattern to match any characters + const regexPattern = pattern + .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // Escape special characters for regex + .replace(/\\\*/g, '.*') // Convert '*' to '.*' to match any characters + + // Create a regex from the pattern and ensure it matches the entire URL + return new RegExp(`^${regexPattern}$`) + }) + + // Check if the URL matches any of the allowed patterns + return regexPatterns.some((regex) => regex.test(uri)) + } + + private async postRequest( + endpoint: string, + body: Record, + ): Promise { + try { + const response = await fetch(`${this.baseUrl}${endpoint}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams(body).toString(), + }) + + if (!response.ok) { + throw new BadRequestException(`HTTP error! Status: ${response.status}`) + } + + return await response.json() + } catch (error) { + this.logger.error(`Error making request to ${endpoint}:`, error) + throw new BadRequestException(`Failed to fetch from ${endpoint}`) + } + } + + /** + * Fetches the PAR (Pushed Authorization Requests) from the Ids + */ + private async fetchPAR({ + sid, + codeChallenge, + }: { + sid: string + codeChallenge: string + }) { + return this.postRequest('/connect/par', { + client_id: this.config.auth.clientId, + client_secret: this.config.auth.secret, + redirect_uri: this.config.auth.callbacksLoginRedirectUri, + response_type: 'code', + response_mode: 'query', + scope: ['openid', 'profile', this.config.auth.scopes].join(' '), + state: sid, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + }) + } + + // Fetches tokens using the authorization code and code verifier + private async fetchTokens({ + code, + codeVerifier, + }: { + code: string + codeVerifier: string + }) { + return this.postRequest('/connect/token', { + grant_type: 'authorization_code', + code, + client_secret: this.config.auth.secret, + client_id: this.config.auth.clientId, + redirect_uri: this.config.auth.callbacksLoginRedirectUri, + code_verifier: codeVerifier, + }) + } + + async login( + res: Response, + { target_link_uri: targetLinkUri, login_hint: loginHint }: LoginQueryDto, + ) { + // Validate return_url if it is provided + if ( + targetLinkUri && + !this.validateRedirectUri( + targetLinkUri, + this.config.auth.allowedRedirectUris, + ) + ) { + throw new BadRequestException('Invalid return_url') + } + + const sid = uuid() + const codeVerifier = await this.pkceService.generateCodeVerifier() + const codeChallenge = await this.pkceService.generateCodeChallenge( + codeVerifier, + ) + + await this.saveToCache({ + key: this.createSessionKeyType('attempt', sid), + value: { + targetLinkUri, + ...(loginHint && { loginHint }), + // Code verifier to be used in the callback + codeVerifier, + }, + ttl: 60 * 60 * 24 * 7, // 1 week + }) + + const parResponse = await this.fetchPAR({ sid, codeChallenge }) + + return res.redirect( + `${this.baseUrl}/connect/authorize?request_uri=${parResponse.request_uri}&client_id=${this.config.auth.clientId}`, + ) + } + + async callback(res: Response, query: CallbackLoginQueryDto) { + // Get login attempt from cache + const loginAttemptData = await this.getFromCache<{ + targetLinkUri?: string + loginHint?: string + codeVerifier: string + }>(this.createSessionKeyType('attempt', query.state)) + + // Get tokens from the authorization code + const tokenResponse = await this.fetchTokens({ + code: query.code, + codeVerifier: loginAttemptData.codeVerifier, + }) + + const sid = uuid() + + // Save the tokenResponse to the cache + await this.saveToCache({ + key: this.createSessionKeyType('current', sid), + value: tokenResponse, + ttl: 60 * 60, // 1 hour + }) + + // Clean up the login attempt from the cache since we have a successful login. + await this.cacheManager.del( + this.createSessionKeyType('attempt', query.state), + ) + + // Create session cookie with successful login session id + res.cookie('sid', sid, { + httpOnly: true, + secure: true, + sameSite: 'strict', + }) + + return res.redirect(loginAttemptData.targetLinkUri || '/') + } +} diff --git a/apps/services/bff/src/app/modules/auth/dto/callback-login-query.dto.ts b/apps/services/bff/src/app/modules/auth/dto/callback-login-query.dto.ts new file mode 100644 index 000000000000..2cb960fc8fd9 --- /dev/null +++ b/apps/services/bff/src/app/modules/auth/dto/callback-login-query.dto.ts @@ -0,0 +1,15 @@ +import { IsString } from 'class-validator' + +export class CallbackLoginQueryDto { + @IsString() + code!: string + + @IsString() + scope!: string + + @IsString() + state!: string + + @IsString() + session_state!: string +} diff --git a/apps/services/bff/src/app/modules/auth/dto/login-query.dto.ts b/apps/services/bff/src/app/modules/auth/dto/login-query.dto.ts new file mode 100644 index 000000000000..1b02347e08d3 --- /dev/null +++ b/apps/services/bff/src/app/modules/auth/dto/login-query.dto.ts @@ -0,0 +1,11 @@ +import { IsOptional, IsString } from 'class-validator' + +export class LoginQueryDto { + @IsOptional() + @IsString() + target_link_uri?: string + + @IsOptional() + @IsString() + login_hint?: string +} diff --git a/apps/services/bff/src/app/modules/auth/pkce.service.spec.ts b/apps/services/bff/src/app/modules/auth/pkce.service.spec.ts new file mode 100644 index 000000000000..b08f76aa03dd --- /dev/null +++ b/apps/services/bff/src/app/modules/auth/pkce.service.spec.ts @@ -0,0 +1,55 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { PKCEService } from './pkce.service'; + +describe('PKCEService', () => { + let service: PKCEService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [PKCEService], + }).compile(); + + service = module.get(PKCEService); + }); + + describe('generateVerifier', () => { + const verifierTestCases = [ + { length: 50, description: 'default length 50' }, + { length: 64, description: 'specified length 64' }, + ]; + + verifierTestCases.forEach(({ length, description }) => { + it(`should generate a verifier of ${description}`, async () => { + const verifier = await service.generateVerifier(length); + expect(verifier).toHaveLength(length); + expect(verifier).toMatch(/^[a-zA-Z0-9-._~]+$/); // Check allowed characters + }); + }); + }); + + describe('generateCodeVerifier', () => { + it('should generate a code verifier of default length 50', async () => { + const verifier = await service.generateCodeVerifier(); + expect(verifier).toHaveLength(50); + expect(verifier).toMatch(/^[a-zA-Z0-9-._~]+$/); // Match allowed characters + }); + }); + + describe('generateCodeChallenge', () => { + it('should generate a valid code challenge from a given verifier', async () => { + const verifier = 'testVerifier123'; + const challenge = await service.generateCodeChallenge(verifier); + expect(challenge).toBeDefined(); + expect(challenge).toMatch(/^[a-zA-Z0-9-_]+$/); // Match base64url format + }); + }); + + describe('getRandomValues', () => { + it('should generate an array of random values of specified length', async () => { + const length = 10; + const randomValues = await service.getRandomValues(length); + expect(randomValues).toBeInstanceOf(Uint8Array); + expect(randomValues).toHaveLength(length); + }); + }); +}); diff --git a/apps/services/bff/src/app/modules/auth/pkce.service.ts b/apps/services/bff/src/app/modules/auth/pkce.service.ts new file mode 100644 index 000000000000..3fe9b1ea7828 --- /dev/null +++ b/apps/services/bff/src/app/modules/auth/pkce.service.ts @@ -0,0 +1,75 @@ +import { Injectable } from '@nestjs/common'; +import { promisify } from 'util'; +import * as crypto from 'crypto'; + +const randomBytesAsync = promisify(crypto.randomBytes); + +@Injectable() +export class PKCEService { + /** + * Generate a PKCE code verifier + */ + public async generateCodeVerifier(): Promise { + return this.generateVerifier(50); // Generates a 50-character long verifier by default + } + + /** + * Generate a PKCE code challenge based on the verifier + * Uses SHA-256 hashing and Base64 URL encoding + */ + public async generateCodeChallenge(codeVerifier: string): Promise { + // Convert the verifier to a Uint8Array + const encoder = new TextEncoder(); + const data = encoder.encode(codeVerifier); + + // Use Web Crypto API for async hashing + const hashBuffer = await crypto.webcrypto.subtle.digest('SHA-256', data); + + // Convert the buffer to a Uint8Array + const hashArray = new Uint8Array(hashBuffer); + + // and then Base64 URL encode + return this.base64UrlEncode(Buffer.from(hashArray)); + } + + /** + * Creates an array of length "size" of random bytes + * @returns Array of random ints (0 to 255) + */ + async getRandomValues(size: number): Promise { + const randomBytes = await randomBytesAsync(size); + + return new Uint8Array(randomBytes); + } + + /** + * Generate a PKCE challenge verifier + * Generates cryptographically strong random string + */ + async generateVerifier(length = 50): Promise { + const mask = + 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~'; + + let result = ''; + const randomUints = await this.getRandomValues(length); + + for (let i = 0; i < length; i++) { + // Cap the value of the randomIndex to mask.length - 1 + const randomIndex = randomUints[i] % mask.length; + result += mask[randomIndex]; + } + + return result; + } + + /** + * Base64 URL encode the buffer input + */ + private base64UrlEncode(buffer: Buffer): string { + return buffer + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + } +} diff --git a/apps/services/bff/src/app/modules/cache/cache.module.ts b/apps/services/bff/src/app/modules/cache/cache.module.ts new file mode 100644 index 000000000000..6748845ad4d4 --- /dev/null +++ b/apps/services/bff/src/app/modules/cache/cache.module.ts @@ -0,0 +1,31 @@ +import { DynamicModule } from '@nestjs/common' +import { CacheModule as NestCacheModule } from '@nestjs/cache-manager' +import { redisInsStore } from 'cache-manager-ioredis-yet' +import { createRedisCluster } from '@island.is/cache' +import { ConfigType } from '@nestjs/config' +import { BffConfig } from '../../bff.config' + +let CacheModule: DynamicModule + +export const CACHE_MODULE_KEY = 'BFFModuleCache' + +if (process.env.NODE_ENV === 'test' || process.env.INIT_SCHEMA === 'true') { + CacheModule = NestCacheModule.register() +} else { + CacheModule = NestCacheModule.registerAsync({ + useFactory: ({ redis: { ssl, nodes } }: ConfigType) => ( + { + store: redisInsStore( + createRedisCluster({ + name: 'bff', + ssl, + nodes, + }), + ), + } + ), + inject: [BffConfig.KEY], + }) +} + +export { CacheModule } diff --git a/apps/services/bff/src/environment/environment.schema.ts b/apps/services/bff/src/environment/environment.schema.ts index a7743ccd5972..0fd4d280533a 100644 --- a/apps/services/bff/src/environment/environment.schema.ts +++ b/apps/services/bff/src/environment/environment.schema.ts @@ -1,19 +1,34 @@ import { z } from 'zod' +export const authSchema = z.strictObject({ + issuer: z.string(), + clientId: z.string(), + audience: z.string().array(), + scopes: z.string().array(), + allowedRedirectUris: z.string().array(), + secret: z.string(), + callbacksLoginRedirectUri: z.string(), +}) + export const environmentSchema = z.strictObject({ production: z.boolean(), port: z.number(), + /** + * The global prefix for the API + */ + globalPrefix: z.string(), + /** + * Audit configuration + */ audit: z.strictObject({ defaultNamespace: z.string(), groupName: z.string(), serviceName: z.string(), }), - auth: z.strictObject({ - issuer: z.string(), - audience: z.string().array(), - clientId: z.string(), - scopes: z.string().array(), - }), + /** + * Identity server configuration + */ + auth: authSchema, }) export type BffEnvironmentSchema = z.infer diff --git a/apps/services/bff/src/environment/environment.ts b/apps/services/bff/src/environment/environment.ts index 1a242c51f877..ee4a5ff69584 100644 --- a/apps/services/bff/src/environment/environment.ts +++ b/apps/services/bff/src/environment/environment.ts @@ -1,11 +1,12 @@ -import { requiredString } from '../utils/env' +import { requiredString, requiredStringArray } from '../utils/env' import type { BffEnvironmentSchema } from './environment.schema' export const isProduction = process.env.NODE_ENV === 'production' -const port = parseInt(process.env.PORT as string, 10) || 4444 +const port = parseInt(process.env.PORT as string, 10) || 3333 export const environment: BffEnvironmentSchema = { production: isProduction, + globalPrefix: requiredString('BFF_API_URL_PREFIX'), audit: { groupName: requiredString('AUDIT_GROUP_NAME'), defaultNamespace: '@island.is/bff', @@ -13,9 +14,12 @@ export const environment: BffEnvironmentSchema = { }, port, auth: { - issuer: requiredString('IDENTITY_SERVER_ISSUER_URL'), - audience: ['@admin.island.is'], - clientId: requiredString('IDENTITY_SERVER_CLIENT_ID'), - scopes: JSON.parse(requiredString('IDENTITY_SERVER_CLIENT_SCOPES') ?? []), + issuer: requiredString('BFF_IDENTITY_SERVER_ISSUER_URL'), + audience: requiredStringArray('BFF_IDENTITY_SERVER_AUDIENCE'), + clientId: requiredString('BFF_IDENTITY_SERVER_CLIENT_ID'), + scopes: requiredStringArray('BFF_IDENTITY_SERVER_CLIENT_SCOPES'), + allowedRedirectUris: requiredStringArray('BFF_ALLOWED_REDIRECT_URIS'), + secret: requiredString('BFF_IDENTITY_SERVER_SECRET'), + callbacksLoginRedirectUri: requiredString('BFF_CALLBACKS_LOGIN_REDIRECT_URI'), }, } diff --git a/apps/services/bff/src/environment/index.ts b/apps/services/bff/src/environment/index.ts index 7426df499a14..1953a2e9b9f0 100644 --- a/apps/services/bff/src/environment/index.ts +++ b/apps/services/bff/src/environment/index.ts @@ -1 +1 @@ -export { environment } from './environment' +export * from './environment' diff --git a/apps/services/bff/src/main.ts b/apps/services/bff/src/main.ts index 92fcffe459e0..ffd1e28ef0ba 100644 --- a/apps/services/bff/src/main.ts +++ b/apps/services/bff/src/main.ts @@ -7,7 +7,7 @@ bootstrap({ appModule: AppModule, name: 'bff', port: environment.port, - globalPrefix: 'bff', + globalPrefix: environment.globalPrefix, healthCheck: { timeout: 1000, }, diff --git a/apps/services/bff/src/utils/env.ts b/apps/services/bff/src/utils/env.ts index fa498fd82a03..1ab6fa409451 100644 --- a/apps/services/bff/src/utils/env.ts +++ b/apps/services/bff/src/utils/env.ts @@ -1,3 +1,6 @@ +/** + * Validates that an environment variable is a string and returns it as value + */ export const requiredString = (key: string): string => { const value = process.env[key] @@ -9,3 +12,29 @@ export const requiredString = (key: string): string => { return value } + +/** + * Validates that an environment variable is a JSON-stringified array of strings and returns it as value + */ +export const requiredStringArray = (key: string): string[] => { + const value = requiredString(key) + + try { + // Parse the JSON string into an array + const parsedArray = JSON.parse(value) + + // Ensure that the parsed value is an array of strings + if ( + !Array.isArray(parsedArray) || + !parsedArray.every((item) => typeof item === 'string') + ) { + throw new Error() + } + + return parsedArray + } catch (error) { + throw new Error( + `Environment variable ${key} is not a valid JSON-stringified array of strings`, + ) + } +} diff --git a/libs/auth/react/src/lib/bff/BFFContext.tsx b/libs/auth/react/src/lib/bff/BFFContext.tsx new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/libs/auth/react/src/lib/bff/BFFProvider.tsx b/libs/auth/react/src/lib/bff/BFFProvider.tsx new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/libs/shared/utils/src/index.ts b/libs/shared/utils/src/index.ts index 1e696acbf6ad..0dd2f28a25ca 100644 --- a/libs/shared/utils/src/index.ts +++ b/libs/shared/utils/src/index.ts @@ -22,3 +22,4 @@ export * from './lib/date' export * from './lib/shouldLinkBeAnAnchorTag' export * from './lib/videoEmbed' export * from './lib/web' +export { isString } from './lib/isString' diff --git a/libs/shared/utils/src/lib/isString.ts b/libs/shared/utils/src/lib/isString.ts new file mode 100644 index 000000000000..137d0c384292 --- /dev/null +++ b/libs/shared/utils/src/lib/isString.ts @@ -0,0 +1,2 @@ +export const isString = (str: T | string): str is string => + typeof str === 'string' From 6b6ee140901eb05628f4452978cacdd5a3ea80f8 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Fri, 30 Aug 2024 14:53:01 +0000 Subject: [PATCH 005/248] Updates to auth and user modules and services --- apps/portals/admin/project.json | 31 +++-- apps/portals/admin/proxy.config.json | 6 + apps/services/bff/infra/admin-portal.infra.ts | 25 ++-- apps/services/bff/src/app/app.module.ts | 11 +- apps/services/bff/src/app/bff.config.ts | 2 +- .../src/app/modules/auth/auth.controller.ts | 10 +- .../bff/src/app/modules/auth/auth.module.ts | 5 +- .../bff/src/app/modules/auth/auth.service.ts | 119 +++++++++--------- .../bff/src/app/modules/auth/auth.types.ts | 7 ++ .../bff/src/app/modules/auth/pkce.service.ts | 41 +++--- .../bff/src/app/modules/cache/cache.module.ts | 20 ++- .../src/app/modules/cache/cache.service.ts | 49 ++++++++ .../src/app/modules/user/user.controller.ts | 18 +-- .../bff/src/app/modules/user/user.module.ts | 5 + .../bff/src/app/modules/user/user.service.ts | 40 ++++++ .../bff/src/environment/environment.ts | 4 +- 16 files changed, 247 insertions(+), 146 deletions(-) create mode 100644 apps/portals/admin/proxy.config.json create mode 100644 apps/services/bff/src/app/modules/auth/auth.types.ts create mode 100644 apps/services/bff/src/app/modules/cache/cache.service.ts create mode 100644 apps/services/bff/src/app/modules/user/user.service.ts diff --git a/apps/portals/admin/project.json b/apps/portals/admin/project.json index d7fc9572a5f0..0562b15e383d 100644 --- a/apps/portals/admin/project.json +++ b/apps/portals/admin/project.json @@ -3,11 +3,15 @@ "$schema": "../../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "apps/portals/admin/src", "projectType": "application", - "tags": ["scope:portals-admin"], + "tags": [ + "scope:portals-admin" + ], "targets": { "build": { "executor": "@nx/webpack:webpack", - "outputs": ["{options.outputPath}"], + "outputs": [ + "{options.outputPath}" + ], "defaultConfiguration": "production", "options": { "compiler": "babel", @@ -22,7 +26,9 @@ "apps/portals/admin/src/mockServiceWorker.js", "apps/portals/admin/src/assets" ], - "styles": ["apps/portals/admin/src/styles.css"], + "styles": [ + "apps/portals/admin/src/styles.css" + ], "scripts": [], "webpackConfig": "apps/portals/admin/webpack.config.js" }, @@ -49,13 +55,16 @@ "node scripts/dockerfile-assets/bash/extract-environment.js apps/portals/admin/src" ] }, - "outputs": ["{workspaceRoot}/apps/portals/admin/src/index.html"] + "outputs": [ + "{workspaceRoot}/apps/portals/admin/src/index.html" + ] }, "serve": { "executor": "@nx/webpack:dev-server", "options": { "buildTarget": "portals-admin:build", - "hmr": true + "hmr": true, + "proxyConfig": "apps/portals/admin/proxy.config.json" }, "configurations": { "production": { @@ -74,7 +83,9 @@ }, "test": { "executor": "@nx/jest:jest", - "outputs": ["{workspaceRoot}/coverage/apps/portals/admin"], + "outputs": [ + "{workspaceRoot}/coverage/apps/portals/admin" + ], "options": { "jestConfig": "apps/portals/admin/jest.config.ts" } @@ -88,14 +99,18 @@ "dev-init": { "executor": "nx:run-commands", "options": { - "commands": ["yarn get-secrets portals-admin"], + "commands": [ + "yarn get-secrets portals-admin" + ], "parallel": false } }, "dev": { "executor": "nx:run-commands", "options": { - "commands": ["yarn start portals-admin"], + "commands": [ + "yarn start portals-admin" + ], "parallel": true } }, diff --git a/apps/portals/admin/proxy.config.json b/apps/portals/admin/proxy.config.json new file mode 100644 index 000000000000..797fbdcff738 --- /dev/null +++ b/apps/portals/admin/proxy.config.json @@ -0,0 +1,6 @@ +{ + "/stjornbord/bff/user": { + "target": "http://localhost:3333", + "secure": false + } +} diff --git a/apps/services/bff/infra/admin-portal.infra.ts b/apps/services/bff/infra/admin-portal.infra.ts index a5ce67fe9fbd..25a0efcede68 100644 --- a/apps/services/bff/infra/admin-portal.infra.ts +++ b/apps/services/bff/infra/admin-portal.infra.ts @@ -1,30 +1,21 @@ import { json, service, ServiceBuilder } from '../../../../infra/src/dsl/dsl' -import { Base, Client, RskProcuring } from '../../../../infra/src/dsl/xroad' -const createRedisUrl = (clusterId: string) => - `clustercfg.general-redis-cluster-group.${clusterId}.euw1.cache.amazonaws.com:6379` - -const BFF_REDIS_NODES = { - dev: createRedisUrl('dummy'), // TODO - change to correct cluster id - staging: createRedisUrl('dummy'), // TODO - change to correct cluster id - prod: createRedisUrl('dummy'), // TODO - change to correct cluster id -} - -export const serviceSetup = (): ServiceBuilder<'services-bff'> => - service('services-bff') +export const serviceSetup = (): ServiceBuilder<'services-bff-admin-portal'> => + service('services-bff-admin-portal') .namespace('services-bff') .image('services-bff') + .redis() .env({ - IDENTITY_SERVER_CLIENT_ID: '@admin.island.is/web', + IDENTITY_SERVER_CLIENT_ID: '@admin.island.is/bff', IDENTITY_SERVER_ISSUER_URL: { dev: 'https://identity-server.dev01.devland.is', staging: 'https://identity-server.staging01.devland.is', prod: 'https://innskra.island.is', }, - BFF_REDIS_NODES, BFF_CALLBACKS_LOGIN_REDIRECT_URI: { dev: 'https://beta.dev01.devland.is/stjornbord/bff/callbacks/login', - staging: 'https://beta.staging01.devland.is/stjornbord/bff/callbacks/login', + staging: + 'https://beta.staging01.devland.is/stjornbord/bff/callbacks/login', prod: 'https://island.is/stjornbord/bff/callbacks/login', }, IDENTITY_SERVER_CLIENT_SCOPES: json([ @@ -51,10 +42,8 @@ export const serviceSetup = (): ServiceBuilder<'services-bff'> => BFF_API_URL_PREFIX: 'stjornbord/bff', }) .secrets({ - BFF_IDENTITY_SERVER_SECRET: - 'TODO - add secret', + BFF_IDENTITY_SERVER_SECRET: 'TODO - add secret', }) - .xroad(Base, Client) .readiness('/health/check') .liveness('/liveness') .replicaCount({ diff --git a/apps/services/bff/src/app/app.module.ts b/apps/services/bff/src/app/app.module.ts index e9de6b2984c6..1576428a66cd 100644 --- a/apps/services/bff/src/app/app.module.ts +++ b/apps/services/bff/src/app/app.module.ts @@ -1,16 +1,11 @@ import { AuthModule as BaseAuthModule } from '@island.is/auth-nest-tools' import { AuditModule } from '@island.is/nest/audit' -import { - ConfigModule, - IdsClientConfig, - XRoadConfig, -} from '@island.is/nest/config' +import { ConfigModule, IdsClientConfig } from '@island.is/nest/config' import { FeatureFlagConfig } from '@island.is/nest/feature-flags' import { Module } from '@nestjs/common' import { environment } from '../environment' import { BffConfig } from './bff.config' import { AuthModule as AppAuthModule } from './modules/auth/auth.module' -import { CacheModule } from './modules/cache/cache.module' import { UserModule } from './modules/user/user.module' @Module({ @@ -19,10 +14,10 @@ import { UserModule } from './modules/user/user.module' BaseAuthModule.register(environment.auth), ConfigModule.forRoot({ isGlobal: true, - load: [XRoadConfig, FeatureFlagConfig, IdsClientConfig, BffConfig], + load: [FeatureFlagConfig, IdsClientConfig, BffConfig], }), UserModule, AppAuthModule, ], }) -export class AppModule { } +export class AppModule {} diff --git a/apps/services/bff/src/app/bff.config.ts b/apps/services/bff/src/app/bff.config.ts index 092ee7b7638e..9845ceda0e45 100644 --- a/apps/services/bff/src/app/bff.config.ts +++ b/apps/services/bff/src/app/bff.config.ts @@ -18,7 +18,7 @@ export const BffConfig = defineConfig({ load(env) { return { redis: { - nodes: env.requiredJSON('BFF_REDIS_NODES', [ + nodes: env.requiredJSON('REDIS_URL_NODE_01', [ 'localhost:7000', 'localhost:7001', 'localhost:7002', diff --git a/apps/services/bff/src/app/modules/auth/auth.controller.ts b/apps/services/bff/src/app/modules/auth/auth.controller.ts index c6ebb9f8098a..781736600349 100644 --- a/apps/services/bff/src/app/modules/auth/auth.controller.ts +++ b/apps/services/bff/src/app/modules/auth/auth.controller.ts @@ -2,11 +2,12 @@ import { Controller, Get, Query, + Req, Res, ValidationPipe, - VERSION_NEUTRAL + VERSION_NEUTRAL, } from '@nestjs/common' -import { Response } from 'express' +import { Response, Request } from 'express' import { AuthService } from './auth.service' import { CallbackLoginQueryDto } from './dto/callback-login-query.dto' import { LoginQueryDto } from './dto/login-query.dto' @@ -21,15 +22,16 @@ const authValidationPipe = new ValidationPipe({ version: [VERSION_NEUTRAL, '1'], }) export class AuthController { - constructor(private authService: AuthService) { } + constructor(private authService: AuthService) {} @Get('login') async login( + @Req() req: Request, @Res() res: Response, @Query(authValidationPipe) query: LoginQueryDto, ): Promise { - return this.authService.login(res, query) + return this.authService.login({ req, res, query }) } @Get('callbacks/login') diff --git a/apps/services/bff/src/app/modules/auth/auth.module.ts b/apps/services/bff/src/app/modules/auth/auth.module.ts index 82f629161ce7..1d2bfe19ff04 100644 --- a/apps/services/bff/src/app/modules/auth/auth.module.ts +++ b/apps/services/bff/src/app/modules/auth/auth.module.ts @@ -3,10 +3,11 @@ import { AuthController } from './auth.controller' import { AuthService } from './auth.service' import { CacheModule } from '../cache/cache.module' import { PKCEService } from './pkce.service' +import { CacheService } from '../cache/cache.service' @Module({ imports: [CacheModule], controllers: [AuthController], - providers: [AuthService, PKCEService], + providers: [AuthService, PKCEService, CacheService], }) -export class AuthModule { } +export class AuthModule {} diff --git a/apps/services/bff/src/app/modules/auth/auth.service.ts b/apps/services/bff/src/app/modules/auth/auth.service.ts index 395449d852b4..7f107153d901 100644 --- a/apps/services/bff/src/app/modules/auth/auth.service.ts +++ b/apps/services/bff/src/app/modules/auth/auth.service.ts @@ -1,15 +1,16 @@ import { Logger, LOGGER_PROVIDER } from '@island.is/logging' import { BadRequestException, Inject, Injectable } from '@nestjs/common' import { ConfigType } from '@nestjs/config' -import { Cache as CacheManager } from 'cache-manager' -import { Response } from 'express' +import { Request, Response } from 'express' -import { CACHE_MANAGER } from '@nestjs/cache-manager' import { uuid } from 'uuidv4' +import { environment } from '../../../environment' import { BffConfig } from '../../bff.config' +import { CacheService } from '../cache/cache.service' import { CallbackLoginQueryDto } from './dto/callback-login-query.dto' import { LoginQueryDto } from './dto/login-query.dto' import { PKCEService } from './pkce.service' +import { TokenResponse } from './auth.types' export type ParResponse = { request_uri: string @@ -27,47 +28,12 @@ export class AuthService { @Inject(BffConfig.KEY) private readonly config: ConfigType, - @Inject(CACHE_MANAGER) - private readonly cacheManager: CacheManager, - private readonly pkceService: PKCEService, + private readonly cacheService: CacheService, ) { this.baseUrl = this.config.auth.issuer } - private async saveToCache({ - key, - value, - ttl - }: { - key: string - value: unknown - // Time to live in milliseconds - ttl?: number - }): Promise { - await this.cacheManager.set(key, value, ttl) - } - - private async getFromCache(key: string) { - const value = await this.cacheManager.get(key) - - if (!value) { - throw new BadRequestException('Not found') - } - - return value as Value - } - - /** - * Creates s unique key with session id. - * type is either 'attempt' or 'current' - * attempt represents the login attempt - * current represents the current login session - */ - private createSessionKeyType(type: 'attempt' | 'current', sid: string) { - return `${type}_${sid}` - } - /** * Validates the redirect URI to ensure blocking of external URLs. */ @@ -76,8 +42,10 @@ export class AuthService { const regexPatterns = allowedUris.map((pattern) => { // Escape special regex characters and replace '*' with a regex pattern to match any characters const regexPattern = pattern - .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // Escape special characters for regex - .replace(/\\\*/g, '.*') // Convert '*' to '.*' to match any characters + // Escape special characters for regex + .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + // Convert '*' to '.*' to match any characters + .replace(/\\\*/g, '.*') // Create a regex from the pattern and ensure it matches the entire URL return new RegExp(`^${regexPattern}$`) @@ -87,6 +55,9 @@ export class AuthService { return regexPatterns.some((regex) => regex.test(uri)) } + /** + * Reusable fetch fn to make POST requests + */ private async postRequest( endpoint: string, body: Record, @@ -107,6 +78,7 @@ export class AuthService { return await response.json() } catch (error) { this.logger.error(`Error making request to ${endpoint}:`, error) + throw new BadRequestException(`Failed to fetch from ${endpoint}`) } } @@ -117,9 +89,11 @@ export class AuthService { private async fetchPAR({ sid, codeChallenge, + loginHint, }: { sid: string codeChallenge: string + loginHint?: string }) { return this.postRequest('/connect/par', { client_id: this.config.auth.clientId, @@ -131,6 +105,7 @@ export class AuthService { state: sid, code_challenge: codeChallenge, code_challenge_method: 'S256', + ...(loginHint && { login_hint: loginHint }), }) } @@ -142,7 +117,7 @@ export class AuthService { code: string codeVerifier: string }) { - return this.postRequest('/connect/token', { + return this.postRequest('/connect/token', { grant_type: 'authorization_code', code, client_secret: this.config.auth.secret, @@ -152,11 +127,16 @@ export class AuthService { }) } - async login( - res: Response, - { target_link_uri: targetLinkUri, login_hint: loginHint }: LoginQueryDto, - ) { - // Validate return_url if it is provided + async login({ + req, + res, + query: { target_link_uri: targetLinkUri, login_hint: loginHint }, + }: { + req: Request + res: Response + query: LoginQueryDto + }) { + // Validate targetLinkUri if it is provided if ( targetLinkUri && !this.validateRedirectUri( @@ -164,27 +144,39 @@ export class AuthService { this.config.auth.allowedRedirectUris, ) ) { - throw new BadRequestException('Invalid return_url') + throw new BadRequestException('Invalid target_link_uri') } + // Generate a unique session id to be used in the login flow const sid = uuid() + + // Generate a code verifier and code challenge to enhance security const codeVerifier = await this.pkceService.generateCodeVerifier() const codeChallenge = await this.pkceService.generateCodeChallenge( codeVerifier, ) - await this.saveToCache({ - key: this.createSessionKeyType('attempt', sid), + // Get the calling URL + const originUrl = `${( + req.headers['origin'] || + req.headers['referer'] || + '' + ).replace(/\/$/, '')}${environment.globalPrefix}` + + await this.cacheService.save({ + key: this.cacheService.createSessionKeyType('attempt', sid), value: { - targetLinkUri, + // Fallback if targetLinkUri is not provided + originUrl, + targetLinkUri: targetLinkUri, ...(loginHint && { loginHint }), // Code verifier to be used in the callback codeVerifier, }, - ttl: 60 * 60 * 24 * 7, // 1 week + ttl: 60 * 60 * 24 * 7 * 1000, // 1 week }) - const parResponse = await this.fetchPAR({ sid, codeChallenge }) + const parResponse = await this.fetchPAR({ sid, codeChallenge, loginHint }) return res.redirect( `${this.baseUrl}/connect/authorize?request_uri=${parResponse.request_uri}&client_id=${this.config.auth.clientId}`, @@ -193,13 +185,14 @@ export class AuthService { async callback(res: Response, query: CallbackLoginQueryDto) { // Get login attempt from cache - const loginAttemptData = await this.getFromCache<{ + const loginAttemptData = await this.cacheService.get<{ targetLinkUri?: string loginHint?: string codeVerifier: string - }>(this.createSessionKeyType('attempt', query.state)) + originUrl: string + }>(this.cacheService.createSessionKeyType('attempt', query.state)) - // Get tokens from the authorization code + // Get tokens and user information from the authorization code const tokenResponse = await this.fetchTokens({ code: query.code, codeVerifier: loginAttemptData.codeVerifier, @@ -208,15 +201,15 @@ export class AuthService { const sid = uuid() // Save the tokenResponse to the cache - await this.saveToCache({ - key: this.createSessionKeyType('current', sid), + await this.cacheService.save({ + key: this.cacheService.createSessionKeyType('current', sid), value: tokenResponse, - ttl: 60 * 60, // 1 hour + ttl: 60 * 60 * 1000, // 1 hour }) // Clean up the login attempt from the cache since we have a successful login. - await this.cacheManager.del( - this.createSessionKeyType('attempt', query.state), + await this.cacheService.delete( + this.cacheService.createSessionKeyType('attempt', query.state), ) // Create session cookie with successful login session id @@ -226,6 +219,8 @@ export class AuthService { sameSite: 'strict', }) - return res.redirect(loginAttemptData.targetLinkUri || '/') + return res.redirect( + loginAttemptData.targetLinkUri || loginAttemptData.originUrl, + ) } } diff --git a/apps/services/bff/src/app/modules/auth/auth.types.ts b/apps/services/bff/src/app/modules/auth/auth.types.ts new file mode 100644 index 000000000000..dbcee733653a --- /dev/null +++ b/apps/services/bff/src/app/modules/auth/auth.types.ts @@ -0,0 +1,7 @@ +export type TokenResponse = { + id_token: string + access_token: string + expires_in: number + token_type: string + scope: string +} diff --git a/apps/services/bff/src/app/modules/auth/pkce.service.ts b/apps/services/bff/src/app/modules/auth/pkce.service.ts index 3fe9b1ea7828..c70e0839abbb 100644 --- a/apps/services/bff/src/app/modules/auth/pkce.service.ts +++ b/apps/services/bff/src/app/modules/auth/pkce.service.ts @@ -1,8 +1,8 @@ -import { Injectable } from '@nestjs/common'; -import { promisify } from 'util'; -import * as crypto from 'crypto'; +import { Injectable } from '@nestjs/common' +import { promisify } from 'util' +import * as crypto from 'crypto' -const randomBytesAsync = promisify(crypto.randomBytes); +const randomBytesAsync = promisify(crypto.randomBytes) @Injectable() export class PKCEService { @@ -10,7 +10,7 @@ export class PKCEService { * Generate a PKCE code verifier */ public async generateCodeVerifier(): Promise { - return this.generateVerifier(50); // Generates a 50-character long verifier by default + return this.generateVerifier(50) // Generates a 50-character long verifier by default } /** @@ -19,17 +19,17 @@ export class PKCEService { */ public async generateCodeChallenge(codeVerifier: string): Promise { // Convert the verifier to a Uint8Array - const encoder = new TextEncoder(); - const data = encoder.encode(codeVerifier); + const encoder = new TextEncoder() + const data = encoder.encode(codeVerifier) // Use Web Crypto API for async hashing - const hashBuffer = await crypto.webcrypto.subtle.digest('SHA-256', data); + const hashBuffer = await crypto.webcrypto.subtle.digest('SHA-256', data) // Convert the buffer to a Uint8Array - const hashArray = new Uint8Array(hashBuffer); + const hashArray = new Uint8Array(hashBuffer) // and then Base64 URL encode - return this.base64UrlEncode(Buffer.from(hashArray)); + return this.base64UrlEncode(Buffer.from(hashArray)) } /** @@ -37,9 +37,9 @@ export class PKCEService { * @returns Array of random ints (0 to 255) */ async getRandomValues(size: number): Promise { - const randomBytes = await randomBytesAsync(size); + const randomBytes = await randomBytesAsync(size) - return new Uint8Array(randomBytes); + return new Uint8Array(randomBytes) } /** @@ -48,28 +48,31 @@ export class PKCEService { */ async generateVerifier(length = 50): Promise { const mask = - 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~'; + 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~' - let result = ''; - const randomUints = await this.getRandomValues(length); + let result = '' + const randomUints = await this.getRandomValues(length) for (let i = 0; i < length; i++) { // Cap the value of the randomIndex to mask.length - 1 - const randomIndex = randomUints[i] % mask.length; - result += mask[randomIndex]; + const randomIndex = randomUints[i] % mask.length + result += mask[randomIndex] } - return result; + return result } /** * Base64 URL encode the buffer input + * This utility function converts a Buffer to a Base64 URL-safe string, + * replacing + with -, / with _, and removing any padding = characters. + * This is necessary because the standard Base64 encoding includes characters (+, /, and padding =) that are not URL-safe. */ private base64UrlEncode(buffer: Buffer): string { return buffer .toString('base64') .replace(/\+/g, '-') .replace(/\//g, '_') - .replace(/=+$/, ''); + .replace(/=+$/, '') } } diff --git a/apps/services/bff/src/app/modules/cache/cache.module.ts b/apps/services/bff/src/app/modules/cache/cache.module.ts index 6748845ad4d4..63c47e95db4d 100644 --- a/apps/services/bff/src/app/modules/cache/cache.module.ts +++ b/apps/services/bff/src/app/modules/cache/cache.module.ts @@ -13,17 +13,15 @@ if (process.env.NODE_ENV === 'test' || process.env.INIT_SCHEMA === 'true') { CacheModule = NestCacheModule.register() } else { CacheModule = NestCacheModule.registerAsync({ - useFactory: ({ redis: { ssl, nodes } }: ConfigType) => ( - { - store: redisInsStore( - createRedisCluster({ - name: 'bff', - ssl, - nodes, - }), - ), - } - ), + useFactory: ({ redis: { ssl, nodes } }: ConfigType) => ({ + store: redisInsStore( + createRedisCluster({ + name: 'bff', + ssl, + nodes, + }), + ), + }), inject: [BffConfig.KEY], }) } diff --git a/apps/services/bff/src/app/modules/cache/cache.service.ts b/apps/services/bff/src/app/modules/cache/cache.service.ts new file mode 100644 index 000000000000..6d127d8e161c --- /dev/null +++ b/apps/services/bff/src/app/modules/cache/cache.service.ts @@ -0,0 +1,49 @@ +import { BadRequestException, Inject, Injectable } from '@nestjs/common' +import { Cache as CacheManager } from 'cache-manager' + +import { CACHE_MANAGER } from '@nestjs/cache-manager' + +@Injectable() +export class CacheService { + constructor( + @Inject(CACHE_MANAGER) + private readonly cacheManager: CacheManager, + ) {} + + /** + * Creates s unique key with session id. + * type is either 'attempt' or 'current' + * attempt represents the login attempt + * current represents the current login session + */ + public createSessionKeyType(type: 'attempt' | 'current', sid: string) { + return `${type}_${sid}` + } + + public async save({ + key, + value, + ttl, + }: { + key: string + value: unknown + // Time to live in milliseconds + ttl?: number + }): Promise { + await this.cacheManager.set(key, value, ttl) + } + + public async get(key: string) { + const value = await this.cacheManager.get(key) + + if (!value) { + throw new BadRequestException('Not found') + } + + return value as Value + } + + public async delete(key: string) { + await this.cacheManager.del(key) + } +} diff --git a/apps/services/bff/src/app/modules/user/user.controller.ts b/apps/services/bff/src/app/modules/user/user.controller.ts index 7c0970ca636f..695a0ea1b4ac 100644 --- a/apps/services/bff/src/app/modules/user/user.controller.ts +++ b/apps/services/bff/src/app/modules/user/user.controller.ts @@ -1,22 +1,16 @@ -import { IdsUserGuard, Scopes, ScopesGuard } from '@island.is/auth-nest-tools' -import { Audit } from '@island.is/nest/audit' -import { Controller, Get, UseGuards, VERSION_NEUTRAL } from '@nestjs/common' -import { ApiOkResponse } from '@nestjs/swagger' -import { environment } from '../../../environment' +import { Controller, Get, Req, VERSION_NEUTRAL } from '@nestjs/common' +import type { Request } from 'express' +import { UserService } from './user.service' -@UseGuards(IdsUserGuard, ScopesGuard) @Controller({ path: 'user', version: [VERSION_NEUTRAL, '1'], }) export class UserController { - constructor() {} + constructor(private readonly userService: UserService) {} - @Scopes(...environment.auth.scopes) @Get() - @Audit() - @ApiOkResponse({ type: Boolean }) - async getUser(): Promise { - return true + async getUser(@Req() req: Request): Promise { + return this.userService.getUser(req) } } diff --git a/apps/services/bff/src/app/modules/user/user.module.ts b/apps/services/bff/src/app/modules/user/user.module.ts index 1b734f71f3ca..dd4b85727e24 100644 --- a/apps/services/bff/src/app/modules/user/user.module.ts +++ b/apps/services/bff/src/app/modules/user/user.module.ts @@ -1,7 +1,12 @@ import { Module } from '@nestjs/common' import { UserController } from './user.controller' +import { CacheService } from '../cache/cache.service' +import { CacheModule } from '../cache/cache.module' +import { UserService } from './user.service' @Module({ + imports: [CacheModule], controllers: [UserController], + providers: [CacheService, UserService], }) export class UserModule {} diff --git a/apps/services/bff/src/app/modules/user/user.service.ts b/apps/services/bff/src/app/modules/user/user.service.ts new file mode 100644 index 000000000000..ba1e3c12d933 --- /dev/null +++ b/apps/services/bff/src/app/modules/user/user.service.ts @@ -0,0 +1,40 @@ +import { Logger, LOGGER_PROVIDER } from '@island.is/logging' +import { Inject, Injectable, UnauthorizedException } from '@nestjs/common' +import { Request } from 'express' + +import { TokenResponse } from '../auth/auth.types' +import { CacheService } from '../cache/cache.service' + +@Injectable() +export class UserService { + constructor( + @Inject(LOGGER_PROVIDER) + private logger: Logger, + + private readonly cacheService: CacheService, + ) {} + + public async getUser(req: Request): Promise { + const sid = req.cookies['sid'] + + if (!sid) { + throw new UnauthorizedException() + } + + try { + const user = await this.cacheService.get( + this.cacheService.createSessionKeyType('current', sid), + ) + + if (!user) { + throw new Error() + } + + return user.id_token + } catch (error) { + this.logger.error('Error getting user from cache: ', error) + + throw new UnauthorizedException() + } + } +} diff --git a/apps/services/bff/src/environment/environment.ts b/apps/services/bff/src/environment/environment.ts index ee4a5ff69584..cfff94b622e5 100644 --- a/apps/services/bff/src/environment/environment.ts +++ b/apps/services/bff/src/environment/environment.ts @@ -20,6 +20,8 @@ export const environment: BffEnvironmentSchema = { scopes: requiredStringArray('BFF_IDENTITY_SERVER_CLIENT_SCOPES'), allowedRedirectUris: requiredStringArray('BFF_ALLOWED_REDIRECT_URIS'), secret: requiredString('BFF_IDENTITY_SERVER_SECRET'), - callbacksLoginRedirectUri: requiredString('BFF_CALLBACKS_LOGIN_REDIRECT_URI'), + callbacksLoginRedirectUri: requiredString( + 'BFF_CALLBACKS_LOGIN_REDIRECT_URI', + ), }, } From f2a9f8aa4323fac004bd92609d31ac5de36fc4a3 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Fri, 30 Aug 2024 15:12:29 +0000 Subject: [PATCH 006/248] Update project readme --- apps/services/bff/README.md | 37 ++++++++++++++++++++++++++++++------- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/apps/services/bff/README.md b/apps/services/bff/README.md index c7b7660fa7f0..eca15b6ef1c8 100644 --- a/apps/services/bff/README.md +++ b/apps/services/bff/README.md @@ -1,15 +1,38 @@ -# Bff +# BFF (Backend for Frontend) ## About -This service is the BFF(Backend for frontend) for various clients, i.e. admin-portal, service-portal, etc. -It is responsible for handling authorization, authentication, and other business logic for those clients. -It authenticates with identity server. -TODO - Add more details +The BFF (Backend for Frontend) service serves as an intermediary layer for multiple clients, such as the admin portal, service portal, and other applications. It is designed to centralize authentication and business logic, ensuring a secure and streamlined communication process between clients and backend resources. -## Getting started +This service handles user authentication through our IdentityServer, facilitating secure access and session management. Once authenticated, the BFF proxies and manages requests to our GraphQL API, ensuring only authorized requests are processed. -To start the service you run `yarn start services-bff`. This starts a server on `localhost:3333`. +## Getting Started + +To set up and run the BFF service, use the following commands: + +### Start Development Server + +`yarn start services-bff` +Starts the service on `localhost:3333`. + +### Build for Production + +`yarn nx build services-bff` +Builds the service to `dist/apps/services/bff`. +For production: `nx build services-bff --configuration=production` + +### Lint Code + +`yarn nx lint services-bff` + +### Run Tests + +`yarn nx test services-bff` +Runs tests with Jest and outputs coverage to `coverage/apps/services/bff`. + +### Starts Redis server with Docker + +`yarn nx dev-services services-bff` ## Code owners and maintainers From c06f9a9d49691b03a9391182c724d98985a80ac4 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Fri, 30 Aug 2024 15:32:12 +0000 Subject: [PATCH 007/248] Add secret --- apps/services/bff/infra/admin-portal.infra.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/services/bff/infra/admin-portal.infra.ts b/apps/services/bff/infra/admin-portal.infra.ts index 25a0efcede68..e265dbc52fb1 100644 --- a/apps/services/bff/infra/admin-portal.infra.ts +++ b/apps/services/bff/infra/admin-portal.infra.ts @@ -42,7 +42,8 @@ export const serviceSetup = (): ServiceBuilder<'services-bff-admin-portal'> => BFF_API_URL_PREFIX: 'stjornbord/bff', }) .secrets({ - BFF_IDENTITY_SERVER_SECRET: 'TODO - add secret', + BFF_IDENTITY_SERVER_SECRET: + '/k8s/services-bff/BFF_IDENTITY_SERVER_SECRET', }) .readiness('/health/check') .liveness('/liveness') From fa56db658729e251c493798b7a0178581d3db6d9 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Fri, 30 Aug 2024 15:35:27 +0000 Subject: [PATCH 008/248] Remove unnecessary config --- apps/services/bff/src/app/app.module.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/services/bff/src/app/app.module.ts b/apps/services/bff/src/app/app.module.ts index 1576428a66cd..329b0017dcd6 100644 --- a/apps/services/bff/src/app/app.module.ts +++ b/apps/services/bff/src/app/app.module.ts @@ -1,7 +1,6 @@ import { AuthModule as BaseAuthModule } from '@island.is/auth-nest-tools' import { AuditModule } from '@island.is/nest/audit' -import { ConfigModule, IdsClientConfig } from '@island.is/nest/config' -import { FeatureFlagConfig } from '@island.is/nest/feature-flags' +import { ConfigModule } from '@island.is/nest/config' import { Module } from '@nestjs/common' import { environment } from '../environment' import { BffConfig } from './bff.config' @@ -14,7 +13,7 @@ import { UserModule } from './modules/user/user.module' BaseAuthModule.register(environment.auth), ConfigModule.forRoot({ isGlobal: true, - load: [FeatureFlagConfig, IdsClientConfig, BffConfig], + load: [BffConfig], }), UserModule, AppAuthModule, From 0cb7fe00abaca4352719f16d8aa534833f1e09c4 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Fri, 30 Aug 2024 15:43:37 +0000 Subject: [PATCH 009/248] Fix env config for ids --- apps/services/bff/src/app/app.module.ts | 4 ++-- apps/services/bff/src/environment/environment.ts | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/services/bff/src/app/app.module.ts b/apps/services/bff/src/app/app.module.ts index 329b0017dcd6..fb52a1faac6f 100644 --- a/apps/services/bff/src/app/app.module.ts +++ b/apps/services/bff/src/app/app.module.ts @@ -1,6 +1,6 @@ import { AuthModule as BaseAuthModule } from '@island.is/auth-nest-tools' import { AuditModule } from '@island.is/nest/audit' -import { ConfigModule } from '@island.is/nest/config' +import { ConfigModule, IdsClientConfig } from '@island.is/nest/config' import { Module } from '@nestjs/common' import { environment } from '../environment' import { BffConfig } from './bff.config' @@ -13,7 +13,7 @@ import { UserModule } from './modules/user/user.module' BaseAuthModule.register(environment.auth), ConfigModule.forRoot({ isGlobal: true, - load: [BffConfig], + load: [IdsClientConfig, BffConfig], }), UserModule, AppAuthModule, diff --git a/apps/services/bff/src/environment/environment.ts b/apps/services/bff/src/environment/environment.ts index cfff94b622e5..150267a3ca37 100644 --- a/apps/services/bff/src/environment/environment.ts +++ b/apps/services/bff/src/environment/environment.ts @@ -14,12 +14,12 @@ export const environment: BffEnvironmentSchema = { }, port, auth: { - issuer: requiredString('BFF_IDENTITY_SERVER_ISSUER_URL'), - audience: requiredStringArray('BFF_IDENTITY_SERVER_AUDIENCE'), - clientId: requiredString('BFF_IDENTITY_SERVER_CLIENT_ID'), - scopes: requiredStringArray('BFF_IDENTITY_SERVER_CLIENT_SCOPES'), + issuer: requiredString('IDENTITY_SERVER_ISSUER_URL'), + clientId: requiredString('IDENTITY_SERVER_CLIENT_ID'), + secret: requiredString('IDENTITY_SERVER_CLIENT_SECRET'), + scopes: requiredStringArray('IDENTITY_SERVER_CLIENT_SCOPES'), + audience: requiredStringArray('IDENTITY_SERVER_AUDIENCE'), allowedRedirectUris: requiredStringArray('BFF_ALLOWED_REDIRECT_URIS'), - secret: requiredString('BFF_IDENTITY_SERVER_SECRET'), callbacksLoginRedirectUri: requiredString( 'BFF_CALLBACKS_LOGIN_REDIRECT_URI', ), From c08f5459d2ce7ba05c1ff5c6fb077920278a4b91 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Fri, 30 Aug 2024 15:49:11 +0000 Subject: [PATCH 010/248] Remove unused util isString --- libs/shared/utils/src/index.ts | 1 - libs/shared/utils/src/lib/isString.ts | 2 -- 2 files changed, 3 deletions(-) delete mode 100644 libs/shared/utils/src/lib/isString.ts diff --git a/libs/shared/utils/src/index.ts b/libs/shared/utils/src/index.ts index 0dd2f28a25ca..1e696acbf6ad 100644 --- a/libs/shared/utils/src/index.ts +++ b/libs/shared/utils/src/index.ts @@ -22,4 +22,3 @@ export * from './lib/date' export * from './lib/shouldLinkBeAnAnchorTag' export * from './lib/videoEmbed' export * from './lib/web' -export { isString } from './lib/isString' diff --git a/libs/shared/utils/src/lib/isString.ts b/libs/shared/utils/src/lib/isString.ts deleted file mode 100644 index 137d0c384292..000000000000 --- a/libs/shared/utils/src/lib/isString.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const isString = (str: T | string): str is string => - typeof str === 'string' From f794c9561d6b08e88412b30495579343e1fd6760 Mon Sep 17 00:00:00 2001 From: andes-it Date: Fri, 30 Aug 2024 16:01:59 +0000 Subject: [PATCH 011/248] chore: nx format:write update dirty files --- apps/portals/admin/project.json | 28 +++------ apps/services/bff/project.json | 4 +- .../src/app/modules/auth/pkce.service.spec.ts | 62 +++++++++---------- 3 files changed, 39 insertions(+), 55 deletions(-) diff --git a/apps/portals/admin/project.json b/apps/portals/admin/project.json index 0562b15e383d..dddd11104b36 100644 --- a/apps/portals/admin/project.json +++ b/apps/portals/admin/project.json @@ -3,15 +3,11 @@ "$schema": "../../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "apps/portals/admin/src", "projectType": "application", - "tags": [ - "scope:portals-admin" - ], + "tags": ["scope:portals-admin"], "targets": { "build": { "executor": "@nx/webpack:webpack", - "outputs": [ - "{options.outputPath}" - ], + "outputs": ["{options.outputPath}"], "defaultConfiguration": "production", "options": { "compiler": "babel", @@ -26,9 +22,7 @@ "apps/portals/admin/src/mockServiceWorker.js", "apps/portals/admin/src/assets" ], - "styles": [ - "apps/portals/admin/src/styles.css" - ], + "styles": ["apps/portals/admin/src/styles.css"], "scripts": [], "webpackConfig": "apps/portals/admin/webpack.config.js" }, @@ -55,9 +49,7 @@ "node scripts/dockerfile-assets/bash/extract-environment.js apps/portals/admin/src" ] }, - "outputs": [ - "{workspaceRoot}/apps/portals/admin/src/index.html" - ] + "outputs": ["{workspaceRoot}/apps/portals/admin/src/index.html"] }, "serve": { "executor": "@nx/webpack:dev-server", @@ -83,9 +75,7 @@ }, "test": { "executor": "@nx/jest:jest", - "outputs": [ - "{workspaceRoot}/coverage/apps/portals/admin" - ], + "outputs": ["{workspaceRoot}/coverage/apps/portals/admin"], "options": { "jestConfig": "apps/portals/admin/jest.config.ts" } @@ -99,18 +89,14 @@ "dev-init": { "executor": "nx:run-commands", "options": { - "commands": [ - "yarn get-secrets portals-admin" - ], + "commands": ["yarn get-secrets portals-admin"], "parallel": false } }, "dev": { "executor": "nx:run-commands", "options": { - "commands": [ - "yarn start portals-admin" - ], + "commands": ["yarn start portals-admin"], "parallel": true } }, diff --git a/apps/services/bff/project.json b/apps/services/bff/project.json index 6b644d1869d2..2f7c33fb1d89 100644 --- a/apps/services/bff/project.json +++ b/apps/services/bff/project.json @@ -50,9 +50,7 @@ "dev": { "executor": "nx:run-commands", "options": { - "commands": [ - "yarn start services-bff" - ], + "commands": ["yarn start services-bff"], "parallel": true } } diff --git a/apps/services/bff/src/app/modules/auth/pkce.service.spec.ts b/apps/services/bff/src/app/modules/auth/pkce.service.spec.ts index b08f76aa03dd..e80ad9711290 100644 --- a/apps/services/bff/src/app/modules/auth/pkce.service.spec.ts +++ b/apps/services/bff/src/app/modules/auth/pkce.service.spec.ts @@ -1,55 +1,55 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { PKCEService } from './pkce.service'; +import { Test, TestingModule } from '@nestjs/testing' +import { PKCEService } from './pkce.service' describe('PKCEService', () => { - let service: PKCEService; + let service: PKCEService beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [PKCEService], - }).compile(); + }).compile() - service = module.get(PKCEService); - }); + service = module.get(PKCEService) + }) describe('generateVerifier', () => { const verifierTestCases = [ { length: 50, description: 'default length 50' }, { length: 64, description: 'specified length 64' }, - ]; + ] verifierTestCases.forEach(({ length, description }) => { it(`should generate a verifier of ${description}`, async () => { - const verifier = await service.generateVerifier(length); - expect(verifier).toHaveLength(length); - expect(verifier).toMatch(/^[a-zA-Z0-9-._~]+$/); // Check allowed characters - }); - }); - }); + const verifier = await service.generateVerifier(length) + expect(verifier).toHaveLength(length) + expect(verifier).toMatch(/^[a-zA-Z0-9-._~]+$/) // Check allowed characters + }) + }) + }) describe('generateCodeVerifier', () => { it('should generate a code verifier of default length 50', async () => { - const verifier = await service.generateCodeVerifier(); - expect(verifier).toHaveLength(50); - expect(verifier).toMatch(/^[a-zA-Z0-9-._~]+$/); // Match allowed characters - }); - }); + const verifier = await service.generateCodeVerifier() + expect(verifier).toHaveLength(50) + expect(verifier).toMatch(/^[a-zA-Z0-9-._~]+$/) // Match allowed characters + }) + }) describe('generateCodeChallenge', () => { it('should generate a valid code challenge from a given verifier', async () => { - const verifier = 'testVerifier123'; - const challenge = await service.generateCodeChallenge(verifier); - expect(challenge).toBeDefined(); - expect(challenge).toMatch(/^[a-zA-Z0-9-_]+$/); // Match base64url format - }); - }); + const verifier = 'testVerifier123' + const challenge = await service.generateCodeChallenge(verifier) + expect(challenge).toBeDefined() + expect(challenge).toMatch(/^[a-zA-Z0-9-_]+$/) // Match base64url format + }) + }) describe('getRandomValues', () => { it('should generate an array of random values of specified length', async () => { - const length = 10; - const randomValues = await service.getRandomValues(length); - expect(randomValues).toBeInstanceOf(Uint8Array); - expect(randomValues).toHaveLength(length); - }); - }); -}); + const length = 10 + const randomValues = await service.getRandomValues(length) + expect(randomValues).toBeInstanceOf(Uint8Array) + expect(randomValues).toHaveLength(length) + }) + }) +}) From c0e5ed8bc68b68ae3f316fefe3abea6972611132 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Sun, 1 Sep 2024 10:39:14 +0000 Subject: [PATCH 012/248] Rename dto to queries --- apps/services/bff/src/app/modules/auth/auth.controller.ts | 8 ++++---- apps/services/bff/src/app/modules/auth/auth.service.ts | 8 ++++---- .../callback-login.query.ts} | 2 +- .../{dto/login-query.dto.ts => queries/login.query.ts} | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) rename apps/services/bff/src/app/modules/auth/{dto/callback-login-query.dto.ts => queries/callback-login.query.ts} (82%) rename apps/services/bff/src/app/modules/auth/{dto/login-query.dto.ts => queries/login.query.ts} (85%) diff --git a/apps/services/bff/src/app/modules/auth/auth.controller.ts b/apps/services/bff/src/app/modules/auth/auth.controller.ts index 781736600349..c7db48d22a00 100644 --- a/apps/services/bff/src/app/modules/auth/auth.controller.ts +++ b/apps/services/bff/src/app/modules/auth/auth.controller.ts @@ -9,8 +9,8 @@ import { } from '@nestjs/common' import { Response, Request } from 'express' import { AuthService } from './auth.service' -import { CallbackLoginQueryDto } from './dto/callback-login-query.dto' -import { LoginQueryDto } from './dto/login-query.dto' +import { CallbackLoginQuery } from './queries/callback-login.query' +import { LoginQuery } from './queries/login.query' const authValidationPipe = new ValidationPipe({ transform: true, @@ -29,7 +29,7 @@ export class AuthController { @Req() req: Request, @Res() res: Response, @Query(authValidationPipe) - query: LoginQueryDto, + query: LoginQuery, ): Promise { return this.authService.login({ req, res, query }) } @@ -38,7 +38,7 @@ export class AuthController { async callback( @Res() res: Response, @Query(authValidationPipe) - query: CallbackLoginQueryDto, + query: CallbackLoginQuery, ): Promise { return this.authService.callback(res, query) } diff --git a/apps/services/bff/src/app/modules/auth/auth.service.ts b/apps/services/bff/src/app/modules/auth/auth.service.ts index 7f107153d901..c6a4f4bdf772 100644 --- a/apps/services/bff/src/app/modules/auth/auth.service.ts +++ b/apps/services/bff/src/app/modules/auth/auth.service.ts @@ -7,8 +7,8 @@ import { uuid } from 'uuidv4' import { environment } from '../../../environment' import { BffConfig } from '../../bff.config' import { CacheService } from '../cache/cache.service' -import { CallbackLoginQueryDto } from './dto/callback-login-query.dto' -import { LoginQueryDto } from './dto/login-query.dto' +import { CallbackLoginQuery } from './queries/callback-login.query' +import { LoginQuery } from './queries/login.query' import { PKCEService } from './pkce.service' import { TokenResponse } from './auth.types' @@ -134,7 +134,7 @@ export class AuthService { }: { req: Request res: Response - query: LoginQueryDto + query: LoginQuery }) { // Validate targetLinkUri if it is provided if ( @@ -183,7 +183,7 @@ export class AuthService { ) } - async callback(res: Response, query: CallbackLoginQueryDto) { + async callback(res: Response, query: CallbackLoginQuery) { // Get login attempt from cache const loginAttemptData = await this.cacheService.get<{ targetLinkUri?: string diff --git a/apps/services/bff/src/app/modules/auth/dto/callback-login-query.dto.ts b/apps/services/bff/src/app/modules/auth/queries/callback-login.query.ts similarity index 82% rename from apps/services/bff/src/app/modules/auth/dto/callback-login-query.dto.ts rename to apps/services/bff/src/app/modules/auth/queries/callback-login.query.ts index 2cb960fc8fd9..286a5c38091f 100644 --- a/apps/services/bff/src/app/modules/auth/dto/callback-login-query.dto.ts +++ b/apps/services/bff/src/app/modules/auth/queries/callback-login.query.ts @@ -1,6 +1,6 @@ import { IsString } from 'class-validator' -export class CallbackLoginQueryDto { +export class CallbackLoginQuery { @IsString() code!: string diff --git a/apps/services/bff/src/app/modules/auth/dto/login-query.dto.ts b/apps/services/bff/src/app/modules/auth/queries/login.query.ts similarity index 85% rename from apps/services/bff/src/app/modules/auth/dto/login-query.dto.ts rename to apps/services/bff/src/app/modules/auth/queries/login.query.ts index 1b02347e08d3..7bdbc60c9742 100644 --- a/apps/services/bff/src/app/modules/auth/dto/login-query.dto.ts +++ b/apps/services/bff/src/app/modules/auth/queries/login.query.ts @@ -1,6 +1,6 @@ import { IsOptional, IsString } from 'class-validator' -export class LoginQueryDto { +export class LoginQuery { @IsOptional() @IsString() target_link_uri?: string From 862ba42977322fbef990467c879dc8d5b516a50a Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Mon, 2 Sep 2024 20:51:33 +0000 Subject: [PATCH 013/248] Add logout flow --- apps/services/bff/infra/admin-portal.infra.ts | 9 +- .../src/app/modules/auth/auth.controller.ts | 26 ++++- .../bff/src/app/modules/auth/auth.service.ts | 110 +++++++++++++++--- .../bff/src/app/modules/auth/auth.types.ts | 78 ++++++++++++- .../auth/queries/callback-logout.query.ts | 6 + .../app/modules/auth/queries/logout.query.ts | 6 + .../src/app/modules/user/user.controller.ts | 3 +- .../bff/src/app/modules/user/user.service.ts | 15 +-- .../bff/src/environment/environment.schema.ts | 15 ++- .../bff/src/environment/environment.ts | 24 +++- apps/services/bff/src/main.ts | 3 + libs/infra-nest-server/src/lib/bootstrap.ts | 4 + libs/infra-nest-server/src/lib/types.ts | 6 + 13 files changed, 270 insertions(+), 35 deletions(-) create mode 100644 apps/services/bff/src/app/modules/auth/queries/callback-logout.query.ts create mode 100644 apps/services/bff/src/app/modules/auth/queries/logout.query.ts diff --git a/apps/services/bff/infra/admin-portal.infra.ts b/apps/services/bff/infra/admin-portal.infra.ts index e265dbc52fb1..5ddff3f9d7ed 100644 --- a/apps/services/bff/infra/admin-portal.infra.ts +++ b/apps/services/bff/infra/admin-portal.infra.ts @@ -12,11 +12,10 @@ export const serviceSetup = (): ServiceBuilder<'services-bff-admin-portal'> => staging: 'https://identity-server.staging01.devland.is', prod: 'https://innskra.island.is', }, - BFF_CALLBACKS_LOGIN_REDIRECT_URI: { - dev: 'https://beta.dev01.devland.is/stjornbord/bff/callbacks/login', - staging: - 'https://beta.staging01.devland.is/stjornbord/bff/callbacks/login', - prod: 'https://island.is/stjornbord/bff/callbacks/login', + BFF_CALLBACKS_BASE_PATH: { + dev: 'https://beta.dev01.devland.is/stjornbord/bff/callbacks', + staging: 'https://beta.staging01.devland.is/stjornbord/bff/callbacks', + prod: 'https://island.is/stjornbord/bff/callbacks', }, IDENTITY_SERVER_CLIENT_SCOPES: json([ '@admin.island.is/delegation-system', diff --git a/apps/services/bff/src/app/modules/auth/auth.controller.ts b/apps/services/bff/src/app/modules/auth/auth.controller.ts index c7db48d22a00..0d094dafd749 100644 --- a/apps/services/bff/src/app/modules/auth/auth.controller.ts +++ b/apps/services/bff/src/app/modules/auth/auth.controller.ts @@ -7,10 +7,12 @@ import { ValidationPipe, VERSION_NEUTRAL, } from '@nestjs/common' -import { Response, Request } from 'express' +import { Request, Response } from 'express' import { AuthService } from './auth.service' import { CallbackLoginQuery } from './queries/callback-login.query' import { LoginQuery } from './queries/login.query' +import { LogoutQuery } from './queries/logout.query' +import { CallbackLogoutQuery } from './queries/callback-logout.query' const authValidationPipe = new ValidationPipe({ transform: true, @@ -35,11 +37,29 @@ export class AuthController { } @Get('callbacks/login') - async callback( + async callbackLogin( @Res() res: Response, @Query(authValidationPipe) query: CallbackLoginQuery, ): Promise { - return this.authService.callback(res, query) + return this.authService.callbackLogin(res, query) + } + + @Get('logout') + async logout( + @Res() res: Response, + @Query(authValidationPipe) + query: LogoutQuery, + ): Promise { + return this.authService.logout({ res, query }) + } + + @Get('callbacks/logout') + async callbackLogout( + @Res() res: Response, + @Query(authValidationPipe) + query: CallbackLogoutQuery, + ): Promise { + return this.authService.callbackLogout(res, query) } } diff --git a/apps/services/bff/src/app/modules/auth/auth.service.ts b/apps/services/bff/src/app/modules/auth/auth.service.ts index c6a4f4bdf772..a777ff852ed8 100644 --- a/apps/services/bff/src/app/modules/auth/auth.service.ts +++ b/apps/services/bff/src/app/modules/auth/auth.service.ts @@ -2,15 +2,18 @@ import { Logger, LOGGER_PROVIDER } from '@island.is/logging' import { BadRequestException, Inject, Injectable } from '@nestjs/common' import { ConfigType } from '@nestjs/config' import { Request, Response } from 'express' +import { jwtDecode } from 'jwt-decode' import { uuid } from 'uuidv4' import { environment } from '../../../environment' import { BffConfig } from '../../bff.config' import { CacheService } from '../cache/cache.service' +import { CachedTokenResponse, IdTokenData, TokenResponse } from './auth.types' +import { PKCEService } from './pkce.service' import { CallbackLoginQuery } from './queries/callback-login.query' import { LoginQuery } from './queries/login.query' -import { PKCEService } from './pkce.service' -import { TokenResponse } from './auth.types' +import { LogoutQuery } from './queries/logout.query' +import { CallbackLogoutQuery } from './queries/callback-logout.query' export type ParResponse = { request_uri: string @@ -77,7 +80,10 @@ export class AuthService { return await response.json() } catch (error) { - this.logger.error(`Error making request to ${endpoint}:`, error) + this.logger.error( + `Error making request to ${endpoint}:`, + JSON.stringify(error), + ) throw new BadRequestException(`Failed to fetch from ${endpoint}`) } @@ -98,7 +104,7 @@ export class AuthService { return this.postRequest('/connect/par', { client_id: this.config.auth.clientId, client_secret: this.config.auth.secret, - redirect_uri: this.config.auth.callbacksLoginRedirectUri, + redirect_uri: this.config.auth.callbacksRedirectUris.login, response_type: 'code', response_mode: 'query', scope: ['openid', 'profile', this.config.auth.scopes].join(' '), @@ -122,11 +128,27 @@ export class AuthService { code, client_secret: this.config.auth.secret, client_id: this.config.auth.clientId, - redirect_uri: this.config.auth.callbacksLoginRedirectUri, + redirect_uri: this.config.auth.callbacksRedirectUris.login, code_verifier: codeVerifier, }) } + /** + * Get the origin URL from the request headers and add the global prefix + */ + private getOriginUrl(req: Request) { + return `${(req.headers['origin'] || req.headers['referer'] || '') + // Remove trailing slash and add global prefix + .replace(/\/$/, '')}${environment.globalPrefix}` + } + + /** + * This method initiates the login flow. + * It validates the target_link_uri and generates a unique session id, for a login attempt. + * It also generates a code verifier and code challenge to enhance security. + * The login attempt data is saved in the cache and a PAR request is made to the identity server. + * The user is then redirected to the identity server login page. + */ async login({ req, res, @@ -157,11 +179,7 @@ export class AuthService { ) // Get the calling URL - const originUrl = `${( - req.headers['origin'] || - req.headers['referer'] || - '' - ).replace(/\/$/, '')}${environment.globalPrefix}` + const originUrl = this.getOriginUrl(req) await this.cacheService.save({ key: this.cacheService.createSessionKeyType('attempt', sid), @@ -183,7 +201,15 @@ export class AuthService { ) } - async callback(res: Response, query: CallbackLoginQuery) { + /** + * Callback for the login flow + * This method is called from the identity server after the user has logged in + * and the authorization code has been issued. + * The authorization code is then exchanged for tokens. + * We then save the tokens as well as decoded id token to the cache and create a session cookie. + * Finally, we redirect the user back to the original URL. + */ + async callbackLogin(res: Response, query: CallbackLoginQuery) { // Get login attempt from cache const loginAttemptData = await this.cacheService.get<{ targetLinkUri?: string @@ -198,12 +224,18 @@ export class AuthService { codeVerifier: loginAttemptData.codeVerifier, }) - const sid = uuid() + const userProfile: IdTokenData = jwtDecode(tokenResponse.id_token) + const sid = userProfile.sid + const value: CachedTokenResponse = { + ...tokenResponse, + originUrl: loginAttemptData.originUrl, + userProfile, + } // Save the tokenResponse to the cache await this.cacheService.save({ key: this.cacheService.createSessionKeyType('current', sid), - value: tokenResponse, + value, ttl: 60 * 60 * 1000, // 1 hour }) @@ -223,4 +255,56 @@ export class AuthService { loginAttemptData.targetLinkUri || loginAttemptData.originUrl, ) } + + /** + * This method initiates the logout flow. + * It gets necessary data from the cache and constructs a logout URL. + * The user is then redirected to the identity server logout page. + */ + async logout({ res, query: { sid } }: { res: Response; query: LogoutQuery }) { + const currentLoginCacheKey = this.cacheService.createSessionKeyType( + 'current', + sid, + ) + + const cachedTokenResponse = + await this.cacheService.get(currentLoginCacheKey) + + const searchParams = new URLSearchParams({ + id_token_hint: cachedTokenResponse.id_token, + post_logout_redirect_uri: this.config.auth.callbacksRedirectUris.logout, + }) + + return res.redirect(`${this.baseUrl}/connect/endsession?${searchParams}`) + } + + /** + * Callback for the logout flow. + * This method is called from the identity server after the user has logged out. + * We clean up the current login from the cache and delete the session cookie. + * Finally, we redirect the user back to the original URL. + */ + async callbackLogout(res: Response, { sid }: CallbackLogoutQuery) { + if (!sid) { + this.logger.error('Logout failed: No session id provided') + + throw new BadRequestException('Logout failed') + } + + const currentLoginCacheKey = this.cacheService.createSessionKeyType( + 'current', + sid, + ) + + const cachedTokenResponse = + await this.cacheService.get(currentLoginCacheKey) + + // Clean up current login from the cache since we have a successful logout. + await this.cacheService.delete(currentLoginCacheKey) + + // Delete session cookie + res.clearCookie('sid') + + return res.redirect(cachedTokenResponse.originUrl) + } } diff --git a/apps/services/bff/src/app/modules/auth/auth.types.ts b/apps/services/bff/src/app/modules/auth/auth.types.ts index dbcee733653a..590774b89fdf 100644 --- a/apps/services/bff/src/app/modules/auth/auth.types.ts +++ b/apps/services/bff/src/app/modules/auth/auth.types.ts @@ -1,7 +1,83 @@ -export type TokenResponse = { +export interface TokenResponse { + // ID token issued by the authorization server id_token: string + + // Access token used to access protected resources access_token: string + + // Time in seconds until the access token expires expires_in: number + + // Type of the token issued, typically "Bearer" token_type: string + + // Scopes associated with the access token scope: string } + +export interface IdTokenData { + // Issuer + iss: string + + // Not before (timestamp) + nbf: number + + // Issued at (timestamp) + iat: number + + // Expiration time (timestamp) + exp: number + + // Audience + aud: string + + // Authentication methods references + amr: string[] + + // Access token hash + at_hash: string + + // Session ID + sid: string + + // Subject identifier + sub: string + + // Authentication time (timestamp) + auth_time: number + + // Identity provider + idp: string + + // Authentication context class reference + acr: string + + // Subject type (e.g., "person") + subjectType: string + + // National ID + nationalId: string + + // Full name + name: string + + // Gender (e.g., "male") + gender: string + + // Birthdate in the format YYYY-MM-DD + birthdate: string + + // Locale (e.g., "is") + locale: string +} + +export type CachedTokenResponse = TokenResponse & { + userProfile: IdTokenData + /** + * Stores the original URL to facilitate user redirection during the `/logout/callback` process. + * This is necessary because the `/logout/callback` endpoint is invoked by the identity server, + * meaning we cannot obtain the original URL from the incoming request not does the identity server + * send query parameters to the callback URL. + */ + originUrl: string +} diff --git a/apps/services/bff/src/app/modules/auth/queries/callback-logout.query.ts b/apps/services/bff/src/app/modules/auth/queries/callback-logout.query.ts new file mode 100644 index 000000000000..e52b6af5da2f --- /dev/null +++ b/apps/services/bff/src/app/modules/auth/queries/callback-logout.query.ts @@ -0,0 +1,6 @@ +import { IsString } from 'class-validator' + +export class CallbackLogoutQuery { + @IsString() + sid!: string +} diff --git a/apps/services/bff/src/app/modules/auth/queries/logout.query.ts b/apps/services/bff/src/app/modules/auth/queries/logout.query.ts new file mode 100644 index 000000000000..3acae6d8475a --- /dev/null +++ b/apps/services/bff/src/app/modules/auth/queries/logout.query.ts @@ -0,0 +1,6 @@ +import { IsString } from 'class-validator' + +export class LogoutQuery { + @IsString() + sid!: string +} diff --git a/apps/services/bff/src/app/modules/user/user.controller.ts b/apps/services/bff/src/app/modules/user/user.controller.ts index 695a0ea1b4ac..72c82b5e873f 100644 --- a/apps/services/bff/src/app/modules/user/user.controller.ts +++ b/apps/services/bff/src/app/modules/user/user.controller.ts @@ -1,6 +1,7 @@ import { Controller, Get, Req, VERSION_NEUTRAL } from '@nestjs/common' import type { Request } from 'express' import { UserService } from './user.service' +import { IdTokenData } from '../auth/auth.types' @Controller({ path: 'user', @@ -10,7 +11,7 @@ export class UserController { constructor(private readonly userService: UserService) {} @Get() - async getUser(@Req() req: Request): Promise { + async getUser(@Req() req: Request): Promise { return this.userService.getUser(req) } } diff --git a/apps/services/bff/src/app/modules/user/user.service.ts b/apps/services/bff/src/app/modules/user/user.service.ts index ba1e3c12d933..48ee4fda4577 100644 --- a/apps/services/bff/src/app/modules/user/user.service.ts +++ b/apps/services/bff/src/app/modules/user/user.service.ts @@ -2,7 +2,7 @@ import { Logger, LOGGER_PROVIDER } from '@island.is/logging' import { Inject, Injectable, UnauthorizedException } from '@nestjs/common' import { Request } from 'express' -import { TokenResponse } from '../auth/auth.types' +import { CachedTokenResponse, IdTokenData } from '../auth/auth.types' import { CacheService } from '../cache/cache.service' @Injectable() @@ -14,7 +14,7 @@ export class UserService { private readonly cacheService: CacheService, ) {} - public async getUser(req: Request): Promise { + public async getUser(req: Request): Promise { const sid = req.cookies['sid'] if (!sid) { @@ -22,15 +22,16 @@ export class UserService { } try { - const user = await this.cacheService.get( - this.cacheService.createSessionKeyType('current', sid), - ) + const cachedTokenResponse = + await this.cacheService.get( + this.cacheService.createSessionKeyType('current', sid), + ) - if (!user) { + if (!cachedTokenResponse) { throw new Error() } - return user.id_token + return cachedTokenResponse.userProfile } catch (error) { this.logger.error('Error getting user from cache: ', error) diff --git a/apps/services/bff/src/environment/environment.schema.ts b/apps/services/bff/src/environment/environment.schema.ts index 0fd4d280533a..711a7a6a0c73 100644 --- a/apps/services/bff/src/environment/environment.schema.ts +++ b/apps/services/bff/src/environment/environment.schema.ts @@ -7,7 +7,10 @@ export const authSchema = z.strictObject({ scopes: z.string().array(), allowedRedirectUris: z.string().array(), secret: z.string(), - callbacksLoginRedirectUri: z.string(), + callbacksRedirectUris: z.strictObject({ + login: z.string(), + logout: z.string(), + }), }) export const environmentSchema = z.strictObject({ @@ -29,6 +32,16 @@ export const environmentSchema = z.strictObject({ * Identity server configuration */ auth: authSchema, + /** + * Enable CORS configuration + */ + enableCors: z + .object({ + origin: z.string().array(), + methods: z.enum(['GET', 'POST']).array(), + credentials: z.boolean().optional(), + }) + .optional(), }) export type BffEnvironmentSchema = z.infer diff --git a/apps/services/bff/src/environment/environment.ts b/apps/services/bff/src/environment/environment.ts index 150267a3ca37..f12adf9a0e04 100644 --- a/apps/services/bff/src/environment/environment.ts +++ b/apps/services/bff/src/environment/environment.ts @@ -4,6 +4,12 @@ import type { BffEnvironmentSchema } from './environment.schema' export const isProduction = process.env.NODE_ENV === 'production' const port = parseInt(process.env.PORT as string, 10) || 3333 +const callbacksBaseRedirectPath = requiredString('BFF_CALLBACKS_BASE_PATH') + // Remove trailing slash if present + .replace(/\/$/, '') + +const issuer = requiredString('IDENTITY_SERVER_ISSUER_URL') + export const environment: BffEnvironmentSchema = { production: isProduction, globalPrefix: requiredString('BFF_API_URL_PREFIX'), @@ -12,16 +18,26 @@ export const environment: BffEnvironmentSchema = { defaultNamespace: '@island.is/bff', serviceName: 'services-bff', }, + ...(!isProduction && { + enableCors: { + // Allowed origin(s) + origin: ['http://localhost:4200', issuer], + methods: ['GET', 'POST'], + // Allow cookies and credentials to be sent + credentials: true, + }, + }), port, auth: { - issuer: requiredString('IDENTITY_SERVER_ISSUER_URL'), + issuer, clientId: requiredString('IDENTITY_SERVER_CLIENT_ID'), secret: requiredString('IDENTITY_SERVER_CLIENT_SECRET'), scopes: requiredStringArray('IDENTITY_SERVER_CLIENT_SCOPES'), audience: requiredStringArray('IDENTITY_SERVER_AUDIENCE'), allowedRedirectUris: requiredStringArray('BFF_ALLOWED_REDIRECT_URIS'), - callbacksLoginRedirectUri: requiredString( - 'BFF_CALLBACKS_LOGIN_REDIRECT_URI', - ), + callbacksRedirectUris: { + login: `${callbacksBaseRedirectPath}/login`, + logout: `${callbacksBaseRedirectPath}/logout`, + }, }, } diff --git a/apps/services/bff/src/main.ts b/apps/services/bff/src/main.ts index ffd1e28ef0ba..9df3b5a1d233 100644 --- a/apps/services/bff/src/main.ts +++ b/apps/services/bff/src/main.ts @@ -8,6 +8,9 @@ bootstrap({ name: 'bff', port: environment.port, globalPrefix: environment.globalPrefix, + ...(!environment.production && { + enableCors: environment.enableCors, + }), healthCheck: { timeout: 1000, }, diff --git a/libs/infra-nest-server/src/lib/bootstrap.ts b/libs/infra-nest-server/src/lib/bootstrap.ts index edc410b7a285..f9d3e43998fd 100644 --- a/libs/infra-nest-server/src/lib/bootstrap.ts +++ b/libs/infra-nest-server/src/lib/bootstrap.ts @@ -50,6 +50,10 @@ export const createApp = async ({ app.enableVersioning() } + if (options.enableCors) { + app.enableCors(options.enableCors) + } + // Configure "X-Requested-For" handling. // Internal services should trust the X-Forwarded-For header (EXPRESS_TRUST_PROXY=1) // Public services (eg API Gateway) should trust our own reverse proxies diff --git a/libs/infra-nest-server/src/lib/types.ts b/libs/infra-nest-server/src/lib/types.ts index f0c6b054b20b..20916310c498 100644 --- a/libs/infra-nest-server/src/lib/types.ts +++ b/libs/infra-nest-server/src/lib/types.ts @@ -2,6 +2,7 @@ import { INestApplication, NestInterceptor, Type } from '@nestjs/common' import { OpenAPIObject } from '@nestjs/swagger' import { Server } from 'http' +import { CorsOptions } from '@nestjs/common/interfaces/external/cors-options.interface' import { HealthCheckOptions } from './infra/health/types' export type RunServerOptions = { @@ -67,6 +68,11 @@ export type RunServerOptions = { */ healthCheck?: boolean | HealthCheckOptions + /** + * Enables CORS (Cross-Origin Resource Sharing) + */ + enableCors?: CorsOptions + /** * Hook to run before app is initialized. */ From 2e2483edac50483a00ec5a1f1105a0e2529e06e7 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Tue, 3 Sep 2024 10:31:42 +0000 Subject: [PATCH 014/248] Finalize logout logic --- apps/portals/admin/proxy.config.json | 6 ----- apps/services/bff/infra/admin-portal.infra.ts | 6 +++++ .../bff/src/app/modules/auth/auth.service.ts | 25 ++++++++++++------- .../bff/src/app/modules/auth/auth.types.ts | 8 ++---- .../auth/queries/callback-logout.query.ts | 2 +- .../bff/src/environment/environment.schema.ts | 1 + .../bff/src/environment/environment.ts | 1 + 7 files changed, 27 insertions(+), 22 deletions(-) delete mode 100644 apps/portals/admin/proxy.config.json diff --git a/apps/portals/admin/proxy.config.json b/apps/portals/admin/proxy.config.json deleted file mode 100644 index 797fbdcff738..000000000000 --- a/apps/portals/admin/proxy.config.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "/stjornbord/bff/user": { - "target": "http://localhost:3333", - "secure": false - } -} diff --git a/apps/services/bff/infra/admin-portal.infra.ts b/apps/services/bff/infra/admin-portal.infra.ts index 5ddff3f9d7ed..73b89a82dfac 100644 --- a/apps/services/bff/infra/admin-portal.infra.ts +++ b/apps/services/bff/infra/admin-portal.infra.ts @@ -17,8 +17,14 @@ export const serviceSetup = (): ServiceBuilder<'services-bff-admin-portal'> => staging: 'https://beta.staging01.devland.is/stjornbord/bff/callbacks', prod: 'https://island.is/stjornbord/bff/callbacks', }, + BFF_LOGOUT_REDIRECT_PATH: { + dev: 'https://beta.dev01.devland.is', + staging: 'https://beta.staging01.devland.is', + prod: 'https://island.is', + }, IDENTITY_SERVER_CLIENT_SCOPES: json([ '@admin.island.is/delegation-system', + '@admin.island.is/delegation-system:admin', '@admin.island.is/ads', '@admin.island.is/bff', '@admin.island.is/ads:explicit', diff --git a/apps/services/bff/src/app/modules/auth/auth.service.ts b/apps/services/bff/src/app/modules/auth/auth.service.ts index a777ff852ed8..e0bf42341922 100644 --- a/apps/services/bff/src/app/modules/auth/auth.service.ts +++ b/apps/services/bff/src/app/modules/auth/auth.service.ts @@ -107,7 +107,7 @@ export class AuthService { redirect_uri: this.config.auth.callbacksRedirectUris.login, response_type: 'code', response_mode: 'query', - scope: ['openid', 'profile', this.config.auth.scopes].join(' '), + scope: ['openid', 'profile', ...this.config.auth.scopes].join(' '), state: sid, code_challenge: codeChallenge, code_challenge_method: 'S256', @@ -228,7 +228,6 @@ export class AuthService { const sid = userProfile.sid const value: CachedTokenResponse = { ...tokenResponse, - originUrl: loginAttemptData.originUrl, userProfile, } @@ -273,6 +272,7 @@ export class AuthService { const searchParams = new URLSearchParams({ id_token_hint: cachedTokenResponse.id_token, post_logout_redirect_uri: this.config.auth.callbacksRedirectUris.logout, + state: encodeURIComponent(JSON.stringify({ sid })), }) return res.redirect(`${this.baseUrl}/connect/endsession?${searchParams}`) @@ -284,27 +284,34 @@ export class AuthService { * We clean up the current login from the cache and delete the session cookie. * Finally, we redirect the user back to the original URL. */ - async callbackLogout(res: Response, { sid }: CallbackLogoutQuery) { + async callbackLogout(res: Response, { state }: CallbackLogoutQuery) { + if (!state) { + this.logger.error('Logout failed: No state param provided') + + throw new BadRequestException('Logout failed') + } + + const { sid } = JSON.parse(decodeURIComponent(state)) + if (!sid) { - this.logger.error('Logout failed: No session id provided') + this.logger.error( + 'Logout failed: Invalid state param provided. No sid (session id) found', + ) throw new BadRequestException('Logout failed') } const currentLoginCacheKey = this.cacheService.createSessionKeyType( 'current', - sid, + state, ) - const cachedTokenResponse = - await this.cacheService.get(currentLoginCacheKey) - // Clean up current login from the cache since we have a successful logout. await this.cacheService.delete(currentLoginCacheKey) // Delete session cookie res.clearCookie('sid') - return res.redirect(cachedTokenResponse.originUrl) + return res.redirect(environment.auth.logoutRedirectUri) } } diff --git a/apps/services/bff/src/app/modules/auth/auth.types.ts b/apps/services/bff/src/app/modules/auth/auth.types.ts index 590774b89fdf..6acd48d0a6a1 100644 --- a/apps/services/bff/src/app/modules/auth/auth.types.ts +++ b/apps/services/bff/src/app/modules/auth/auth.types.ts @@ -72,12 +72,8 @@ export interface IdTokenData { } export type CachedTokenResponse = TokenResponse & { - userProfile: IdTokenData /** - * Stores the original URL to facilitate user redirection during the `/logout/callback` process. - * This is necessary because the `/logout/callback` endpoint is invoked by the identity server, - * meaning we cannot obtain the original URL from the incoming request not does the identity server - * send query parameters to the callback URL. + * Decoded id token */ - originUrl: string + userProfile: IdTokenData } diff --git a/apps/services/bff/src/app/modules/auth/queries/callback-logout.query.ts b/apps/services/bff/src/app/modules/auth/queries/callback-logout.query.ts index e52b6af5da2f..be27cf6eeee1 100644 --- a/apps/services/bff/src/app/modules/auth/queries/callback-logout.query.ts +++ b/apps/services/bff/src/app/modules/auth/queries/callback-logout.query.ts @@ -2,5 +2,5 @@ import { IsString } from 'class-validator' export class CallbackLogoutQuery { @IsString() - sid!: string + state!: string } diff --git a/apps/services/bff/src/environment/environment.schema.ts b/apps/services/bff/src/environment/environment.schema.ts index 711a7a6a0c73..f513115ee94e 100644 --- a/apps/services/bff/src/environment/environment.schema.ts +++ b/apps/services/bff/src/environment/environment.schema.ts @@ -11,6 +11,7 @@ export const authSchema = z.strictObject({ login: z.string(), logout: z.string(), }), + logoutRedirectUri: z.string(), }) export const environmentSchema = z.strictObject({ diff --git a/apps/services/bff/src/environment/environment.ts b/apps/services/bff/src/environment/environment.ts index f12adf9a0e04..2a563405526f 100644 --- a/apps/services/bff/src/environment/environment.ts +++ b/apps/services/bff/src/environment/environment.ts @@ -39,5 +39,6 @@ export const environment: BffEnvironmentSchema = { login: `${callbacksBaseRedirectPath}/login`, logout: `${callbacksBaseRedirectPath}/logout`, }, + logoutRedirectUri: requiredString('BFF_LOGOUT_REDIRECT_PATH'), }, } From 17d98b437bc31ac43eb15407ce116205f55e3d58 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Tue, 3 Sep 2024 10:33:04 +0000 Subject: [PATCH 015/248] Remove proxy --- apps/portals/admin/project.json | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/apps/portals/admin/project.json b/apps/portals/admin/project.json index dddd11104b36..f51482c6f9ed 100644 --- a/apps/portals/admin/project.json +++ b/apps/portals/admin/project.json @@ -3,11 +3,15 @@ "$schema": "../../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "apps/portals/admin/src", "projectType": "application", - "tags": ["scope:portals-admin"], + "tags": [ + "scope:portals-admin" + ], "targets": { "build": { "executor": "@nx/webpack:webpack", - "outputs": ["{options.outputPath}"], + "outputs": [ + "{options.outputPath}" + ], "defaultConfiguration": "production", "options": { "compiler": "babel", @@ -22,7 +26,9 @@ "apps/portals/admin/src/mockServiceWorker.js", "apps/portals/admin/src/assets" ], - "styles": ["apps/portals/admin/src/styles.css"], + "styles": [ + "apps/portals/admin/src/styles.css" + ], "scripts": [], "webpackConfig": "apps/portals/admin/webpack.config.js" }, @@ -49,14 +55,15 @@ "node scripts/dockerfile-assets/bash/extract-environment.js apps/portals/admin/src" ] }, - "outputs": ["{workspaceRoot}/apps/portals/admin/src/index.html"] + "outputs": [ + "{workspaceRoot}/apps/portals/admin/src/index.html" + ] }, "serve": { "executor": "@nx/webpack:dev-server", "options": { "buildTarget": "portals-admin:build", - "hmr": true, - "proxyConfig": "apps/portals/admin/proxy.config.json" + "hmr": true }, "configurations": { "production": { @@ -75,7 +82,9 @@ }, "test": { "executor": "@nx/jest:jest", - "outputs": ["{workspaceRoot}/coverage/apps/portals/admin"], + "outputs": [ + "{workspaceRoot}/coverage/apps/portals/admin" + ], "options": { "jestConfig": "apps/portals/admin/jest.config.ts" } @@ -89,14 +98,18 @@ "dev-init": { "executor": "nx:run-commands", "options": { - "commands": ["yarn get-secrets portals-admin"], + "commands": [ + "yarn get-secrets portals-admin" + ], "parallel": false } }, "dev": { "executor": "nx:run-commands", "options": { - "commands": ["yarn start portals-admin"], + "commands": [ + "yarn start portals-admin" + ], "parallel": true } }, From 277b0a2d4f79d052f9b9c9e750dc2a1f585682fb Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Tue, 3 Sep 2024 10:43:33 +0000 Subject: [PATCH 016/248] Move type from service to type file --- .../bff/src/app/modules/auth/auth.service.ts | 12 ++++++------ apps/services/bff/src/app/modules/auth/auth.types.ts | 8 ++++++++ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/apps/services/bff/src/app/modules/auth/auth.service.ts b/apps/services/bff/src/app/modules/auth/auth.service.ts index e0bf42341922..f2779698d81c 100644 --- a/apps/services/bff/src/app/modules/auth/auth.service.ts +++ b/apps/services/bff/src/app/modules/auth/auth.service.ts @@ -8,18 +8,18 @@ import { uuid } from 'uuidv4' import { environment } from '../../../environment' import { BffConfig } from '../../bff.config' import { CacheService } from '../cache/cache.service' -import { CachedTokenResponse, IdTokenData, TokenResponse } from './auth.types' +import { + CachedTokenResponse, + IdTokenData, + ParResponse, + TokenResponse, +} from './auth.types' import { PKCEService } from './pkce.service' import { CallbackLoginQuery } from './queries/callback-login.query' import { LoginQuery } from './queries/login.query' import { LogoutQuery } from './queries/logout.query' import { CallbackLogoutQuery } from './queries/callback-logout.query' -export type ParResponse = { - request_uri: string - expires_in: number -} - @Injectable() export class AuthService { private readonly baseUrl diff --git a/apps/services/bff/src/app/modules/auth/auth.types.ts b/apps/services/bff/src/app/modules/auth/auth.types.ts index 6acd48d0a6a1..2abcdba920d0 100644 --- a/apps/services/bff/src/app/modules/auth/auth.types.ts +++ b/apps/services/bff/src/app/modules/auth/auth.types.ts @@ -1,3 +1,11 @@ +export type ParResponse = { + // An identifier for the authorization request, instead of sending the parameters that were just pushed + request_uri: string + + // Time in seconds until the request_uri expires + expires_in: number +} + export interface TokenResponse { // ID token issued by the authorization server id_token: string From 73f9d55ba7ba26b907d3f10401d27ca3b7fb0f2a Mon Sep 17 00:00:00 2001 From: andes-it Date: Tue, 3 Sep 2024 11:02:46 +0000 Subject: [PATCH 017/248] chore: nx format:write update dirty files --- apps/portals/admin/project.json | 28 +++++++--------------------- 1 file changed, 7 insertions(+), 21 deletions(-) diff --git a/apps/portals/admin/project.json b/apps/portals/admin/project.json index f51482c6f9ed..d7fc9572a5f0 100644 --- a/apps/portals/admin/project.json +++ b/apps/portals/admin/project.json @@ -3,15 +3,11 @@ "$schema": "../../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "apps/portals/admin/src", "projectType": "application", - "tags": [ - "scope:portals-admin" - ], + "tags": ["scope:portals-admin"], "targets": { "build": { "executor": "@nx/webpack:webpack", - "outputs": [ - "{options.outputPath}" - ], + "outputs": ["{options.outputPath}"], "defaultConfiguration": "production", "options": { "compiler": "babel", @@ -26,9 +22,7 @@ "apps/portals/admin/src/mockServiceWorker.js", "apps/portals/admin/src/assets" ], - "styles": [ - "apps/portals/admin/src/styles.css" - ], + "styles": ["apps/portals/admin/src/styles.css"], "scripts": [], "webpackConfig": "apps/portals/admin/webpack.config.js" }, @@ -55,9 +49,7 @@ "node scripts/dockerfile-assets/bash/extract-environment.js apps/portals/admin/src" ] }, - "outputs": [ - "{workspaceRoot}/apps/portals/admin/src/index.html" - ] + "outputs": ["{workspaceRoot}/apps/portals/admin/src/index.html"] }, "serve": { "executor": "@nx/webpack:dev-server", @@ -82,9 +74,7 @@ }, "test": { "executor": "@nx/jest:jest", - "outputs": [ - "{workspaceRoot}/coverage/apps/portals/admin" - ], + "outputs": ["{workspaceRoot}/coverage/apps/portals/admin"], "options": { "jestConfig": "apps/portals/admin/jest.config.ts" } @@ -98,18 +88,14 @@ "dev-init": { "executor": "nx:run-commands", "options": { - "commands": [ - "yarn get-secrets portals-admin" - ], + "commands": ["yarn get-secrets portals-admin"], "parallel": false } }, "dev": { "executor": "nx:run-commands", "options": { - "commands": [ - "yarn start portals-admin" - ], + "commands": ["yarn start portals-admin"], "parallel": true } }, From 0327c75d753d813ab10cf17c0545299b3a3c0f53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sn=C3=A6r=20Seljan=20=C3=9E=C3=B3roddsson?= <112904566+snaerseljan@users.noreply.github.com> Date: Tue, 3 Sep 2024 11:03:32 +0000 Subject: [PATCH 018/248] Delete libs/auth/react/src/lib/bff/BFFProvider.tsx --- libs/auth/react/src/lib/bff/BFFProvider.tsx | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 libs/auth/react/src/lib/bff/BFFProvider.tsx diff --git a/libs/auth/react/src/lib/bff/BFFProvider.tsx b/libs/auth/react/src/lib/bff/BFFProvider.tsx deleted file mode 100644 index e69de29bb2d1..000000000000 From 197d0921ff23c9e41953ec6a8ae09c8ef5a32a5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sn=C3=A6r=20Seljan=20=C3=9E=C3=B3roddsson?= <112904566+snaerseljan@users.noreply.github.com> Date: Tue, 3 Sep 2024 11:03:51 +0000 Subject: [PATCH 019/248] Delete libs/auth/react/src/lib/bff/BFFContext.tsx --- libs/auth/react/src/lib/bff/BFFContext.tsx | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 libs/auth/react/src/lib/bff/BFFContext.tsx diff --git a/libs/auth/react/src/lib/bff/BFFContext.tsx b/libs/auth/react/src/lib/bff/BFFContext.tsx deleted file mode 100644 index e69de29bb2d1..000000000000 From f26deebbfc72ec3b070276d0aa1cf1f3c056b324 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Tue, 3 Sep 2024 10:51:12 +0000 Subject: [PATCH 020/248] Small refactor in auth service --- .../bff/src/app/modules/auth/auth.service.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/services/bff/src/app/modules/auth/auth.service.ts b/apps/services/bff/src/app/modules/auth/auth.service.ts index f2779698d81c..e7559ee6a1b4 100644 --- a/apps/services/bff/src/app/modules/auth/auth.service.ts +++ b/apps/services/bff/src/app/modules/auth/auth.service.ts @@ -166,7 +166,9 @@ export class AuthService { this.config.auth.allowedRedirectUris, ) ) { - throw new BadRequestException('Invalid target_link_uri') + this.logger.error('Invalid target_link_uri provided:', targetLinkUri) + + throw new BadRequestException('Login failed') } // Generate a unique session id to be used in the login flow @@ -196,9 +198,12 @@ export class AuthService { const parResponse = await this.fetchPAR({ sid, codeChallenge, loginHint }) - return res.redirect( - `${this.baseUrl}/connect/authorize?request_uri=${parResponse.request_uri}&client_id=${this.config.auth.clientId}`, - ) + const searchParams = new URLSearchParams({ + request_uri: parResponse.request_uri, + client_id: this.config.auth.clientId, + }).toString() + + return res.redirect(`${this.baseUrl}/connect/authorize?${searchParams}`) } /** From cbf8d312a727f33babda42dd83d8a5cfc944cfa2 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Tue, 3 Sep 2024 10:54:20 +0000 Subject: [PATCH 021/248] Small refactor in test --- .../bff/src/app/modules/auth/pkce.service.spec.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/services/bff/src/app/modules/auth/pkce.service.spec.ts b/apps/services/bff/src/app/modules/auth/pkce.service.spec.ts index e80ad9711290..b469fba3eb8e 100644 --- a/apps/services/bff/src/app/modules/auth/pkce.service.spec.ts +++ b/apps/services/bff/src/app/modules/auth/pkce.service.spec.ts @@ -1,6 +1,8 @@ import { Test, TestingModule } from '@nestjs/testing' import { PKCEService } from './pkce.service' +const ALLOWED_VERIFIER_CHARACTERS_REGEX = /^[a-zA-Z0-9-._~]+$/ + describe('PKCEService', () => { let service: PKCEService @@ -22,7 +24,8 @@ describe('PKCEService', () => { it(`should generate a verifier of ${description}`, async () => { const verifier = await service.generateVerifier(length) expect(verifier).toHaveLength(length) - expect(verifier).toMatch(/^[a-zA-Z0-9-._~]+$/) // Check allowed characters + // Check allowed characters + expect(verifier).toMatch(ALLOWED_VERIFIER_CHARACTERS_REGEX) }) }) }) @@ -31,7 +34,8 @@ describe('PKCEService', () => { it('should generate a code verifier of default length 50', async () => { const verifier = await service.generateCodeVerifier() expect(verifier).toHaveLength(50) - expect(verifier).toMatch(/^[a-zA-Z0-9-._~]+$/) // Match allowed characters + // Check allowed characters + expect(verifier).toMatch(ALLOWED_VERIFIER_CHARACTERS_REGEX) }) }) @@ -40,7 +44,8 @@ describe('PKCEService', () => { const verifier = 'testVerifier123' const challenge = await service.generateCodeChallenge(verifier) expect(challenge).toBeDefined() - expect(challenge).toMatch(/^[a-zA-Z0-9-_]+$/) // Match base64url format + // Match base64url format + expect(challenge).toMatch(/^[a-zA-Z0-9-_]+$/) }) }) From 7cff5bee91f021613ac1ce093031cd1ae4f4c6f6 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Tue, 3 Sep 2024 11:05:22 +0000 Subject: [PATCH 022/248] Small refactor --- apps/services/bff/src/app/modules/auth/pkce.service.ts | 3 ++- apps/services/bff/src/app/modules/cache/cache.service.ts | 6 +++--- apps/services/bff/src/app/modules/user/user.service.ts | 4 ++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/services/bff/src/app/modules/auth/pkce.service.ts b/apps/services/bff/src/app/modules/auth/pkce.service.ts index c70e0839abbb..2ddfca528c31 100644 --- a/apps/services/bff/src/app/modules/auth/pkce.service.ts +++ b/apps/services/bff/src/app/modules/auth/pkce.service.ts @@ -8,9 +8,10 @@ const randomBytesAsync = promisify(crypto.randomBytes) export class PKCEService { /** * Generate a PKCE code verifier + * Generates a 50-character long verifier by default */ public async generateCodeVerifier(): Promise { - return this.generateVerifier(50) // Generates a 50-character long verifier by default + return this.generateVerifier(50) } /** diff --git a/apps/services/bff/src/app/modules/cache/cache.service.ts b/apps/services/bff/src/app/modules/cache/cache.service.ts index 6d127d8e161c..3dfe05cd5efd 100644 --- a/apps/services/bff/src/app/modules/cache/cache.service.ts +++ b/apps/services/bff/src/app/modules/cache/cache.service.ts @@ -12,9 +12,9 @@ export class CacheService { /** * Creates s unique key with session id. - * type is either 'attempt' or 'current' - * attempt represents the login attempt - * current represents the current login session + * Type is either 'attempt' or 'current'. + * `attempt` represents the login attempt. + * `current` represents the current login session. */ public createSessionKeyType(type: 'attempt' | 'current', sid: string) { return `${type}_${sid}` diff --git a/apps/services/bff/src/app/modules/user/user.service.ts b/apps/services/bff/src/app/modules/user/user.service.ts index 48ee4fda4577..612472ea0faf 100644 --- a/apps/services/bff/src/app/modules/user/user.service.ts +++ b/apps/services/bff/src/app/modules/user/user.service.ts @@ -27,8 +27,8 @@ export class UserService { this.cacheService.createSessionKeyType('current', sid), ) - if (!cachedTokenResponse) { - throw new Error() + if (!cachedTokenResponse.userProfile) { + throw new Error('userProfile not found in cache') } return cachedTokenResponse.userProfile From ded1ccadde50f8609a7b08faf6e6b3f4e99785ff Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Tue, 3 Sep 2024 11:32:10 +0000 Subject: [PATCH 023/248] Fix esbuild --- apps/services/bff/src/app/modules/auth/auth.service.ts | 3 ++- apps/services/bff/src/app/modules/user/user.service.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/services/bff/src/app/modules/auth/auth.service.ts b/apps/services/bff/src/app/modules/auth/auth.service.ts index e7559ee6a1b4..024112c75c47 100644 --- a/apps/services/bff/src/app/modules/auth/auth.service.ts +++ b/apps/services/bff/src/app/modules/auth/auth.service.ts @@ -1,4 +1,5 @@ -import { Logger, LOGGER_PROVIDER } from '@island.is/logging' +import type { Logger } from '@island.is/logging' +import { LOGGER_PROVIDER } from '@island.is/logging' import { BadRequestException, Inject, Injectable } from '@nestjs/common' import { ConfigType } from '@nestjs/config' import { Request, Response } from 'express' diff --git a/apps/services/bff/src/app/modules/user/user.service.ts b/apps/services/bff/src/app/modules/user/user.service.ts index 612472ea0faf..e3c9db29c074 100644 --- a/apps/services/bff/src/app/modules/user/user.service.ts +++ b/apps/services/bff/src/app/modules/user/user.service.ts @@ -1,4 +1,5 @@ -import { Logger, LOGGER_PROVIDER } from '@island.is/logging' +import type { Logger } from '@island.is/logging' +import { LOGGER_PROVIDER } from '@island.is/logging' import { Inject, Injectable, UnauthorizedException } from '@nestjs/common' import { Request } from 'express' From 8622db488444085ce03b0d781c635c0bdcbf8545 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Tue, 3 Sep 2024 11:49:11 +0000 Subject: [PATCH 024/248] Add scope --- apps/services/bff/project.json | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/services/bff/project.json b/apps/services/bff/project.json index 2f7c33fb1d89..5bdc3fd8978c 100644 --- a/apps/services/bff/project.json +++ b/apps/services/bff/project.json @@ -4,7 +4,9 @@ "sourceRoot": "apps/services/bff/src", "projectType": "application", "prefix": "services-bff", - "tags": [], + "tags": [ + "scope:services-bff" + ], "targets": { "build": { "executor": "./tools/executors/node:build", @@ -21,7 +23,9 @@ "inspect": false } }, - "outputs": ["{options.outputPath}"] + "outputs": [ + "{options.outputPath}" + ] }, "serve": { "executor": "@nx/js:node", @@ -38,7 +42,9 @@ "jestConfig": "apps/services/bff/jest.config.ts", "runInBand": true }, - "outputs": ["{workspaceRoot}/coverage/apps/services/bff"] + "outputs": [ + "{workspaceRoot}/coverage/apps/services/bff" + ] }, "dev-services": { "executor": "nx:run-commands", @@ -50,7 +56,9 @@ "dev": { "executor": "nx:run-commands", "options": { - "commands": ["yarn start services-bff"], + "commands": [ + "yarn start services-bff" + ], "parallel": true } } From 7a1ed3a320aea0854b5f1dea463468266647b921 Mon Sep 17 00:00:00 2001 From: andes-it Date: Tue, 3 Sep 2024 12:01:52 +0000 Subject: [PATCH 025/248] chore: nx format:write update dirty files --- apps/services/bff/project.json | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/apps/services/bff/project.json b/apps/services/bff/project.json index 5bdc3fd8978c..d0261857f0b3 100644 --- a/apps/services/bff/project.json +++ b/apps/services/bff/project.json @@ -4,9 +4,7 @@ "sourceRoot": "apps/services/bff/src", "projectType": "application", "prefix": "services-bff", - "tags": [ - "scope:services-bff" - ], + "tags": ["scope:services-bff"], "targets": { "build": { "executor": "./tools/executors/node:build", @@ -23,9 +21,7 @@ "inspect": false } }, - "outputs": [ - "{options.outputPath}" - ] + "outputs": ["{options.outputPath}"] }, "serve": { "executor": "@nx/js:node", @@ -42,9 +38,7 @@ "jestConfig": "apps/services/bff/jest.config.ts", "runInBand": true }, - "outputs": [ - "{workspaceRoot}/coverage/apps/services/bff" - ] + "outputs": ["{workspaceRoot}/coverage/apps/services/bff"] }, "dev-services": { "executor": "nx:run-commands", @@ -56,9 +50,7 @@ "dev": { "executor": "nx:run-commands", "options": { - "commands": [ - "yarn start services-bff" - ], + "commands": ["yarn start services-bff"], "parallel": true } } From 056d6634c3a90e526084853cc344033f5119b76a Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Wed, 4 Sep 2024 15:08:27 +0000 Subject: [PATCH 026/248] Updates to bff service and client. WIP --- apps/portals/admin/project.json | 3 +- apps/portals/admin/proxy.config.json | 6 + apps/portals/admin/src/app/App.tsx | 14 +-- apps/portals/admin/src/auth.ts | 45 ------- apps/portals/admin/src/graphql.ts | 12 +- apps/portals/admin/src/main.tsx | 3 +- apps/services/bff/src/app/app.module.ts | 2 + .../bff/src/app/modules/auth/auth.module.ts | 3 +- .../bff/src/app/modules/auth/auth.service.ts | 105 +++------------- .../bff/src/app/modules/auth/auth.types.ts | 88 +------------- .../bff/src/app/modules/ids/ids.service.ts | 113 ++++++++++++++++++ .../bff/src/app/modules/ids/ids.types.ts | 27 +++++ .../src/app/modules/proxy/proxy.controller.ts | 16 +++ .../bff/src/app/modules/proxy/proxy.module.ts | 13 ++ .../src/app/modules/proxy/proxy.service.ts | 65 ++++++++++ .../src/app/modules/user/user.controller.ts | 8 +- .../bff/src/app/modules/user/user.service.ts | 13 +- .../bff/src/environment/environment.schema.ts | 4 + .../bff/src/environment/environment.ts | 1 + .../core/src/components/PortalRouter.tsx | 3 +- libs/react-spa/bff/.babelrc | 12 ++ libs/react-spa/bff/.eslintrc.json | 18 +++ libs/react-spa/bff/README.md | 7 ++ libs/react-spa/bff/jest.config.ts | 11 ++ libs/react-spa/bff/project.json | 24 ++++ libs/react-spa/bff/src/index.ts | 3 + libs/react-spa/bff/src/lib/BffContext.tsx | 11 ++ libs/react-spa/bff/src/lib/BffProvider.tsx | 109 +++++++++++++++++ libs/react-spa/bff/src/lib/ErrorScreen.css.ts | 5 + libs/react-spa/bff/src/lib/ErrorScreen.tsx | 37 ++++++ libs/react-spa/bff/src/lib/bff.hooks.ts | 22 ++++ libs/react-spa/bff/src/lib/bff.state.ts | 106 ++++++++++++++++ libs/react-spa/bff/src/lib/bff.utils.ts | 23 ++++ libs/react-spa/bff/tsconfig.json | 20 ++++ libs/react-spa/bff/tsconfig.lib.json | 24 ++++ libs/react-spa/bff/tsconfig.spec.json | 20 ++++ libs/shared/types/src/index.ts | 1 + libs/shared/types/src/lib/bff.ts | 72 +++++++++++ tsconfig.base.json | 1 + 39 files changed, 831 insertions(+), 239 deletions(-) create mode 100644 apps/portals/admin/proxy.config.json delete mode 100644 apps/portals/admin/src/auth.ts create mode 100644 apps/services/bff/src/app/modules/ids/ids.service.ts create mode 100644 apps/services/bff/src/app/modules/ids/ids.types.ts create mode 100644 apps/services/bff/src/app/modules/proxy/proxy.controller.ts create mode 100644 apps/services/bff/src/app/modules/proxy/proxy.module.ts create mode 100644 apps/services/bff/src/app/modules/proxy/proxy.service.ts create mode 100644 libs/react-spa/bff/.babelrc create mode 100644 libs/react-spa/bff/.eslintrc.json create mode 100644 libs/react-spa/bff/README.md create mode 100644 libs/react-spa/bff/jest.config.ts create mode 100644 libs/react-spa/bff/project.json create mode 100644 libs/react-spa/bff/src/index.ts create mode 100644 libs/react-spa/bff/src/lib/BffContext.tsx create mode 100644 libs/react-spa/bff/src/lib/BffProvider.tsx create mode 100644 libs/react-spa/bff/src/lib/ErrorScreen.css.ts create mode 100644 libs/react-spa/bff/src/lib/ErrorScreen.tsx create mode 100644 libs/react-spa/bff/src/lib/bff.hooks.ts create mode 100644 libs/react-spa/bff/src/lib/bff.state.ts create mode 100644 libs/react-spa/bff/src/lib/bff.utils.ts create mode 100644 libs/react-spa/bff/tsconfig.json create mode 100644 libs/react-spa/bff/tsconfig.lib.json create mode 100644 libs/react-spa/bff/tsconfig.spec.json create mode 100644 libs/shared/types/src/lib/bff.ts diff --git a/apps/portals/admin/project.json b/apps/portals/admin/project.json index d7fc9572a5f0..dddd11104b36 100644 --- a/apps/portals/admin/project.json +++ b/apps/portals/admin/project.json @@ -55,7 +55,8 @@ "executor": "@nx/webpack:dev-server", "options": { "buildTarget": "portals-admin:build", - "hmr": true + "hmr": true, + "proxyConfig": "apps/portals/admin/proxy.config.json" }, "configurations": { "production": { diff --git a/apps/portals/admin/proxy.config.json b/apps/portals/admin/proxy.config.json new file mode 100644 index 000000000000..91fa997b899c --- /dev/null +++ b/apps/portals/admin/proxy.config.json @@ -0,0 +1,6 @@ +{ + "/stjornbord/bff/*": { + "target": "http://localhost:3333", + "secure": false + } +} diff --git a/apps/portals/admin/src/app/App.tsx b/apps/portals/admin/src/app/App.tsx index 918f04a4307c..0921c6a3c271 100644 --- a/apps/portals/admin/src/app/App.tsx +++ b/apps/portals/admin/src/app/App.tsx @@ -1,12 +1,12 @@ import { ApolloProvider } from '@apollo/client' -import { AuthProvider } from '@island.is/auth/react' import { LocaleProvider } from '@island.is/localization' -import { defaultLanguage } from '@island.is/shared/constants' -import { FeatureFlagProvider } from '@island.is/react/feature-flags' import { ApplicationErrorBoundary, PortalRouter } from '@island.is/portals/core' -import { modules } from '../lib/modules' -import { client } from '../graphql' +import { BffProvider } from '@island.is/react-spa/bff' +import { FeatureFlagProvider } from '@island.is/react/feature-flags' +import { defaultLanguage } from '@island.is/shared/constants' import environment from '../environments/environment' +import { client } from '../graphql' +import { modules } from '../lib/modules' import { AdminPortalPaths } from '../lib/paths' import { createRoutes } from '../lib/routes' @@ -14,7 +14,7 @@ export const App = () => ( - + ( }} /> - + diff --git a/apps/portals/admin/src/auth.ts b/apps/portals/admin/src/auth.ts deleted file mode 100644 index 4a081c7312ea..000000000000 --- a/apps/portals/admin/src/auth.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { configure, configureMock } from '@island.is/auth/react' -import { AdminPortalScope } from '@island.is/auth/scopes' - -import environment from './environments/environment' - -const userMocked = process.env.API_MOCKS === 'true' - -if (userMocked) { - configureMock({ - profile: { name: 'Mock', locale: 'is', nationalId: '0000000000' }, - scopes: [], - }) -} else { - configure({ - baseUrl: `${window.location.origin}/stjornbord`, - redirectPath: '/signin-oidc', - redirectPathSilent: '/silent/signin-oidc', - switchUserRedirectUrl: '/', - authority: environment.identityServer.authority, - client_id: '@admin.island.is/web', - scope: [ - 'openid', - 'profile', - AdminPortalScope.delegations, - AdminPortalScope.airDiscountScheme, - AdminPortalScope.regulationAdmin, - AdminPortalScope.regulationAdminManage, - AdminPortalScope.icelandicNamesRegistry, - AdminPortalScope.applicationSystemAdmin, - AdminPortalScope.applicationSystemInstitution, - AdminPortalScope.documentProvider, - AdminPortalScope.idsAdmin, - AdminPortalScope.idsAdminSuperUser, - AdminPortalScope.petitionsAdmin, - AdminPortalScope.serviceDesk, - AdminPortalScope.explicitAirDiscountScheme, - AdminPortalScope.signatureCollectionManage, - AdminPortalScope.signatureCollectionProcess, - AdminPortalScope.formSystem, - AdminPortalScope.formSystemSuperUser, - ], - post_logout_redirect_uri: `${window.location.origin}`, - userStorePrefix: 'ap.', - }) -} diff --git a/apps/portals/admin/src/graphql.ts b/apps/portals/admin/src/graphql.ts index d20d4f3df080..0352b26a7eac 100644 --- a/apps/portals/admin/src/graphql.ts +++ b/apps/portals/admin/src/graphql.ts @@ -7,17 +7,17 @@ import { } from '@apollo/client' import { onError } from '@apollo/client/link/error' import { RetryLink } from '@apollo/client/link/retry' +import { createBffUrlGenerator } from '@island.is/react-spa/bff' +import { AdminPortalPaths } from './lib/paths' -import { authLink } from '@island.is/auth/react' +const bffUrlGenerator = createBffUrlGenerator(AdminPortalPaths.Base) -const uri = - process.env.NODE_ENV === 'development' - ? 'http://localhost:4444/api/graphql' - : '/api/graphql' +const uri = bffUrlGenerator('/api/graphql') const httpLink = new HttpLink({ uri: ({ operationName }) => `${uri}?op=${operationName}`, fetch, + credentials: 'include', }) const retryLink = new RetryLink() @@ -34,7 +34,7 @@ const errorLink = onError(({ graphQLErrors, networkError }) => { }) export const client = new ApolloClient({ - link: ApolloLink.from([retryLink, errorLink, authLink, httpLink]), + link: ApolloLink.from([retryLink, errorLink, httpLink]), cache: new InMemoryCache({ typePolicies: { UserProfile: { diff --git a/apps/portals/admin/src/main.tsx b/apps/portals/admin/src/main.tsx index 0d8890a30cbb..68c71119e767 100644 --- a/apps/portals/admin/src/main.tsx +++ b/apps/portals/admin/src/main.tsx @@ -1,11 +1,10 @@ import '@island.is/api/mocks' import { userMonitoring } from '@island.is/user-monitoring' -import React, { StrictMode } from 'react' +import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { isRunningOnEnvironment } from '@island.is/shared/utils' -import './auth' import environment from './environments/environment' import { App } from './app/App' diff --git a/apps/services/bff/src/app/app.module.ts b/apps/services/bff/src/app/app.module.ts index fb52a1faac6f..e46de0546366 100644 --- a/apps/services/bff/src/app/app.module.ts +++ b/apps/services/bff/src/app/app.module.ts @@ -6,6 +6,7 @@ import { environment } from '../environment' import { BffConfig } from './bff.config' import { AuthModule as AppAuthModule } from './modules/auth/auth.module' import { UserModule } from './modules/user/user.module' +import { ProxyModule } from './modules/proxy/proxy.module' @Module({ imports: [ @@ -17,6 +18,7 @@ import { UserModule } from './modules/user/user.module' }), UserModule, AppAuthModule, + ProxyModule, ], }) export class AppModule {} diff --git a/apps/services/bff/src/app/modules/auth/auth.module.ts b/apps/services/bff/src/app/modules/auth/auth.module.ts index 1d2bfe19ff04..6302dcc98f74 100644 --- a/apps/services/bff/src/app/modules/auth/auth.module.ts +++ b/apps/services/bff/src/app/modules/auth/auth.module.ts @@ -4,10 +4,11 @@ import { AuthService } from './auth.service' import { CacheModule } from '../cache/cache.module' import { PKCEService } from './pkce.service' import { CacheService } from '../cache/cache.service' +import { IdsService } from '../ids/ids.service' @Module({ imports: [CacheModule], controllers: [AuthController], - providers: [AuthService, PKCEService, CacheService], + providers: [AuthService, PKCEService, CacheService, IdsService], }) export class AuthModule {} diff --git a/apps/services/bff/src/app/modules/auth/auth.service.ts b/apps/services/bff/src/app/modules/auth/auth.service.ts index 024112c75c47..74736e12d643 100644 --- a/apps/services/bff/src/app/modules/auth/auth.service.ts +++ b/apps/services/bff/src/app/modules/auth/auth.service.ts @@ -5,21 +5,19 @@ import { ConfigType } from '@nestjs/config' import { Request, Response } from 'express' import { jwtDecode } from 'jwt-decode' +import { IdTokenClaims } from '@island.is/shared/types' import { uuid } from 'uuidv4' import { environment } from '../../../environment' import { BffConfig } from '../../bff.config' import { CacheService } from '../cache/cache.service' -import { - CachedTokenResponse, - IdTokenData, - ParResponse, - TokenResponse, -} from './auth.types' +import { IdsService } from '../ids/ids.service' +import { CachedTokenResponse } from './auth.types' import { PKCEService } from './pkce.service' import { CallbackLoginQuery } from './queries/callback-login.query' +import { CallbackLogoutQuery } from './queries/callback-logout.query' import { LoginQuery } from './queries/login.query' import { LogoutQuery } from './queries/logout.query' -import { CallbackLogoutQuery } from './queries/callback-logout.query' +import omit from 'lodash/omit' @Injectable() export class AuthService { @@ -34,6 +32,7 @@ export class AuthService { private readonly pkceService: PKCEService, private readonly cacheService: CacheService, + private readonly idsService: IdsService, ) { this.baseUrl = this.config.auth.issuer } @@ -59,88 +58,13 @@ export class AuthService { return regexPatterns.some((regex) => regex.test(uri)) } - /** - * Reusable fetch fn to make POST requests - */ - private async postRequest( - endpoint: string, - body: Record, - ): Promise { - try { - const response = await fetch(`${this.baseUrl}${endpoint}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams(body).toString(), - }) - - if (!response.ok) { - throw new BadRequestException(`HTTP error! Status: ${response.status}`) - } - - return await response.json() - } catch (error) { - this.logger.error( - `Error making request to ${endpoint}:`, - JSON.stringify(error), - ) - - throw new BadRequestException(`Failed to fetch from ${endpoint}`) - } - } - - /** - * Fetches the PAR (Pushed Authorization Requests) from the Ids - */ - private async fetchPAR({ - sid, - codeChallenge, - loginHint, - }: { - sid: string - codeChallenge: string - loginHint?: string - }) { - return this.postRequest('/connect/par', { - client_id: this.config.auth.clientId, - client_secret: this.config.auth.secret, - redirect_uri: this.config.auth.callbacksRedirectUris.login, - response_type: 'code', - response_mode: 'query', - scope: ['openid', 'profile', ...this.config.auth.scopes].join(' '), - state: sid, - code_challenge: codeChallenge, - code_challenge_method: 'S256', - ...(loginHint && { login_hint: loginHint }), - }) - } - - // Fetches tokens using the authorization code and code verifier - private async fetchTokens({ - code, - codeVerifier, - }: { - code: string - codeVerifier: string - }) { - return this.postRequest('/connect/token', { - grant_type: 'authorization_code', - code, - client_secret: this.config.auth.secret, - client_id: this.config.auth.clientId, - redirect_uri: this.config.auth.callbacksRedirectUris.login, - code_verifier: codeVerifier, - }) - } - /** * Get the origin URL from the request headers and add the global prefix */ private getOriginUrl(req: Request) { return `${(req.headers['origin'] || req.headers['referer'] || '') - // Remove trailing slash and add global prefix - .replace(/\/$/, '')}${environment.globalPrefix}` + // Remove trailing slash and add the client base path + .replace(/\/$/, '')}${environment.clientBasePath}` } /** @@ -197,7 +121,11 @@ export class AuthService { ttl: 60 * 60 * 24 * 7 * 1000, // 1 week }) - const parResponse = await this.fetchPAR({ sid, codeChallenge, loginHint }) + const parResponse = await this.idsService.getPar({ + sid, + codeChallenge, + loginHint, + }) const searchParams = new URLSearchParams({ request_uri: parResponse.request_uri, @@ -225,15 +153,16 @@ export class AuthService { }>(this.cacheService.createSessionKeyType('attempt', query.state)) // Get tokens and user information from the authorization code - const tokenResponse = await this.fetchTokens({ + const tokenResponse = await this.idsService.getTokens({ code: query.code, codeVerifier: loginAttemptData.codeVerifier, }) - const userProfile: IdTokenData = jwtDecode(tokenResponse.id_token) + const userProfile: IdTokenClaims = jwtDecode(tokenResponse.id_token) const sid = userProfile.sid const value: CachedTokenResponse = { - ...tokenResponse, + ...omit(tokenResponse, ['scope']), + scopes: tokenResponse.scope.split(' '), userProfile, } diff --git a/apps/services/bff/src/app/modules/auth/auth.types.ts b/apps/services/bff/src/app/modules/auth/auth.types.ts index 2abcdba920d0..4024060bff0a 100644 --- a/apps/services/bff/src/app/modules/auth/auth.types.ts +++ b/apps/services/bff/src/app/modules/auth/auth.types.ts @@ -1,87 +1,11 @@ -export type ParResponse = { - // An identifier for the authorization request, instead of sending the parameters that were just pushed - request_uri: string +import { IdTokenClaims } from '@island.is/shared/types' - // Time in seconds until the request_uri expires - expires_in: number -} - -export interface TokenResponse { - // ID token issued by the authorization server - id_token: string - - // Access token used to access protected resources - access_token: string - - // Time in seconds until the access token expires - expires_in: number - - // Type of the token issued, typically "Bearer" - token_type: string - - // Scopes associated with the access token - scope: string -} - -export interface IdTokenData { - // Issuer - iss: string - - // Not before (timestamp) - nbf: number - - // Issued at (timestamp) - iat: number - - // Expiration time (timestamp) - exp: number - - // Audience - aud: string - - // Authentication methods references - amr: string[] - - // Access token hash - at_hash: string - - // Session ID - sid: string - - // Subject identifier - sub: string - - // Authentication time (timestamp) - auth_time: number - - // Identity provider - idp: string - - // Authentication context class reference - acr: string - - // Subject type (e.g., "person") - subjectType: string - - // National ID - nationalId: string - - // Full name - name: string - - // Gender (e.g., "male") - gender: string - - // Birthdate in the format YYYY-MM-DD - birthdate: string - - // Locale (e.g., "is") - locale: string -} +import { TokenResponse } from '../ids/ids.types' -export type CachedTokenResponse = TokenResponse & { +export type CachedTokenResponse = Omit & { + scopes: string[] /** - * Decoded id token + * Decoded id token claims */ - userProfile: IdTokenData + userProfile: IdTokenClaims } diff --git a/apps/services/bff/src/app/modules/ids/ids.service.ts b/apps/services/bff/src/app/modules/ids/ids.service.ts new file mode 100644 index 000000000000..3399ec2ff182 --- /dev/null +++ b/apps/services/bff/src/app/modules/ids/ids.service.ts @@ -0,0 +1,113 @@ +import type { Logger } from '@island.is/logging' +import { LOGGER_PROVIDER } from '@island.is/logging' +import { BadRequestException, Inject, Injectable } from '@nestjs/common' +import { ConfigType } from '@nestjs/config' + +import { BffConfig } from '../../bff.config' +import { ParResponse, TokenResponse } from './ids.types' + +@Injectable() +export class IdsService { + private readonly baseUrl + + constructor( + @Inject(LOGGER_PROVIDER) + private logger: Logger, + + @Inject(BffConfig.KEY) + private readonly config: ConfigType, + ) { + this.baseUrl = this.config.auth.issuer + } + + /** + * Reusable fetch fn to make POST requests + */ + private async postRequest( + endpoint: string, + body: Record, + ): Promise { + try { + const response = await fetch(`${this.baseUrl}${endpoint}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams(body).toString(), + }) + + if (!response.ok) { + throw new BadRequestException(`HTTP error! Status: ${response.status}`) + } + + return await response.json() + } catch (error) { + this.logger.error( + `Error making request to ${endpoint}:`, + JSON.stringify(error), + ) + + throw new BadRequestException(`Failed to fetch from ${endpoint}`) + } + } + + /** + * Fetches the PAR (Pushed Authorization Requests) from the Ids + */ + public async getPar({ + sid, + codeChallenge, + loginHint, + }: { + sid: string + codeChallenge: string + loginHint?: string + }) { + return this.postRequest('/connect/par', { + client_id: this.config.auth.clientId, + client_secret: this.config.auth.secret, + redirect_uri: this.config.auth.callbacksRedirectUris.login, + response_type: 'code', + response_mode: 'query', + scope: [ + 'openid', + 'profile', + // Allows us to get refresh tokens + 'offline_access', + ...this.config.auth.scopes, + ].join(' '), + state: sid, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + ...(loginHint && { login_hint: loginHint }), + }) + } + + // Fetches tokens using the authorization code and code verifier + public async getTokens({ + code, + codeVerifier, + }: { + code: string + codeVerifier: string + }) { + return this.postRequest('/connect/token', { + grant_type: 'authorization_code', + code, + client_secret: this.config.auth.secret, + client_id: this.config.auth.clientId, + redirect_uri: this.config.auth.callbacksRedirectUris.login, + code_verifier: codeVerifier, + }) + } + + // Uses the refresh token to get a new access token + public async refreshToken(refreshToken: string) { + return this.postRequest('/connect/token', { + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_secret: this.config.auth.secret, + client_id: this.config.auth.clientId, + }) + } +} diff --git a/apps/services/bff/src/app/modules/ids/ids.types.ts b/apps/services/bff/src/app/modules/ids/ids.types.ts new file mode 100644 index 000000000000..f7466e1916c9 --- /dev/null +++ b/apps/services/bff/src/app/modules/ids/ids.types.ts @@ -0,0 +1,27 @@ +export type ParResponse = { + // An identifier for the authorization request, instead of sending the parameters that were just pushed + request_uri: string + + // Time in seconds until the request_uri expires + expires_in: number +} + +export interface TokenResponse { + // ID token issued by the authorization server + id_token: string + + // Access token used to access protected resources + access_token: string + + // Time in seconds until the access token expires + expires_in: number + + // Type of the token issued, typically "Bearer" + token_type: string + + // Refresh token used to obtain a new access token + refresh_token: string + + // Scopes associated with the access token + scope: string +} diff --git a/apps/services/bff/src/app/modules/proxy/proxy.controller.ts b/apps/services/bff/src/app/modules/proxy/proxy.controller.ts new file mode 100644 index 000000000000..f04647ff03a5 --- /dev/null +++ b/apps/services/bff/src/app/modules/proxy/proxy.controller.ts @@ -0,0 +1,16 @@ +import { Controller, Post, Req, Res, VERSION_NEUTRAL } from '@nestjs/common' +import { Request, Response } from 'express' +import { ProxyService } from './proxy.service' + +@Controller({ + path: 'api/graphql', + version: [VERSION_NEUTRAL, '1'], +}) +export class ProxyController { + constructor(private proxyService: ProxyService) {} + + @Post() + async login(@Req() req: Request, @Res() res: Response): Promise { + return this.proxyService.proxyRequest(req) + } +} diff --git a/apps/services/bff/src/app/modules/proxy/proxy.module.ts b/apps/services/bff/src/app/modules/proxy/proxy.module.ts new file mode 100644 index 000000000000..cf6e7df79f0e --- /dev/null +++ b/apps/services/bff/src/app/modules/proxy/proxy.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common' +import { CacheModule } from '../cache/cache.module' +import { CacheService } from '../cache/cache.service' +import { ProxyController } from './proxy.controller' +import { ProxyService } from './proxy.service' +import { IdsService } from '../ids/ids.service' + +@Module({ + imports: [CacheModule], + controllers: [ProxyController], + providers: [ProxyService, CacheService, IdsService], +}) +export class ProxyModule {} diff --git a/apps/services/bff/src/app/modules/proxy/proxy.service.ts b/apps/services/bff/src/app/modules/proxy/proxy.service.ts new file mode 100644 index 000000000000..6c4b1b80949e --- /dev/null +++ b/apps/services/bff/src/app/modules/proxy/proxy.service.ts @@ -0,0 +1,65 @@ +import type { Logger } from '@island.is/logging' +import { LOGGER_PROVIDER } from '@island.is/logging' +import { Inject, Injectable, UnauthorizedException } from '@nestjs/common' +import { Request } from 'express' + +import { CachedTokenResponse } from '../auth/auth.types' +import { CacheService } from '../cache/cache.service' +import { Observable } from 'rxjs' +import { IdsService } from '../ids/ids.service' + +@Injectable() +export class ProxyService { + constructor( + @Inject(LOGGER_PROVIDER) + private logger: Logger, + + private readonly cacheService: CacheService, + private readonly idsService: IdsService, + ) {} + + public async proxyRequest(req: Request): Promise { + //Promise> { + const sid = req.cookies['sid'] + + if (!sid) { + throw new UnauthorizedException() + } + + try { + // TODO this is a work in progress + // A proxy for the [island.is](http://island.is) GraphQL API, which: + + // 1. Reads session information from redis using session cookie. + // 2. Performs token refresh using the refresh token if the access token is expired. + // 3. Forwards request parameters to the GraphQL API endpoint, including the user’s access token. + // 4. Returns 401 if token refresh fails or no session information is found. + + const cachedTokenResponse = + await this.cacheService.get( + this.cacheService.createSessionKeyType('current', sid), + ) + + // Check if the access token is expired + + if (cachedTokenResponse.expires_in) { + // Refresh the token + const newTokenResponse = await this.idsService.refreshToken( + cachedTokenResponse.refresh_token, + ) + + console.log('newTokenResponse', newTokenResponse) + } + + if (!cachedTokenResponse.userProfile) { + throw new Error('userProfile not found in cache') + } + + //return cachedTokenResponse.userProfile + } catch (error) { + this.logger.error('Error getting user from cache: ', error) + + throw new UnauthorizedException() + } + } +} diff --git a/apps/services/bff/src/app/modules/user/user.controller.ts b/apps/services/bff/src/app/modules/user/user.controller.ts index 72c82b5e873f..1b002291fe1d 100644 --- a/apps/services/bff/src/app/modules/user/user.controller.ts +++ b/apps/services/bff/src/app/modules/user/user.controller.ts @@ -1,7 +1,9 @@ -import { Controller, Get, Req, VERSION_NEUTRAL } from '@nestjs/common' import type { Request } from 'express' + +import type { BffUser } from '@island.is/shared/types' +import { Controller, Get, Req, VERSION_NEUTRAL } from '@nestjs/common' + import { UserService } from './user.service' -import { IdTokenData } from '../auth/auth.types' @Controller({ path: 'user', @@ -11,7 +13,7 @@ export class UserController { constructor(private readonly userService: UserService) {} @Get() - async getUser(@Req() req: Request): Promise { + async getUser(@Req() req: Request): Promise { return this.userService.getUser(req) } } diff --git a/apps/services/bff/src/app/modules/user/user.service.ts b/apps/services/bff/src/app/modules/user/user.service.ts index e3c9db29c074..dfa19589e3d3 100644 --- a/apps/services/bff/src/app/modules/user/user.service.ts +++ b/apps/services/bff/src/app/modules/user/user.service.ts @@ -3,8 +3,9 @@ import { LOGGER_PROVIDER } from '@island.is/logging' import { Inject, Injectable, UnauthorizedException } from '@nestjs/common' import { Request } from 'express' -import { CachedTokenResponse, IdTokenData } from '../auth/auth.types' +import { CachedTokenResponse } from '../auth/auth.types' import { CacheService } from '../cache/cache.service' +import { BffUser } from '@island.is/shared/types' @Injectable() export class UserService { @@ -15,7 +16,7 @@ export class UserService { private readonly cacheService: CacheService, ) {} - public async getUser(req: Request): Promise { + public async getUser(req: Request): Promise { const sid = req.cookies['sid'] if (!sid) { @@ -32,7 +33,13 @@ export class UserService { throw new Error('userProfile not found in cache') } - return cachedTokenResponse.userProfile + return { + scopes: cachedTokenResponse.scopes, + profile: { + ...cachedTokenResponse.userProfile, + dateOfBirth: new Date(cachedTokenResponse.userProfile.birthdate), + }, + } } catch (error) { this.logger.error('Error getting user from cache: ', error) diff --git a/apps/services/bff/src/environment/environment.schema.ts b/apps/services/bff/src/environment/environment.schema.ts index f513115ee94e..1f154fb4fbea 100644 --- a/apps/services/bff/src/environment/environment.schema.ts +++ b/apps/services/bff/src/environment/environment.schema.ts @@ -17,6 +17,10 @@ export const authSchema = z.strictObject({ export const environmentSchema = z.strictObject({ production: z.boolean(), port: z.number(), + /** + * The client base path + */ + clientBasePath: z.string(), /** * The global prefix for the API */ diff --git a/apps/services/bff/src/environment/environment.ts b/apps/services/bff/src/environment/environment.ts index 2a563405526f..8cec77918b4a 100644 --- a/apps/services/bff/src/environment/environment.ts +++ b/apps/services/bff/src/environment/environment.ts @@ -12,6 +12,7 @@ const issuer = requiredString('IDENTITY_SERVER_ISSUER_URL') export const environment: BffEnvironmentSchema = { production: isProduction, + clientBasePath: requiredString('BFF_CLIENT_BASE_PATH'), globalPrefix: requiredString('BFF_API_URL_PREFIX'), audit: { groupName: requiredString('AUDIT_GROUP_NAME'), diff --git a/libs/portals/core/src/components/PortalRouter.tsx b/libs/portals/core/src/components/PortalRouter.tsx index 38038aa1a0c1..09f8038d6fed 100644 --- a/libs/portals/core/src/components/PortalRouter.tsx +++ b/libs/portals/core/src/components/PortalRouter.tsx @@ -19,6 +19,7 @@ import { PortalModule, PortalRoute } from '../types/portalCore' import { PortalMeta, PortalProvider } from './PortalProvider' import { prepareRouterData } from '../utils/router/prepareRouterData' import { m } from '../lib/messages' +import { useUserInfo } from '@island.is/react-spa/bff' type PortalRouterProps = { modules: PortalModule[] @@ -37,7 +38,7 @@ export const PortalRouter = ({ const { formatMessage } = useLocale() const router = useRef>() const [error, setError] = useState(null) - const { userInfo } = useAuth() + const userInfo = useUserInfo() const featureFlagClient = useFeatureFlagClient() const [routerData, setRouterData] = useState<{ modules: PortalModule[] diff --git a/libs/react-spa/bff/.babelrc b/libs/react-spa/bff/.babelrc new file mode 100644 index 000000000000..1ea870ead410 --- /dev/null +++ b/libs/react-spa/bff/.babelrc @@ -0,0 +1,12 @@ +{ + "presets": [ + [ + "@nx/react/babel", + { + "runtime": "automatic", + "useBuiltIns": "usage" + } + ] + ], + "plugins": [] +} diff --git a/libs/react-spa/bff/.eslintrc.json b/libs/react-spa/bff/.eslintrc.json new file mode 100644 index 000000000000..75b85077debb --- /dev/null +++ b/libs/react-spa/bff/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["plugin:@nx/react", "../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/libs/react-spa/bff/README.md b/libs/react-spa/bff/README.md new file mode 100644 index 000000000000..53a6d1e6ec85 --- /dev/null +++ b/libs/react-spa/bff/README.md @@ -0,0 +1,7 @@ +# React SPA BFF + +This library is intended to be used by a React SPA application. It handles authentication with a BFF(Backend For Frontend) server. + +## Running unit tests + +Run `nx test react-spa-bff` to execute the unit tests via [Jest](https://jestjs.io). diff --git a/libs/react-spa/bff/jest.config.ts b/libs/react-spa/bff/jest.config.ts new file mode 100644 index 000000000000..46183d6c70f0 --- /dev/null +++ b/libs/react-spa/bff/jest.config.ts @@ -0,0 +1,11 @@ +/* eslint-disable */ +export default { + displayName: 'bff', + preset: '../../../jest.preset.js', + transform: { + '^(?!.*\\.(js|jsx|ts|tsx|css|json)$)': '@nx/react/plugins/jest', + '^.+\\.[tj]sx?$': ['babel-jest', { presets: ['@nx/react/babel'] }], + }, + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], + coverageDirectory: '../../../coverage/libs/react-spa/bff', +} diff --git a/libs/react-spa/bff/project.json b/libs/react-spa/bff/project.json new file mode 100644 index 000000000000..f54324da87b6 --- /dev/null +++ b/libs/react-spa/bff/project.json @@ -0,0 +1,24 @@ +{ + "name": "react-spa-bff", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/react-spa/bff/src", + "projectType": "library", + "tags": [ + "lib:react-spa", + "scope:react-spa" + ], + "targets": { + "lint": { + "executor": "@nx/eslint:lint" + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": [ + "{workspaceRoot}/coverage/{projectRoot}" + ], + "options": { + "jestConfig": "libs/react-spa/bff/jest.config.ts" + } + } + } +} diff --git a/libs/react-spa/bff/src/index.ts b/libs/react-spa/bff/src/index.ts new file mode 100644 index 000000000000..cd26b588d40c --- /dev/null +++ b/libs/react-spa/bff/src/index.ts @@ -0,0 +1,3 @@ +export * from './lib/BffProvider' +export * from './lib/bff.hooks' +export * from './lib/bff.utils' diff --git a/libs/react-spa/bff/src/lib/BffContext.tsx b/libs/react-spa/bff/src/lib/BffContext.tsx new file mode 100644 index 000000000000..5b158f41dde0 --- /dev/null +++ b/libs/react-spa/bff/src/lib/BffContext.tsx @@ -0,0 +1,11 @@ +import { createContext } from 'react' + +import { BffReducerState } from './bff.state' + +export interface BffContextType extends BffReducerState { + signIn(): void + signOut(): void + switchUser(nationalId?: string): void +} + +export const BffContext = createContext(undefined) diff --git a/libs/react-spa/bff/src/lib/BffProvider.tsx b/libs/react-spa/bff/src/lib/BffProvider.tsx new file mode 100644 index 000000000000..e7a0d3132a3a --- /dev/null +++ b/libs/react-spa/bff/src/lib/BffProvider.tsx @@ -0,0 +1,109 @@ +import { useEffectOnce } from '@island.is/react-spa/shared' +import { ReactNode, useCallback, useReducer } from 'react' + +import { LoadingScreen } from '@island.is/react/components' +import { createBffUrlGenerator } from './bff.utils' +import { BffContext } from './BffContext' +import { ErrorScreen } from './ErrorScreen' +import { reducer, initialState, ActionType } from './bff.state' + +type BffProviderProps = { + children: ReactNode + /** + * The base path of the application. + */ + applicationBasePath: string +} + +export const BffProvider = ({ + children, + applicationBasePath, +}: BffProviderProps) => { + const bffUrlGenerator = createBffUrlGenerator(applicationBasePath) + const [state, dispatch] = useReducer(reducer, initialState) + + const checkLogin = async () => { + dispatch({ + type: ActionType.SIGNIN_START, + }) + + try { + const res = await fetch(bffUrlGenerator('/user'), { + credentials: 'include', + }) + + if (!res.ok) { + dispatch({ + type: ActionType.SIGNIN_FAILURE, + }) + + window.location.href = bffUrlGenerator('/login') + + return + } + + const user = await res.json() + + dispatch({ + type: ActionType.SIGNIN_SUCCESS, + payload: user, + }) + } catch (error) { + dispatch({ + type: ActionType.ERROR, + payload: error, + }) + } + } + + const signIn = useCallback(() => { + window.location.href = bffUrlGenerator('/login') + }, [bffUrlGenerator]) + + const signOut = useCallback(() => { + if (!state.userInfo) { + return + } + + window.location.href = bffUrlGenerator( + `/logout?sid=${state.userInfo.profile.sid}`, + ) + }, [bffUrlGenerator, state.userInfo]) + + const switchUser = useCallback((_nationalId?: string) => { + // TODO + }, []) + + useEffectOnce(() => { + checkLogin() + }) + + const onRetry = () => { + window.location.href = applicationBasePath + } + + const { authState } = state + const showErrorScreen = authState === 'error' + const showLoadingScreen = authState === 'loading' || authState === 'switching' + const isLoggedIn = authState === 'logged-in' + + return ( + + {isLoggedIn && } + {showErrorScreen ? ( + + ) : showLoadingScreen ? ( + + ) : isLoggedIn ? ( + children + ) : null} + + ) +} diff --git a/libs/react-spa/bff/src/lib/ErrorScreen.css.ts b/libs/react-spa/bff/src/lib/ErrorScreen.css.ts new file mode 100644 index 000000000000..62d600b0be1b --- /dev/null +++ b/libs/react-spa/bff/src/lib/ErrorScreen.css.ts @@ -0,0 +1,5 @@ +import { style } from '@vanilla-extract/css' + +export const fullScreen = style({ + height: '100vh', +}) diff --git a/libs/react-spa/bff/src/lib/ErrorScreen.tsx b/libs/react-spa/bff/src/lib/ErrorScreen.tsx new file mode 100644 index 000000000000..9d73d075e781 --- /dev/null +++ b/libs/react-spa/bff/src/lib/ErrorScreen.tsx @@ -0,0 +1,37 @@ +import { Box, Button, ProblemTemplate } from '@island.is/island-ui/core' +import { fullScreen } from './ErrorScreen.css' + +type ErrorScreenProps = { + /** + * Retry callback + */ + onRetry(): void +} + +/** + * This screen is unfortunately not translated because at this point we don't have a user locale. + */ +export const ErrorScreen = ({ onRetry }: ErrorScreenProps) => ( + + + Vinsamlegast reyndu aftur síðar.{' '} + + + } + /> + +) diff --git a/libs/react-spa/bff/src/lib/bff.hooks.ts b/libs/react-spa/bff/src/lib/bff.hooks.ts new file mode 100644 index 000000000000..e21c4528a7e6 --- /dev/null +++ b/libs/react-spa/bff/src/lib/bff.hooks.ts @@ -0,0 +1,22 @@ +import { useContext } from 'react' +import { BffContext } from './BffContext' + +export const useAuth = () => { + const context = useContext(BffContext) + + if (!context) { + throw new Error('useAuth must be used within a BffProvider') + } + + return context +} + +export const useUserInfo = () => { + const context = useContext(BffContext) + + if (!context?.userInfo) { + throw new Error('User info is not available. Is the user authenticated?') + } + + return context.userInfo +} diff --git a/libs/react-spa/bff/src/lib/bff.state.ts b/libs/react-spa/bff/src/lib/bff.state.ts new file mode 100644 index 000000000000..245b25cc2ec6 --- /dev/null +++ b/libs/react-spa/bff/src/lib/bff.state.ts @@ -0,0 +1,106 @@ +import { User } from '@island.is/shared/types' + +export type BffState = + | 'logged-out' + | 'loading' + | 'logged-in' + | 'failed' + | 'switching' + | 'logging-out' + | 'error' + +export enum ActionType { + SIGNIN_START = 'SIGNIN_START', + SIGNIN_SUCCESS = 'SIGNIN_SUCCESS', + SIGNIN_FAILURE = 'SIGNIN_FAILURE', + LOGGING_OUT = 'LOGGING_OUT', + LOGGED_OUT = 'LOGGED_OUT', + USER_LOADED = 'USER_LOADED', + SWITCH_USER = 'SWITCH_USER', + ERROR = 'ERROR', +} + +export interface BffReducerState { + userInfo: User | null + authState: BffState + isAuthenticated: boolean + baseUrl?: string + error?: Error +} + +export const initialState: BffReducerState = { + userInfo: null, + authState: 'logged-out', + isAuthenticated: false, +} + +export type Action = + | { + type: + | ActionType.SIGNIN_START + | ActionType.SIGNIN_FAILURE + | ActionType.LOGGING_OUT + | ActionType.LOGGED_OUT + | ActionType.SWITCH_USER + } + | { type: ActionType.SIGNIN_SUCCESS | ActionType.USER_LOADED; payload: User } + | { type: ActionType.ERROR; payload: Error } + +export const reducer = ( + state: BffReducerState, + action: Action, +): BffReducerState => { + const withState = (newState: Partial) => ({ + ...state, + ...newState, + }) + + switch (action.type) { + case ActionType.SIGNIN_START: + return withState({ + authState: 'loading', + }) + + case ActionType.SIGNIN_SUCCESS: + return withState({ + userInfo: action.payload, + authState: 'logged-in', + isAuthenticated: true, + }) + + case ActionType.USER_LOADED: + return state.isAuthenticated + ? withState({ + userInfo: action.payload, + }) + : state + + case ActionType.SIGNIN_FAILURE: + return withState({ + authState: 'failed', + }) + + case ActionType.LOGGING_OUT: + return withState({ + authState: 'logging-out', + }) + + case ActionType.SWITCH_USER: + return withState({ + authState: 'switching', + }) + + case ActionType.ERROR: + return withState({ + error: action.payload, + }) + + case ActionType.LOGGED_OUT: + return { + ...initialState, + } + + default: + return state + } +} diff --git a/libs/react-spa/bff/src/lib/bff.utils.ts b/libs/react-spa/bff/src/lib/bff.utils.ts new file mode 100644 index 000000000000..d65084661a35 --- /dev/null +++ b/libs/react-spa/bff/src/lib/bff.utils.ts @@ -0,0 +1,23 @@ +/** + * Creates a function that can generate a BFF URLs based on the environment. + * @usage + * const bffBaseUrl = createBffUrlGenerator('/stjornbord) + * const userUrl = bffBaseUrl('/user') + */ +export const createBffUrlGenerator = (basePath: string) => { + // Trim any leading and trailing slashes from the basePath to avoid extra slashes + const sanitizedBasePath = sanitizePath(basePath) + const origin = + process.env.NODE_ENV === 'development' + ? 'http://localhost:3333' // When developing against the BFF locally, use localhost + : sanitizePath(window.location.origin) // Use current window origin for production + + const baseUrl = `${origin}/${sanitizedBasePath}/bff` + + return (relativePath = '') => `${baseUrl}${relativePath}` +} + +/** + * Trim any leading and trailing slashes + */ +const sanitizePath = (path: string) => path.replace(/^\/+|\/+$/g, '') diff --git a/libs/react-spa/bff/tsconfig.json b/libs/react-spa/bff/tsconfig.json new file mode 100644 index 000000000000..4daaf45cd328 --- /dev/null +++ b/libs/react-spa/bff/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "allowJs": false, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../../tsconfig.base.json" +} diff --git a/libs/react-spa/bff/tsconfig.lib.json b/libs/react-spa/bff/tsconfig.lib.json new file mode 100644 index 000000000000..21799b3e6ba3 --- /dev/null +++ b/libs/react-spa/bff/tsconfig.lib.json @@ -0,0 +1,24 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "types": [ + "node", + + "@nx/react/typings/cssmodule.d.ts", + "@nx/react/typings/image.d.ts" + ] + }, + "exclude": [ + "jest.config.ts", + "src/**/*.spec.ts", + "src/**/*.test.ts", + "src/**/*.spec.tsx", + "src/**/*.test.tsx", + "src/**/*.spec.js", + "src/**/*.test.js", + "src/**/*.spec.jsx", + "src/**/*.test.jsx" + ], + "include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"] +} diff --git a/libs/react-spa/bff/tsconfig.spec.json b/libs/react-spa/bff/tsconfig.spec.json new file mode 100644 index 000000000000..25b7af8f6d00 --- /dev/null +++ b/libs/react-spa/bff/tsconfig.spec.json @@ -0,0 +1,20 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.test.js", + "src/**/*.spec.js", + "src/**/*.test.jsx", + "src/**/*.spec.jsx", + "src/**/*.d.ts" + ] +} diff --git a/libs/shared/types/src/index.ts b/libs/shared/types/src/index.ts index cb35f1030dd4..66ea2586686e 100644 --- a/libs/shared/types/src/index.ts +++ b/libs/shared/types/src/index.ts @@ -10,3 +10,4 @@ export * from './lib/delegation' export * from './lib/environment' export * from './lib/searchable-content-types' export * from './lib/PersonalRepresentativeDelegationType' +export * from './lib/bff' diff --git a/libs/shared/types/src/lib/bff.ts b/libs/shared/types/src/lib/bff.ts new file mode 100644 index 000000000000..6bc8d8308b06 --- /dev/null +++ b/libs/shared/types/src/lib/bff.ts @@ -0,0 +1,72 @@ +import { AuthDelegationType } from './delegation' + +export interface IdTokenClaims { + // Issuer + iss: string + + // Not before (timestamp) + nbf: number + + // Issued at (timestamp) + iat: number + + // Expiration time (timestamp) + exp: number + + // Audience + aud: string + + // Authentication methods references + amr: string[] + + // Access token hash + at_hash: string + + // Session ID + sid: string + + // Subject identifier + sub: string + + // Authentication time (timestamp) + auth_time: number + + // Identity provider + idp: string + + // Authentication context class reference + acr: string + + // Subject type (e.g., "person") + subjectType: 'person' | 'legalEntity' + + // National ID + nationalId: string + + // Full name + name: string + + // Gender (e.g., "male") + gender: string + + // Birthdate in the format YYYY-MM-DD + birthdate: string + + // Locale (e.g., "is") + locale: string + + actor?: { + nationalId: string + name: string + } + + // Delegation type + delegationType?: AuthDelegationType[] +} + +export type BffUser = { + scopes: string[] + profile: IdTokenClaims & { + dateOfBirth: Date + } +} diff --git a/tsconfig.base.json b/tsconfig.base.json index 7a4b67804e9c..7a7ad53fa4f7 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -965,6 +965,7 @@ "@island.is/portals/shared-modules/delegations/messages": [ "libs/portals/shared-modules/delegations/src/lib/messages.ts" ], + "@island.is/react-spa/bff": ["libs/react-spa/bff/src/index.ts"], "@island.is/react-spa/shared": ["libs/react-spa/shared/src/index.ts"], "@island.is/react/components": ["libs/react/components/src/index.ts"], "@island.is/react/feature-flags": [ From 9f98a7a0064252e2abf1c7658b3732f89a4851aa Mon Sep 17 00:00:00 2001 From: andes-it Date: Wed, 4 Sep 2024 15:30:01 +0000 Subject: [PATCH 027/248] chore: nx format:write update dirty files --- libs/react-spa/bff/project.json | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/libs/react-spa/bff/project.json b/libs/react-spa/bff/project.json index f54324da87b6..252e6f2aab94 100644 --- a/libs/react-spa/bff/project.json +++ b/libs/react-spa/bff/project.json @@ -3,19 +3,14 @@ "$schema": "../../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "libs/react-spa/bff/src", "projectType": "library", - "tags": [ - "lib:react-spa", - "scope:react-spa" - ], + "tags": ["lib:react-spa", "scope:react-spa"], "targets": { "lint": { "executor": "@nx/eslint:lint" }, "test": { "executor": "@nx/jest:jest", - "outputs": [ - "{workspaceRoot}/coverage/{projectRoot}" - ], + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], "options": { "jestConfig": "libs/react-spa/bff/jest.config.ts" } From e751cc236fcf728194751cb38824804847990bbf Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Thu, 5 Sep 2024 20:01:09 +0000 Subject: [PATCH 028/248] Finishing proxy handling by the bff --- apps/api/src/app/environments/environment.ts | 8 ++ apps/api/src/main.ts | 4 + apps/services/bff/infra/admin-portal.infra.ts | 25 +++-- apps/services/bff/src/app/app.module.ts | 2 + apps/services/bff/src/app/bff.config.ts | 2 + .../bff/src/app/modules/auth/auth.module.ts | 8 +- .../bff/src/app/modules/auth/auth.service.ts | 50 +++++++--- .../bff/src/app/modules/auth/auth.types.ts | 6 ++ .../bff/src/app/modules/cache/cache.module.ts | 58 ++++++----- .../src/app/modules/proxy/proxy.controller.ts | 4 +- .../bff/src/app/modules/proxy/proxy.module.ts | 9 +- .../src/app/modules/proxy/proxy.service.ts | 99 ++++++++++++++----- .../bff/src/app/modules/user/user.module.ts | 8 +- .../bff/src/app/modules/user/user.service.ts | 37 +++++-- apps/services/bff/src/app/utils/isExpired.ts | 6 ++ .../bff/src/environment/environment.schema.ts | 5 + .../bff/src/environment/environment.ts | 4 +- 17 files changed, 237 insertions(+), 98 deletions(-) create mode 100644 apps/services/bff/src/app/utils/isExpired.ts diff --git a/apps/api/src/app/environments/environment.ts b/apps/api/src/app/environments/environment.ts index 588fbe19ccfe..c79e3b5528a8 100644 --- a/apps/api/src/app/environments/environment.ts +++ b/apps/api/src/app/environments/environment.ts @@ -96,7 +96,9 @@ const prodConfig = () => ({ passphrase: process.env.ISLYKILL_SERVICE_PASSPHRASE, basePath: process.env.ISLYKILL_SERVICE_BASEPATH, }, + enableCors: undefined, }) + const devConfig = () => ({ production: false, xroad: { @@ -206,7 +208,13 @@ const devConfig = () => ({ passphrase: process.env.ISLYKILL_SERVICE_PASSPHRASE, basePath: process.env.ISLYKILL_SERVICE_BASEPATH, }, + enableCors: { + origin: 'http://localhost:3333', + methods: ['POST'], + credentials: true, + }, }) + export const getConfig = process.env.PROD_MODE === 'true' || process.env.NODE_ENV === 'production' ? prodConfig() diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 5fa1ec05053d..0b95dc41361d 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,5 +1,6 @@ import { bootstrap } from '@island.is/infra-nest-server' import { AppModule } from './app/app.module' +import { getConfig as config } from './app/environments' bootstrap({ appModule: AppModule, @@ -7,4 +8,7 @@ bootstrap({ port: 4444, stripNonClassValidatorInputs: false, jsonBodyLimit: '300kb', + ...(!config.production && { + enableCors: config.enableCors, + }), }) diff --git a/apps/services/bff/infra/admin-portal.infra.ts b/apps/services/bff/infra/admin-portal.infra.ts index 73b89a82dfac..f8594051ca54 100644 --- a/apps/services/bff/infra/admin-portal.infra.ts +++ b/apps/services/bff/infra/admin-portal.infra.ts @@ -1,5 +1,17 @@ import { json, service, ServiceBuilder } from '../../../../infra/src/dsl/dsl' +const generateWebBaseUrls = (path = '') => { + if (!path.startsWith('/')) { + path = `/${path}` + } + + return { + dev: `https://beta.dev01.devland.is${path}`, + staging: `https://beta.staging01.devland.is${path}`, + prod: `https://island.is${path}`, + } +} + export const serviceSetup = (): ServiceBuilder<'services-bff-admin-portal'> => service('services-bff-admin-portal') .namespace('services-bff') @@ -12,16 +24,9 @@ export const serviceSetup = (): ServiceBuilder<'services-bff-admin-portal'> => staging: 'https://identity-server.staging01.devland.is', prod: 'https://innskra.island.is', }, - BFF_CALLBACKS_BASE_PATH: { - dev: 'https://beta.dev01.devland.is/stjornbord/bff/callbacks', - staging: 'https://beta.staging01.devland.is/stjornbord/bff/callbacks', - prod: 'https://island.is/stjornbord/bff/callbacks', - }, - BFF_LOGOUT_REDIRECT_PATH: { - dev: 'https://beta.dev01.devland.is', - staging: 'https://beta.staging01.devland.is', - prod: 'https://island.is', - }, + BFF_CALLBACKS_BASE_PATH: generateWebBaseUrls('/stjornbord/bff/callbacks'), + BFF_LOGOUT_REDIRECT_PATH: generateWebBaseUrls(), + BFF_PROXY_API_ENDPOINT: generateWebBaseUrls('/api/graphql'), IDENTITY_SERVER_CLIENT_SCOPES: json([ '@admin.island.is/delegation-system', '@admin.island.is/delegation-system:admin', diff --git a/apps/services/bff/src/app/app.module.ts b/apps/services/bff/src/app/app.module.ts index e46de0546366..ebd139bc4e1c 100644 --- a/apps/services/bff/src/app/app.module.ts +++ b/apps/services/bff/src/app/app.module.ts @@ -7,11 +7,13 @@ import { BffConfig } from './bff.config' import { AuthModule as AppAuthModule } from './modules/auth/auth.module' import { UserModule } from './modules/user/user.module' import { ProxyModule } from './modules/proxy/proxy.module' +import { CacheModule } from './modules/cache/cache.module' @Module({ imports: [ AuditModule.forRoot(environment.audit), BaseAuthModule.register(environment.auth), + CacheModule.register(), ConfigModule.forRoot({ isGlobal: true, load: [IdsClientConfig, BffConfig], diff --git a/apps/services/bff/src/app/bff.config.ts b/apps/services/bff/src/app/bff.config.ts index 9845ceda0e45..fc1c6d1a0d27 100644 --- a/apps/services/bff/src/app/bff.config.ts +++ b/apps/services/bff/src/app/bff.config.ts @@ -9,6 +9,7 @@ const BffConfigSchema = z.object({ nodes: z.array(z.string()), ssl: z.boolean(), }), + graphqlApiEndpont: z.string(), auth: authSchema, }) @@ -17,6 +18,7 @@ export const BffConfig = defineConfig({ schema: BffConfigSchema, load(env) { return { + graphqlApiEndpont: env.required('BFF_PROXY_API_ENDPOINT'), redis: { nodes: env.requiredJSON('REDIS_URL_NODE_01', [ 'localhost:7000', diff --git a/apps/services/bff/src/app/modules/auth/auth.module.ts b/apps/services/bff/src/app/modules/auth/auth.module.ts index 6302dcc98f74..82cf55006112 100644 --- a/apps/services/bff/src/app/modules/auth/auth.module.ts +++ b/apps/services/bff/src/app/modules/auth/auth.module.ts @@ -1,14 +1,12 @@ import { Module } from '@nestjs/common' +import { IdsService } from '../ids/ids.service' import { AuthController } from './auth.controller' import { AuthService } from './auth.service' -import { CacheModule } from '../cache/cache.module' import { PKCEService } from './pkce.service' -import { CacheService } from '../cache/cache.service' -import { IdsService } from '../ids/ids.service' @Module({ - imports: [CacheModule], controllers: [AuthController], - providers: [AuthService, PKCEService, CacheService, IdsService], + providers: [AuthService, PKCEService, IdsService], + exports: [AuthService], }) export class AuthModule {} diff --git a/apps/services/bff/src/app/modules/auth/auth.service.ts b/apps/services/bff/src/app/modules/auth/auth.service.ts index 74736e12d643..e0b9c39b38c9 100644 --- a/apps/services/bff/src/app/modules/auth/auth.service.ts +++ b/apps/services/bff/src/app/modules/auth/auth.service.ts @@ -6,18 +6,19 @@ import { Request, Response } from 'express' import { jwtDecode } from 'jwt-decode' import { IdTokenClaims } from '@island.is/shared/types' +import omit from 'lodash/omit' import { uuid } from 'uuidv4' import { environment } from '../../../environment' import { BffConfig } from '../../bff.config' import { CacheService } from '../cache/cache.service' import { IdsService } from '../ids/ids.service' +import { TokenResponse } from '../ids/ids.types' import { CachedTokenResponse } from './auth.types' import { PKCEService } from './pkce.service' import { CallbackLoginQuery } from './queries/callback-login.query' import { CallbackLogoutQuery } from './queries/callback-logout.query' import { LoginQuery } from './queries/login.query' import { LogoutQuery } from './queries/logout.query' -import omit from 'lodash/omit' @Injectable() export class AuthService { @@ -58,6 +59,36 @@ export class AuthService { return regexPatterns.some((regex) => regex.test(uri)) } + /** + * Formats and updates the token cache with new token response data. + */ + public async updateTokenCache( + tokenResponse: TokenResponse, + ): Promise { + const userProfile: IdTokenClaims = jwtDecode(tokenResponse.id_token) + const decodedAccessToken = jwtDecode(tokenResponse.access_token) + + const value: CachedTokenResponse = { + ...omit(tokenResponse, ['scope']), + scopes: tokenResponse.scope.split(' '), + userProfile, + accessTokenExp: + // Prefer the exact expiration time from the access token + decodedAccessToken.exp || + // Fallback to token response expiration time in seconds + new Date(Date.now() + tokenResponse.expires_in * 1000).getTime(), + } + + // Save the tokenResponse to the cache + await this.cacheService.save({ + key: this.cacheService.createSessionKeyType('current', userProfile.sid), + value, + ttl: 60 * 60 * 1000, // 1 hour + }) + + return value + } + /** * Get the origin URL from the request headers and add the global prefix */ @@ -158,20 +189,7 @@ export class AuthService { codeVerifier: loginAttemptData.codeVerifier, }) - const userProfile: IdTokenClaims = jwtDecode(tokenResponse.id_token) - const sid = userProfile.sid - const value: CachedTokenResponse = { - ...omit(tokenResponse, ['scope']), - scopes: tokenResponse.scope.split(' '), - userProfile, - } - - // Save the tokenResponse to the cache - await this.cacheService.save({ - key: this.cacheService.createSessionKeyType('current', sid), - value, - ttl: 60 * 60 * 1000, // 1 hour - }) + const value = await this.updateTokenCache(tokenResponse) // Clean up the login attempt from the cache since we have a successful login. await this.cacheService.delete( @@ -179,7 +197,7 @@ export class AuthService { ) // Create session cookie with successful login session id - res.cookie('sid', sid, { + res.cookie('sid', value.userProfile.sid, { httpOnly: true, secure: true, sameSite: 'strict', diff --git a/apps/services/bff/src/app/modules/auth/auth.types.ts b/apps/services/bff/src/app/modules/auth/auth.types.ts index 4024060bff0a..6e8c40896c01 100644 --- a/apps/services/bff/src/app/modules/auth/auth.types.ts +++ b/apps/services/bff/src/app/modules/auth/auth.types.ts @@ -4,8 +4,14 @@ import { TokenResponse } from '../ids/ids.types' export type CachedTokenResponse = Omit & { scopes: string[] + /** * Decoded id token claims */ userProfile: IdTokenClaims + + /** + * Expiration time of the access token + */ + accessTokenExp: number } diff --git a/apps/services/bff/src/app/modules/cache/cache.module.ts b/apps/services/bff/src/app/modules/cache/cache.module.ts index 63c47e95db4d..58426605a48c 100644 --- a/apps/services/bff/src/app/modules/cache/cache.module.ts +++ b/apps/services/bff/src/app/modules/cache/cache.module.ts @@ -1,29 +1,43 @@ -import { DynamicModule } from '@nestjs/common' -import { CacheModule as NestCacheModule } from '@nestjs/cache-manager' +import { DynamicModule, Module, Global } from '@nestjs/common' +import { + CacheModule as NestCacheModule, + CacheModuleOptions, +} from '@nestjs/cache-manager' import { redisInsStore } from 'cache-manager-ioredis-yet' import { createRedisCluster } from '@island.is/cache' import { ConfigType } from '@nestjs/config' import { BffConfig } from '../../bff.config' +import { CacheService } from './cache.service' -let CacheModule: DynamicModule +@Global() +@Module({}) +export class CacheModule { + static register(): DynamicModule { + const imports = + process.env.NODE_ENV === 'test' || process.env.INIT_SCHEMA === 'true' + ? [NestCacheModule.register()] + : [ + NestCacheModule.registerAsync({ + useFactory: ({ + redis: { ssl, nodes }, + }: ConfigType) => ({ + store: redisInsStore( + createRedisCluster({ + name: 'bff', + ssl, + nodes, + }), + ), + }), + inject: [BffConfig.KEY], + }), + ] -export const CACHE_MODULE_KEY = 'BFFModuleCache' - -if (process.env.NODE_ENV === 'test' || process.env.INIT_SCHEMA === 'true') { - CacheModule = NestCacheModule.register() -} else { - CacheModule = NestCacheModule.registerAsync({ - useFactory: ({ redis: { ssl, nodes } }: ConfigType) => ({ - store: redisInsStore( - createRedisCluster({ - name: 'bff', - ssl, - nodes, - }), - ), - }), - inject: [BffConfig.KEY], - }) + return { + module: CacheModule, + imports, + providers: [CacheService], + exports: [CacheService], + } + } } - -export { CacheModule } diff --git a/apps/services/bff/src/app/modules/proxy/proxy.controller.ts b/apps/services/bff/src/app/modules/proxy/proxy.controller.ts index f04647ff03a5..dbb2b6412795 100644 --- a/apps/services/bff/src/app/modules/proxy/proxy.controller.ts +++ b/apps/services/bff/src/app/modules/proxy/proxy.controller.ts @@ -10,7 +10,7 @@ export class ProxyController { constructor(private proxyService: ProxyService) {} @Post() - async login(@Req() req: Request, @Res() res: Response): Promise { - return this.proxyService.proxyRequest(req) + async proxyRequest(@Req() req: Request, @Res() res: Response): Promise { + return this.proxyService.proxyRequest({ req, res }) } } diff --git a/apps/services/bff/src/app/modules/proxy/proxy.module.ts b/apps/services/bff/src/app/modules/proxy/proxy.module.ts index cf6e7df79f0e..6ce8646966b1 100644 --- a/apps/services/bff/src/app/modules/proxy/proxy.module.ts +++ b/apps/services/bff/src/app/modules/proxy/proxy.module.ts @@ -1,13 +1,12 @@ import { Module } from '@nestjs/common' -import { CacheModule } from '../cache/cache.module' -import { CacheService } from '../cache/cache.service' +import { AuthModule } from '../auth/auth.module' +import { IdsService } from '../ids/ids.service' import { ProxyController } from './proxy.controller' import { ProxyService } from './proxy.service' -import { IdsService } from '../ids/ids.service' @Module({ - imports: [CacheModule], + imports: [AuthModule], controllers: [ProxyController], - providers: [ProxyService, CacheService, IdsService], + providers: [ProxyService, IdsService], }) export class ProxyModule {} diff --git a/apps/services/bff/src/app/modules/proxy/proxy.service.ts b/apps/services/bff/src/app/modules/proxy/proxy.service.ts index 6c4b1b80949e..911ffaae67ae 100644 --- a/apps/services/bff/src/app/modules/proxy/proxy.service.ts +++ b/apps/services/bff/src/app/modules/proxy/proxy.service.ts @@ -1,25 +1,49 @@ import type { Logger } from '@island.is/logging' import { LOGGER_PROVIDER } from '@island.is/logging' import { Inject, Injectable, UnauthorizedException } from '@nestjs/common' -import { Request } from 'express' - +import { ConfigType } from '@nestjs/config' +import { Request, Response } from 'express' +import fetch from 'node-fetch' +import { BffConfig } from '../../bff.config' +import { isExpired } from '../../utils/isExpired' +import { AuthService } from '../auth/auth.service' import { CachedTokenResponse } from '../auth/auth.types' import { CacheService } from '../cache/cache.service' -import { Observable } from 'rxjs' import { IdsService } from '../ids/ids.service' +const defaultHeaders = { + 'Content-Type': 'application/json', +} + @Injectable() export class ProxyService { constructor( @Inject(LOGGER_PROVIDER) private logger: Logger, + @Inject(BffConfig.KEY) + private readonly config: ConfigType, + private readonly cacheService: CacheService, private readonly idsService: IdsService, + private readonly authService: AuthService, ) {} - public async proxyRequest(req: Request): Promise { - //Promise> { + /** + * Proxies an incoming HTTP request to a target GraphQL API, handling authentication, token refresh, + * and response streaming. This method checks for a valid session ID (sid) from cookies, retrieves + * the cached authentication token, and refreshes it if expired. It then forwards the request + * to the target GraphQL API using the appropriate headers, including the refreshed token. + * The response from the target API is streamed back to the client, preserving headers + * and managing any errors that occur during the streaming process. + */ + public async proxyRequest({ + req, + res, + }: { + req: Request + res: Response + }): Promise { const sid = req.cookies['sid'] if (!sid) { @@ -27,39 +51,62 @@ export class ProxyService { } try { - // TODO this is a work in progress - // A proxy for the [island.is](http://island.is) GraphQL API, which: - - // 1. Reads session information from redis using session cookie. - // 2. Performs token refresh using the refresh token if the access token is expired. - // 3. Forwards request parameters to the GraphQL API endpoint, including the user’s access token. - // 4. Returns 401 if token refresh fails or no session information is found. - - const cachedTokenResponse = + let cachedTokenResponse = await this.cacheService.get( this.cacheService.createSessionKeyType('current', sid), ) - // Check if the access token is expired - - if (cachedTokenResponse.expires_in) { - // Refresh the token - const newTokenResponse = await this.idsService.refreshToken( + if (isExpired(cachedTokenResponse.accessTokenExp)) { + const tokenResponse = await this.idsService.refreshToken( cachedTokenResponse.refresh_token, ) - - console.log('newTokenResponse', newTokenResponse) + cachedTokenResponse = await this.authService.updateTokenCache( + tokenResponse, + ) } - if (!cachedTokenResponse.userProfile) { - throw new Error('userProfile not found in cache') + const targetUrl = `${this.config.graphqlApiEndpont}?${ + req.url.split('?')[1] + }` + + const response = await fetch(targetUrl, { + method: 'POST', + headers: { + ...defaultHeaders, + Authorization: `Bearer ${cachedTokenResponse.access_token}`, + }, + body: JSON.stringify(req.body), + }) + + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`) } - //return cachedTokenResponse.userProfile + // Set headers from the target response to the client response + res.status(response.status) + + Object.entries(defaultHeaders).forEach(([key, value]) => { + res.setHeader(key, value) + }) + + // Pipe the response body directly to the client + response.body.pipe(res) + + response.body.on('error', (err) => { + this.logger.error('Proxy stream error:', err) + + res.status(err.status || 500).send('Failed to proxy request') + }) + + // Make sure to end the response when the stream ends + // so that the client knows the request is complete + response.body.on('end', () => { + res.end() + }) } catch (error) { - this.logger.error('Error getting user from cache: ', error) + this.logger.error('Error during proxy request processing: ', error) - throw new UnauthorizedException() + res.status(error.status || 500).send('Failed to proxy request') } } } diff --git a/apps/services/bff/src/app/modules/user/user.module.ts b/apps/services/bff/src/app/modules/user/user.module.ts index dd4b85727e24..912d6f58c6d7 100644 --- a/apps/services/bff/src/app/modules/user/user.module.ts +++ b/apps/services/bff/src/app/modules/user/user.module.ts @@ -1,12 +1,12 @@ import { Module } from '@nestjs/common' +import { AuthModule } from '../auth/auth.module' +import { IdsService } from '../ids/ids.service' import { UserController } from './user.controller' -import { CacheService } from '../cache/cache.service' -import { CacheModule } from '../cache/cache.module' import { UserService } from './user.service' @Module({ - imports: [CacheModule], + imports: [AuthModule], controllers: [UserController], - providers: [CacheService, UserService], + providers: [UserService, IdsService], }) export class UserModule {} diff --git a/apps/services/bff/src/app/modules/user/user.service.ts b/apps/services/bff/src/app/modules/user/user.service.ts index dfa19589e3d3..c50b988bb374 100644 --- a/apps/services/bff/src/app/modules/user/user.service.ts +++ b/apps/services/bff/src/app/modules/user/user.service.ts @@ -3,9 +3,12 @@ import { LOGGER_PROVIDER } from '@island.is/logging' import { Inject, Injectable, UnauthorizedException } from '@nestjs/common' import { Request } from 'express' +import { BffUser } from '@island.is/shared/types' +import { isExpired } from '../../utils/isExpired' +import { AuthService } from '../auth/auth.service' import { CachedTokenResponse } from '../auth/auth.types' import { CacheService } from '../cache/cache.service' -import { BffUser } from '@island.is/shared/types' +import { IdsService } from '../ids/ids.service' @Injectable() export class UserService { @@ -14,8 +17,20 @@ export class UserService { private logger: Logger, private readonly cacheService: CacheService, + private readonly idsService: IdsService, + private readonly authService: AuthService, ) {} + private formatUserResponse(value: CachedTokenResponse): BffUser { + return { + scopes: value.scopes, + profile: { + ...value.userProfile, + dateOfBirth: new Date(value.userProfile.birthdate), + }, + } + } + public async getUser(req: Request): Promise { const sid = req.cookies['sid'] @@ -33,13 +48,21 @@ export class UserService { throw new Error('userProfile not found in cache') } - return { - scopes: cachedTokenResponse.scopes, - profile: { - ...cachedTokenResponse.userProfile, - dateOfBirth: new Date(cachedTokenResponse.userProfile.birthdate), - }, + // Check if the access token is expired + if (isExpired(cachedTokenResponse.accessTokenExp)) { + // Get new token data with refresh token + const tokenResponse = await this.idsService.refreshToken( + cachedTokenResponse.refresh_token, + ) + + // Update cache with new token data + const value: CachedTokenResponse = + await this.authService.updateTokenCache(tokenResponse) + + return this.formatUserResponse(value) } + + return this.formatUserResponse(cachedTokenResponse) } catch (error) { this.logger.error('Error getting user from cache: ', error) diff --git a/apps/services/bff/src/app/utils/isExpired.ts b/apps/services/bff/src/app/utils/isExpired.ts new file mode 100644 index 000000000000..42605f482907 --- /dev/null +++ b/apps/services/bff/src/app/utils/isExpired.ts @@ -0,0 +1,6 @@ +/** + * Check if unix timestamp is expired + */ +export const isExpired = (unixTimestamp: number) => { + return unixTimestamp < Date.now() / 1000 +} diff --git a/apps/services/bff/src/environment/environment.schema.ts b/apps/services/bff/src/environment/environment.schema.ts index 1f154fb4fbea..84483dadce25 100644 --- a/apps/services/bff/src/environment/environment.schema.ts +++ b/apps/services/bff/src/environment/environment.schema.ts @@ -1,3 +1,4 @@ +import { graphql } from 'graphql' import { z } from 'zod' export const authSchema = z.strictObject({ @@ -21,6 +22,10 @@ export const environmentSchema = z.strictObject({ * The client base path */ clientBasePath: z.string(), + /** + * Our main GraphQL API endpoint + */ + graphqlApiEndpont: z.string(), /** * The global prefix for the API */ diff --git a/apps/services/bff/src/environment/environment.ts b/apps/services/bff/src/environment/environment.ts index 8cec77918b4a..eaa65b3b9ee4 100644 --- a/apps/services/bff/src/environment/environment.ts +++ b/apps/services/bff/src/environment/environment.ts @@ -9,10 +9,12 @@ const callbacksBaseRedirectPath = requiredString('BFF_CALLBACKS_BASE_PATH') .replace(/\/$/, '') const issuer = requiredString('IDENTITY_SERVER_ISSUER_URL') +const logoutRedirectUri = requiredString('BFF_LOGOUT_REDIRECT_PATH') export const environment: BffEnvironmentSchema = { production: isProduction, clientBasePath: requiredString('BFF_CLIENT_BASE_PATH'), + graphqlApiEndpont: requiredString('BFF_PROXY_API_ENDPOINT'), globalPrefix: requiredString('BFF_API_URL_PREFIX'), audit: { groupName: requiredString('AUDIT_GROUP_NAME'), @@ -40,6 +42,6 @@ export const environment: BffEnvironmentSchema = { login: `${callbacksBaseRedirectPath}/login`, logout: `${callbacksBaseRedirectPath}/logout`, }, - logoutRedirectUri: requiredString('BFF_LOGOUT_REDIRECT_PATH'), + logoutRedirectUri, }, } From 95abdaa95006de95007dd4b8bc8300e2b2946ae5 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Fri, 6 Sep 2024 14:36:26 +0000 Subject: [PATCH 029/248] Add scope to token response for backwards compatibility --- apps/services/bff/src/app/modules/auth/auth.service.ts | 3 +-- apps/services/bff/src/app/modules/auth/auth.types.ts | 2 +- apps/services/bff/src/app/modules/user/user.service.ts | 1 + libs/react-spa/bff/src/lib/BffProvider.tsx | 4 ---- libs/shared/types/src/lib/bff.ts | 2 ++ 5 files changed, 5 insertions(+), 7 deletions(-) diff --git a/apps/services/bff/src/app/modules/auth/auth.service.ts b/apps/services/bff/src/app/modules/auth/auth.service.ts index e0b9c39b38c9..5e4d07244a3d 100644 --- a/apps/services/bff/src/app/modules/auth/auth.service.ts +++ b/apps/services/bff/src/app/modules/auth/auth.service.ts @@ -6,7 +6,6 @@ import { Request, Response } from 'express' import { jwtDecode } from 'jwt-decode' import { IdTokenClaims } from '@island.is/shared/types' -import omit from 'lodash/omit' import { uuid } from 'uuidv4' import { environment } from '../../../environment' import { BffConfig } from '../../bff.config' @@ -69,7 +68,7 @@ export class AuthService { const decodedAccessToken = jwtDecode(tokenResponse.access_token) const value: CachedTokenResponse = { - ...omit(tokenResponse, ['scope']), + ...tokenResponse, scopes: tokenResponse.scope.split(' '), userProfile, accessTokenExp: diff --git a/apps/services/bff/src/app/modules/auth/auth.types.ts b/apps/services/bff/src/app/modules/auth/auth.types.ts index 6e8c40896c01..544c713458c5 100644 --- a/apps/services/bff/src/app/modules/auth/auth.types.ts +++ b/apps/services/bff/src/app/modules/auth/auth.types.ts @@ -2,7 +2,7 @@ import { IdTokenClaims } from '@island.is/shared/types' import { TokenResponse } from '../ids/ids.types' -export type CachedTokenResponse = Omit & { +export type CachedTokenResponse = TokenResponse & { scopes: string[] /** diff --git a/apps/services/bff/src/app/modules/user/user.service.ts b/apps/services/bff/src/app/modules/user/user.service.ts index c50b988bb374..a360fb3190e0 100644 --- a/apps/services/bff/src/app/modules/user/user.service.ts +++ b/apps/services/bff/src/app/modules/user/user.service.ts @@ -23,6 +23,7 @@ export class UserService { private formatUserResponse(value: CachedTokenResponse): BffUser { return { + scope: value.scope, scopes: value.scopes, profile: { ...value.userProfile, diff --git a/libs/react-spa/bff/src/lib/BffProvider.tsx b/libs/react-spa/bff/src/lib/BffProvider.tsx index e7a0d3132a3a..bdabf9b624eb 100644 --- a/libs/react-spa/bff/src/lib/BffProvider.tsx +++ b/libs/react-spa/bff/src/lib/BffProvider.tsx @@ -33,10 +33,6 @@ export const BffProvider = ({ }) if (!res.ok) { - dispatch({ - type: ActionType.SIGNIN_FAILURE, - }) - window.location.href = bffUrlGenerator('/login') return diff --git a/libs/shared/types/src/lib/bff.ts b/libs/shared/types/src/lib/bff.ts index 6bc8d8308b06..62e8d0ce9d8b 100644 --- a/libs/shared/types/src/lib/bff.ts +++ b/libs/shared/types/src/lib/bff.ts @@ -65,6 +65,8 @@ export interface IdTokenClaims { } export type BffUser = { + // User scope property here for backwards compatibility + scope: string scopes: string[] profile: IdTokenClaims & { dateOfBirth: Date From 90900183a791a04c231424749c6f4aa07c2eec86 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Tue, 10 Sep 2024 10:12:10 +0000 Subject: [PATCH 030/248] Encrypted tokens, hooks update for admin portal, switch user, proxy updated --- apps/services/bff/infra/admin-portal.infra.ts | 10 +- apps/services/bff/src/app/bff.config.ts | 2 + .../bff/src/app/modules/auth/auth.module.ts | 3 +- .../bff/src/app/modules/auth/auth.service.ts | 26 +++-- .../app/modules/auth/queries/login.query.ts | 4 + .../bff/src/app/modules/cache/cache.module.ts | 9 +- .../bff/src/app/modules/ids/ids.service.ts | 14 ++- .../bff/src/app/modules/proxy/proxy.module.ts | 3 +- .../src/app/modules/proxy/proxy.service.ts | 85 +++++++++----- .../bff/src/app/modules/user/user.module.ts | 3 +- .../src/app/services/crypto.service.spec.ts | 105 ++++++++++++++++++ .../bff/src/app/services/crypto.service.ts | 73 ++++++++++++ .../screens/Overview/InstitutionOverview.tsx | 2 +- .../ids-admin/src/hooks/useSuperAdmin.tsx | 4 +- .../src/components/DownloadDraftButton.tsx | 4 +- .../regulations-admin/src/state/reducer.ts | 19 ++-- .../service-desk/src/screens/Users/Users.tsx | 4 +- .../core/src/components/PortalProvider.tsx | 14 +-- .../core/src/components/PortalRouter.tsx | 17 ++- libs/portals/core/src/hooks/useModuleProps.ts | 4 +- libs/portals/core/src/hooks/useNavigation.ts | 4 +- .../portals/core/src/screens/AccessDenied.tsx | 4 +- libs/portals/core/src/screens/ModuleRoute.tsx | 4 +- .../components/access/AccessConfirmModal.tsx | 4 +- .../AccessDeleteModal/AccessDeleteModal.tsx | 4 +- .../DelegationIncomingModal.tsx | 4 +- .../delegations/src/screens/AccessControl.tsx | 11 +- .../src/screens/GrantAccess/GrantAccess.tsx | 24 ++-- libs/react-spa/bff/src/lib/BffProvider.tsx | 40 ++++++- libs/react-spa/bff/src/lib/bff.hooks.ts | 33 ++++-- libs/react-spa/bff/src/lib/bff.utils.ts | 4 + libs/shared/types/src/lib/bff.ts | 59 +--------- 32 files changed, 418 insertions(+), 182 deletions(-) create mode 100644 apps/services/bff/src/app/services/crypto.service.spec.ts create mode 100644 apps/services/bff/src/app/services/crypto.service.ts diff --git a/apps/services/bff/infra/admin-portal.infra.ts b/apps/services/bff/infra/admin-portal.infra.ts index f8594051ca54..d4358fbced80 100644 --- a/apps/services/bff/infra/admin-portal.infra.ts +++ b/apps/services/bff/infra/admin-portal.infra.ts @@ -18,15 +18,13 @@ export const serviceSetup = (): ServiceBuilder<'services-bff-admin-portal'> => .image('services-bff') .redis() .env({ + // Idenity server IDENTITY_SERVER_CLIENT_ID: '@admin.island.is/bff', IDENTITY_SERVER_ISSUER_URL: { dev: 'https://identity-server.dev01.devland.is', staging: 'https://identity-server.staging01.devland.is', prod: 'https://innskra.island.is', }, - BFF_CALLBACKS_BASE_PATH: generateWebBaseUrls('/stjornbord/bff/callbacks'), - BFF_LOGOUT_REDIRECT_PATH: generateWebBaseUrls(), - BFF_PROXY_API_ENDPOINT: generateWebBaseUrls('/api/graphql'), IDENTITY_SERVER_CLIENT_SCOPES: json([ '@admin.island.is/delegation-system', '@admin.island.is/delegation-system:admin', @@ -49,7 +47,13 @@ export const serviceSetup = (): ServiceBuilder<'services-bff-admin-portal'> => '@admin.island.is/form-system', '@admin.island.is/form-system:admin', ]), + + // BFF + BFF_CALLBACKS_BASE_PATH: generateWebBaseUrls('/stjornbord/bff/callbacks'), + BFF_LOGOUT_REDIRECT_PATH: generateWebBaseUrls(), + BFF_PROXY_API_ENDPOINT: generateWebBaseUrls('/api/graphql'), BFF_API_URL_PREFIX: 'stjornbord/bff', + BFF_TOKEN_SECRET: '/k8s/services-bff/BFF_TOKEN_SECRET', }) .secrets({ BFF_IDENTITY_SERVER_SECRET: diff --git a/apps/services/bff/src/app/bff.config.ts b/apps/services/bff/src/app/bff.config.ts index fc1c6d1a0d27..d9ab07fc8ba3 100644 --- a/apps/services/bff/src/app/bff.config.ts +++ b/apps/services/bff/src/app/bff.config.ts @@ -11,6 +11,7 @@ const BffConfigSchema = z.object({ }), graphqlApiEndpont: z.string(), auth: authSchema, + tokenSecretBase64: z.string(), }) export const BffConfig = defineConfig({ @@ -31,6 +32,7 @@ export const BffConfig = defineConfig({ ssl: isProduction, }, auth: environment.auth, + tokenSecretBase64: env.required('BFF_TOKEN_SECRET_BASE64'), } }, }) diff --git a/apps/services/bff/src/app/modules/auth/auth.module.ts b/apps/services/bff/src/app/modules/auth/auth.module.ts index 82cf55006112..90dff2eb72da 100644 --- a/apps/services/bff/src/app/modules/auth/auth.module.ts +++ b/apps/services/bff/src/app/modules/auth/auth.module.ts @@ -3,10 +3,11 @@ import { IdsService } from '../ids/ids.service' import { AuthController } from './auth.controller' import { AuthService } from './auth.service' import { PKCEService } from './pkce.service' +import { CryptoService } from '../../services/crypto.service' @Module({ controllers: [AuthController], - providers: [AuthService, PKCEService, IdsService], + providers: [AuthService, PKCEService, IdsService, CryptoService], exports: [AuthService], }) export class AuthModule {} diff --git a/apps/services/bff/src/app/modules/auth/auth.service.ts b/apps/services/bff/src/app/modules/auth/auth.service.ts index 5e4d07244a3d..863e38aa8d8d 100644 --- a/apps/services/bff/src/app/modules/auth/auth.service.ts +++ b/apps/services/bff/src/app/modules/auth/auth.service.ts @@ -18,6 +18,8 @@ import { CallbackLoginQuery } from './queries/callback-login.query' import { CallbackLogoutQuery } from './queries/callback-logout.query' import { LoginQuery } from './queries/login.query' import { LogoutQuery } from './queries/logout.query' +import { CryptoService } from '../../services/crypto.service' +import omit from 'lodash/omit' @Injectable() export class AuthService { @@ -33,6 +35,7 @@ export class AuthService { private readonly pkceService: PKCEService, private readonly cacheService: CacheService, private readonly idsService: IdsService, + private readonly cryptoService: CryptoService, ) { this.baseUrl = this.config.auth.issuer } @@ -65,17 +68,18 @@ export class AuthService { tokenResponse: TokenResponse, ): Promise { const userProfile: IdTokenClaims = jwtDecode(tokenResponse.id_token) - const decodedAccessToken = jwtDecode(tokenResponse.access_token) const value: CachedTokenResponse = { - ...tokenResponse, + ...omit(tokenResponse, ['access_token', 'refresh_token']), + // Encrypt the access and refresh tokens before saving them to the cache + // to prevent unauthorized access to the tokens if cached service is compromised. + access_token: this.cryptoService.encrypt(tokenResponse.access_token), + refresh_token: this.cryptoService.encrypt(tokenResponse.refresh_token), scopes: tokenResponse.scope.split(' '), userProfile, - accessTokenExp: - // Prefer the exact expiration time from the access token - decodedAccessToken.exp || - // Fallback to token response expiration time in seconds - new Date(Date.now() + tokenResponse.expires_in * 1000).getTime(), + accessTokenExp: new Date( + Date.now() + tokenResponse.expires_in * 1000, + ).getTime(), } // Save the tokenResponse to the cache @@ -107,7 +111,7 @@ export class AuthService { async login({ req, res, - query: { target_link_uri: targetLinkUri, login_hint: loginHint }, + query: { target_link_uri: targetLinkUri, login_hint: loginHint, prompt }, }: { req: Request res: Response @@ -143,10 +147,11 @@ export class AuthService { value: { // Fallback if targetLinkUri is not provided originUrl, - targetLinkUri: targetLinkUri, - ...(loginHint && { loginHint }), // Code verifier to be used in the callback codeVerifier, + targetLinkUri: targetLinkUri, + ...(loginHint && { loginHint }), + ...(prompt && { prompt }), }, ttl: 60 * 60 * 24 * 7 * 1000, // 1 week }) @@ -155,6 +160,7 @@ export class AuthService { sid, codeChallenge, loginHint, + prompt, }) const searchParams = new URLSearchParams({ diff --git a/apps/services/bff/src/app/modules/auth/queries/login.query.ts b/apps/services/bff/src/app/modules/auth/queries/login.query.ts index 7bdbc60c9742..6e80be378ee6 100644 --- a/apps/services/bff/src/app/modules/auth/queries/login.query.ts +++ b/apps/services/bff/src/app/modules/auth/queries/login.query.ts @@ -8,4 +8,8 @@ export class LoginQuery { @IsOptional() @IsString() login_hint?: string + + @IsOptional() + @IsString() + prompt?: string } diff --git a/apps/services/bff/src/app/modules/cache/cache.module.ts b/apps/services/bff/src/app/modules/cache/cache.module.ts index 58426605a48c..ccd96e595a42 100644 --- a/apps/services/bff/src/app/modules/cache/cache.module.ts +++ b/apps/services/bff/src/app/modules/cache/cache.module.ts @@ -1,11 +1,8 @@ -import { DynamicModule, Module, Global } from '@nestjs/common' -import { - CacheModule as NestCacheModule, - CacheModuleOptions, -} from '@nestjs/cache-manager' -import { redisInsStore } from 'cache-manager-ioredis-yet' import { createRedisCluster } from '@island.is/cache' +import { CacheModule as NestCacheModule } from '@nestjs/cache-manager' +import { DynamicModule, Global, Module } from '@nestjs/common' import { ConfigType } from '@nestjs/config' +import { redisInsStore } from 'cache-manager-ioredis-yet' import { BffConfig } from '../../bff.config' import { CacheService } from './cache.service' diff --git a/apps/services/bff/src/app/modules/ids/ids.service.ts b/apps/services/bff/src/app/modules/ids/ids.service.ts index 3399ec2ff182..6eebf7c692b9 100644 --- a/apps/services/bff/src/app/modules/ids/ids.service.ts +++ b/apps/services/bff/src/app/modules/ids/ids.service.ts @@ -5,6 +5,7 @@ import { ConfigType } from '@nestjs/config' import { BffConfig } from '../../bff.config' import { ParResponse, TokenResponse } from './ids.types' +import { CryptoService } from '../../services/crypto.service' @Injectable() export class IdsService { @@ -16,6 +17,8 @@ export class IdsService { @Inject(BffConfig.KEY) private readonly config: ConfigType, + + private readonly cryptoService: CryptoService, ) { this.baseUrl = this.config.auth.issuer } @@ -58,10 +61,12 @@ export class IdsService { sid, codeChallenge, loginHint, + prompt, }: { sid: string codeChallenge: string loginHint?: string + prompt?: string }) { return this.postRequest('/connect/par', { client_id: this.config.auth.clientId, @@ -80,6 +85,7 @@ export class IdsService { code_challenge: codeChallenge, code_challenge_method: 'S256', ...(loginHint && { login_hint: loginHint }), + ...(prompt && { prompt }), }) } @@ -101,11 +107,15 @@ export class IdsService { }) } - // Uses the refresh token to get a new access token + /** + * Use the refresh token to get a new tokens + */ public async refreshToken(refreshToken: string) { + const decryptedRefreshToken = this.cryptoService.decrypt(refreshToken) + return this.postRequest('/connect/token', { grant_type: 'refresh_token', - refresh_token: refreshToken, + refresh_token: decryptedRefreshToken, client_secret: this.config.auth.secret, client_id: this.config.auth.clientId, }) diff --git a/apps/services/bff/src/app/modules/proxy/proxy.module.ts b/apps/services/bff/src/app/modules/proxy/proxy.module.ts index 6ce8646966b1..d767e6c014d6 100644 --- a/apps/services/bff/src/app/modules/proxy/proxy.module.ts +++ b/apps/services/bff/src/app/modules/proxy/proxy.module.ts @@ -3,10 +3,11 @@ import { AuthModule } from '../auth/auth.module' import { IdsService } from '../ids/ids.service' import { ProxyController } from './proxy.controller' import { ProxyService } from './proxy.service' +import { CryptoService } from '../../services/crypto.service' @Module({ imports: [AuthModule], controllers: [ProxyController], - providers: [ProxyService, IdsService], + providers: [ProxyService, IdsService, CryptoService], }) export class ProxyModule {} diff --git a/apps/services/bff/src/app/modules/proxy/proxy.service.ts b/apps/services/bff/src/app/modules/proxy/proxy.service.ts index 911ffaae67ae..7ded5fb3d790 100644 --- a/apps/services/bff/src/app/modules/proxy/proxy.service.ts +++ b/apps/services/bff/src/app/modules/proxy/proxy.service.ts @@ -5,15 +5,14 @@ import { ConfigType } from '@nestjs/config' import { Request, Response } from 'express' import fetch from 'node-fetch' import { BffConfig } from '../../bff.config' +import { CryptoService } from '../../services/crypto.service' import { isExpired } from '../../utils/isExpired' import { AuthService } from '../auth/auth.service' import { CachedTokenResponse } from '../auth/auth.types' import { CacheService } from '../cache/cache.service' import { IdsService } from '../ids/ids.service' -const defaultHeaders = { - 'Content-Type': 'application/json', -} +const whiteListedHeaders = ['access-control-allow-origin'] @Injectable() export class ProxyService { @@ -27,8 +26,42 @@ export class ProxyService { private readonly cacheService: CacheService, private readonly idsService: IdsService, private readonly authService: AuthService, + private readonly cryptoService: CryptoService, ) {} + /** + * Prepares necessary data for proxying a request to the target GraphQL API. + * - Gets the cached token response from the cache service + * - Refreshes the token if it is expired + * - Decrypts the access token + */ + private async prepareProxyRequest(sid: string, req: Request) { + let cachedTokenResponse = await this.cacheService.get( + this.cacheService.createSessionKeyType('current', sid), + ) + + if (isExpired(cachedTokenResponse.accessTokenExp)) { + const tokenResponse = await this.idsService.refreshToken( + cachedTokenResponse.refresh_token, + ) + cachedTokenResponse = await this.authService.updateTokenCache( + tokenResponse, + ) + } + + const targetUrl = `${this.config.graphqlApiEndpont}?${ + req.url.split('?')[1] + }` + const decryptedAccessToken = this.cryptoService.decrypt( + cachedTokenResponse.access_token, + ) + + return { + targetUrl, + decryptedAccessToken, + } + } + /** * Proxies an incoming HTTP request to a target GraphQL API, handling authentication, token refresh, * and response streaming. This method checks for a valid session ID (sid) from cookies, retrieves @@ -51,29 +84,14 @@ export class ProxyService { } try { - let cachedTokenResponse = - await this.cacheService.get( - this.cacheService.createSessionKeyType('current', sid), - ) - - if (isExpired(cachedTokenResponse.accessTokenExp)) { - const tokenResponse = await this.idsService.refreshToken( - cachedTokenResponse.refresh_token, - ) - cachedTokenResponse = await this.authService.updateTokenCache( - tokenResponse, - ) - } - - const targetUrl = `${this.config.graphqlApiEndpont}?${ - req.url.split('?')[1] - }` + const { targetUrl, decryptedAccessToken } = + await this.prepareProxyRequest(sid, req) const response = await fetch(targetUrl, { method: 'POST', headers: { - ...defaultHeaders, - Authorization: `Bearer ${cachedTokenResponse.access_token}`, + 'Content-Type': 'application/json', + Authorization: `Bearer ${decryptedAccessToken}`, }, body: JSON.stringify(req.body), }) @@ -82,11 +100,14 @@ export class ProxyService { throw new Error(`HTTP error! Status: ${response.status}`) } - // Set headers from the target response to the client response + // Set the status code of the response res.status(response.status) - Object.entries(defaultHeaders).forEach(([key, value]) => { - res.setHeader(key, value) + response.headers.forEach((value, key) => { + // Only set headers that are not in the whiteListedHeaders array + if (!whiteListedHeaders.includes(key.toLowerCase())) { + res.setHeader(key, value) + } }) // Pipe the response body directly to the client @@ -95,13 +116,19 @@ export class ProxyService { response.body.on('error', (err) => { this.logger.error('Proxy stream error:', err) - res.status(err.status || 500).send('Failed to proxy request') + // This check ensures that `res.end()` is only called if the response has not already been ended. + if (!res.writableEnded) { + // Ensure the response is properly ended if an error occurs + res.end('An error occurred while streaming data.') + } }) - // Make sure to end the response when the stream ends - // so that the client knows the request is complete + // Make sure to end the response when the stream ends, + // so that the client knows the request is complete. response.body.on('end', () => { - res.end() + if (!res.writableEnded) { + res.end() + } }) } catch (error) { this.logger.error('Error during proxy request processing: ', error) diff --git a/apps/services/bff/src/app/modules/user/user.module.ts b/apps/services/bff/src/app/modules/user/user.module.ts index 912d6f58c6d7..6ad5d5368d65 100644 --- a/apps/services/bff/src/app/modules/user/user.module.ts +++ b/apps/services/bff/src/app/modules/user/user.module.ts @@ -3,10 +3,11 @@ import { AuthModule } from '../auth/auth.module' import { IdsService } from '../ids/ids.service' import { UserController } from './user.controller' import { UserService } from './user.service' +import { CryptoService } from '../../services/crypto.service' @Module({ imports: [AuthModule], controllers: [UserController], - providers: [UserService, IdsService], + providers: [UserService, IdsService, CryptoService], }) export class UserModule {} diff --git a/apps/services/bff/src/app/services/crypto.service.spec.ts b/apps/services/bff/src/app/services/crypto.service.spec.ts new file mode 100644 index 000000000000..fbc97a26afaa --- /dev/null +++ b/apps/services/bff/src/app/services/crypto.service.spec.ts @@ -0,0 +1,105 @@ +import type { Logger } from '@island.is/logging' +import { LOGGER_PROVIDER } from '@island.is/logging' +import { Test, TestingModule } from '@nestjs/testing' +import { BffConfig } from '../bff.config' +import { CryptoService } from './crypto.service' + +const DECRYPTED_TEXT = 'Hello, World!' +const ENCRYPTED_TEXT = 'bW9ja2VkZGl' + +// Mock the crypto module +jest.mock('crypto', () => { + const actualCrypto = jest.requireActual('crypto') + + return { + ...actualCrypto, + createCipheriv: jest.fn(() => ({ + update: jest.fn().mockReturnValue(ENCRYPTED_TEXT), + final: jest.fn().mockReturnValue(''), + })), + createDecipheriv: jest.fn(() => ({ + update: jest.fn().mockReturnValue(DECRYPTED_TEXT), + final: jest.fn().mockReturnValue(''), + })), + } +}) + +const mockLogger = { + error: jest.fn(), +} as unknown as Logger + +const invalidConfig = { + tokenSecretBase64: 'shortkey', +} + +const validConfig = { + // A valid 32-byte base64 key + tokenSecretBase64: 'ABHlmq6Ic6Ihip4OnTa1MeUXtHFex8IT/mFZrjhsme0=', +} + +const createModule = async ( + config: Record, +): Promise => { + return Test.createTestingModule({ + providers: [ + CryptoService, + { provide: LOGGER_PROVIDER, useValue: mockLogger }, + { provide: BffConfig.KEY, useValue: config }, + ], + }).compile() +} + +describe('CryptoService Constructor', () => { + it('should throw an error if "tokenSecretBase64" is not 32 bytes long', async () => { + try { + const module = await createModule(invalidConfig) + module.get(CryptoService) + // Fail the test if no error is thrown + fail('Expected constructor to throw an error, but it did not.') + } catch (error) { + expect(error.message).toBe( + '"tokenSecretBase64" secret must be exactly 32 bytes (256 bits) long.', + ) + } + }) + + it('should not throw an error if "tokenSecretBase64" is 32 bytes long', async () => { + try { + const module = await createModule(validConfig) + module.get(CryptoService) + // No error means the test passes + } catch (error) { + fail(`Expected no error, but received: ${error.message}`) + } + }) +}) + +describe('CryptoService', () => { + let service: CryptoService + + beforeEach(async () => { + const module = await createModule(validConfig) + service = module.get(CryptoService) + }) + + describe('encrypt', () => { + it('should encrypt and return a string containing IV and encrypted text', () => { + const encryptedText = service.encrypt(DECRYPTED_TEXT) + const [ivBase64, encrypted] = encryptedText.split(':') + + // Verify the length of the IV and the encrypted part + // IV in base64 (16 bytes) should be 24 characters long + expect(ivBase64).toHaveLength(24) + expect(encrypted.length).toBeGreaterThan(0) + }) + }) + + describe('decrypt', () => { + it('should decrypt an encrypted string and return the original text', () => { + const encryptedText = service.encrypt(DECRYPTED_TEXT) + const decryptedText = service.decrypt(encryptedText) + + expect(decryptedText).toBe(DECRYPTED_TEXT) + }) + }) +}) diff --git a/apps/services/bff/src/app/services/crypto.service.ts b/apps/services/bff/src/app/services/crypto.service.ts new file mode 100644 index 000000000000..b1470f98b21d --- /dev/null +++ b/apps/services/bff/src/app/services/crypto.service.ts @@ -0,0 +1,73 @@ +import type { Logger } from '@island.is/logging' +import { LOGGER_PROVIDER } from '@island.is/logging' +import { + Inject, + Injectable, + InternalServerErrorException, +} from '@nestjs/common' +import { ConfigType } from '@nestjs/config' +import * as crypto from 'crypto' +import { BffConfig } from '../bff.config' + +@Injectable() +export class CryptoService { + private readonly algorithm = 'aes-256-cbc' + private readonly key: Buffer + + constructor( + @Inject(LOGGER_PROVIDER) + private logger: Logger, + + @Inject(BffConfig.KEY) + private readonly config: ConfigType, + ) { + // Decode from base64 to binary + this.key = Buffer.from(this.config.tokenSecretBase64, 'base64') + + // Ensure the key is exactly 32 bytes (256 bits) long + if (this.key.length !== 32) { + throw new Error( + '"tokenSecretBase64" secret must be exactly 32 bytes (256 bits) long.', + ) + } + } + + /** + * Encrypts a given text using the AES-256-CBC encryption algorithm. + * @returns IV encrypted text for decryption + */ + encrypt(text: string): string { + try { + const iv = crypto.randomBytes(16) + const cipher = crypto.createCipheriv(this.algorithm, this.key, iv) + let encrypted = cipher.update(text, 'utf8', 'base64') + encrypted += cipher.final('base64') + + return `${iv.toString('base64')}:${encrypted}` + } catch (error) { + this.logger.error('Error encrypting text:', error) + + throw new InternalServerErrorException() + } + } + + /** + * Decrypts a given text using the AES-256-CBC decryption algorithm. + * @returns The original plain text. + */ + decrypt(encryptedText: string): string { + try { + const [ivBase64, encrypted] = encryptedText.split(':') + const iv = Buffer.from(ivBase64, 'base64') + const decipher = crypto.createDecipheriv(this.algorithm, this.key, iv) + let decrypted = decipher.update(encrypted, 'base64', 'utf8') + decrypted += decipher.final('utf8') + + return decrypted + } catch (error) { + this.logger.error('Error decrypting text:', error) + + throw new InternalServerErrorException() + } + } +} diff --git a/libs/portals/admin/application-system/src/screens/Overview/InstitutionOverview.tsx b/libs/portals/admin/application-system/src/screens/Overview/InstitutionOverview.tsx index 4baa487414de..904561742ebd 100644 --- a/libs/portals/admin/application-system/src/screens/Overview/InstitutionOverview.tsx +++ b/libs/portals/admin/application-system/src/screens/Overview/InstitutionOverview.tsx @@ -19,7 +19,7 @@ import { ApplicationFilters, MultiChoiceFilter } from '../../types/filters' import { Organization } from '@island.is/shared/types' import { institutionMapper } from '@island.is/application/types' import { AdminApplication } from '../../types/adminApplication' -import { useUserInfo } from '@island.is/auth/react' +import { useUserInfo } from '@island.is/react-spa/bff' import endOfDay from 'date-fns/endOfDay' const defaultFilters: ApplicationFilters = { diff --git a/libs/portals/admin/ids-admin/src/hooks/useSuperAdmin.tsx b/libs/portals/admin/ids-admin/src/hooks/useSuperAdmin.tsx index 9dd93708336e..ef18544a3726 100644 --- a/libs/portals/admin/ids-admin/src/hooks/useSuperAdmin.tsx +++ b/libs/portals/admin/ids-admin/src/hooks/useSuperAdmin.tsx @@ -1,8 +1,8 @@ -import { useAuth } from '@island.is/auth/react' import { AdminPortalScope } from '@island.is/auth/scopes' +import { useUserInfo } from '@island.is/react-spa/bff' export const useSuperAdmin = () => { - const { userInfo } = useAuth() + const userInfo = useUserInfo() const isSuperAdmin = userInfo?.scopes.includes( AdminPortalScope.idsAdminSuperUser, diff --git a/libs/portals/admin/regulations-admin/src/components/DownloadDraftButton.tsx b/libs/portals/admin/regulations-admin/src/components/DownloadDraftButton.tsx index 50ddeee88fe1..7c8882fc9646 100644 --- a/libs/portals/admin/regulations-admin/src/components/DownloadDraftButton.tsx +++ b/libs/portals/admin/regulations-admin/src/components/DownloadDraftButton.tsx @@ -7,7 +7,7 @@ import { Button, toast } from '@island.is/island-ui/core' import { useLocale } from '@island.is/localization' import { editorMsgs, reviewMessages } from '../lib/messages' import type { RegulationDraftId } from '@island.is/regulations/admin' -import { useAuth } from '@island.is/auth/react' +import { useUserInfo } from '@island.is/react-spa/bff' type Props = { draftId: RegulationDraftId @@ -46,7 +46,7 @@ function formSubmit(url: string, token: string) { } export function DownloadDraftButton({ draftId, reviewButton }: Props) { - const userInfo = useAuth().userInfo + const userInfo = useUserInfo() const t = useLocale().formatMessage const [downloadRegulation, { called, loading, error, data }] = useLazyQuery(DownloadRegulationDraftQuery, { diff --git a/libs/portals/admin/regulations-admin/src/state/reducer.ts b/libs/portals/admin/regulations-admin/src/state/reducer.ts index 3771f45b70f7..1b03eb072486 100644 --- a/libs/portals/admin/regulations-admin/src/state/reducer.ts +++ b/libs/portals/admin/regulations-admin/src/state/reducer.ts @@ -1,14 +1,14 @@ +import { AdminPortalScope } from '@island.is/auth/scopes' +import { useUserInfo } from '@island.is/react-spa/bff' +import { LawChapter, MinistryList } from '@island.is/regulations' +import { DraftImpactId, RegulationDraft } from '@island.is/regulations/admin' +import { produce, setAutoFreeze } from 'immer' import { Reducer, useReducer } from 'react' import { RegulationDraftTypes, Step } from '../types' -import { LawChapter, MinistryList } from '@island.is/regulations' +import { actionHandlers } from './actionHandlers' +import { makeDraftForm, stepsAmending, stepsBase } from './makeFields' import { Action, DraftingState, RegDraftFormSimpleProps } from './types' -import { produce, setAutoFreeze } from 'immer' -import { DraftImpactId, RegulationDraft } from '@island.is/regulations/admin' -import { useAuth } from '@island.is/auth/react' -import { AdminPortalScope } from '@island.is/auth/scopes' import { derivedUpdates, validateState } from './validations' -import { makeDraftForm, stepsAmending, stepsBase } from './makeFields' -import { actionHandlers } from './actionHandlers' const draftingStateReducer: Reducer = ( state, @@ -40,9 +40,8 @@ export const useEditDraftReducer = (inputs: StateInputs) => { const { regulationDraft, ministries, lawChapters, stepName } = inputs const isEditor = - useAuth().userInfo?.scopes?.includes( - AdminPortalScope.regulationAdminManage, - ) || false + useUserInfo()?.scopes?.includes(AdminPortalScope.regulationAdminManage) || + false const makeInitialState = () => { const draft = makeDraftForm(regulationDraft) diff --git a/libs/portals/admin/service-desk/src/screens/Users/Users.tsx b/libs/portals/admin/service-desk/src/screens/Users/Users.tsx index 6e66784fba2a..d78b0fa1b1cc 100644 --- a/libs/portals/admin/service-desk/src/screens/Users/Users.tsx +++ b/libs/portals/admin/service-desk/src/screens/Users/Users.tsx @@ -10,7 +10,7 @@ import { import { formatNationalId, IntroHeader } from '@island.is/portals/core' import { maskString } from '@island.is/shared/utils' import { useLocale } from '@island.is/localization' -import { useAuth } from '@island.is/auth/react' +import { useUserInfo } from '@island.is/react-spa/bff' import { replaceParams, useSubmitting } from '@island.is/react-spa/shared' import * as styles from '../Companies/Companies.css' @@ -26,7 +26,7 @@ const Users = () => { const actionData = useActionData() as GetUserProfilesResult const { formatMessage } = useLocale() const navigate = useNavigate() - const { userInfo } = useAuth() + const userInfo = useUserInfo() const { isSubmitting, isLoading } = useSubmitting() const users = actionData?.data?.data const [error, setError] = useState({ hasError: false, message: '' }) diff --git a/libs/portals/core/src/components/PortalProvider.tsx b/libs/portals/core/src/components/PortalProvider.tsx index 5aff6abb53a8..6c2159127d72 100644 --- a/libs/portals/core/src/components/PortalProvider.tsx +++ b/libs/portals/core/src/components/PortalProvider.tsx @@ -1,13 +1,13 @@ -import { useLocale } from '@island.is/localization' -import { createContext, useContext, useMemo } from 'react' -import { useLocation, matchPath, Outlet } from 'react-router-dom' -import { PortalModule, PortalRoute, PortalType } from '../types/portalCore' -import { useAuth } from '@island.is/auth/react' import { ApolloClient, - useApolloClient, NormalizedCacheObject, + useApolloClient, } from '@apollo/client' +import { useLocale } from '@island.is/localization' +import { useUserInfo } from '@island.is/react-spa/bff' +import { createContext, useContext, useMemo } from 'react' +import { Outlet, matchPath, useLocation } from 'react-router-dom' +import { PortalModule, PortalRoute, PortalType } from '../types/portalCore' export type PortalMeta = { portalType: PortalType @@ -44,7 +44,7 @@ export const PortalProvider = ({ routes, }: PortalProviderProps) => { const { pathname } = useLocation() - const { userInfo } = useAuth() + const userInfo = useUserInfo() const { formatMessage } = useLocale() const client = useApolloClient() as ApolloClient diff --git a/libs/portals/core/src/components/PortalRouter.tsx b/libs/portals/core/src/components/PortalRouter.tsx index 09f8038d6fed..d85ec65d0837 100644 --- a/libs/portals/core/src/components/PortalRouter.tsx +++ b/libs/portals/core/src/components/PortalRouter.tsx @@ -1,25 +1,24 @@ -import React, { useEffect, useState, useRef } from 'react' +import React, { useEffect, useRef, useState } from 'react' import { - createBrowserRouter, RouteObject, RouterProvider, + createBrowserRouter, } from 'react-router-dom' import { - useApolloClient, ApolloClient, NormalizedCacheObject, + useApolloClient, } from '@apollo/client' import { useLocale } from '@island.is/localization' -import { useFeatureFlagClient } from '@island.is/react/feature-flags' -import { useAuth } from '@island.is/auth/react' +import { useUserInfo } from '@island.is/react-spa/bff' import { LoadingScreen } from '@island.is/react/components' -import { createModuleRoutes } from '../utils/router/createModuleRoutes' +import { useFeatureFlagClient } from '@island.is/react/feature-flags' +import { m } from '../lib/messages' import { PortalModule, PortalRoute } from '../types/portalCore' -import { PortalMeta, PortalProvider } from './PortalProvider' +import { createModuleRoutes } from '../utils/router/createModuleRoutes' import { prepareRouterData } from '../utils/router/prepareRouterData' -import { m } from '../lib/messages' -import { useUserInfo } from '@island.is/react-spa/bff' +import { PortalMeta, PortalProvider } from './PortalProvider' type PortalRouterProps = { modules: PortalModule[] diff --git a/libs/portals/core/src/hooks/useModuleProps.ts b/libs/portals/core/src/hooks/useModuleProps.ts index a6e18220c078..895bc5744f57 100644 --- a/libs/portals/core/src/hooks/useModuleProps.ts +++ b/libs/portals/core/src/hooks/useModuleProps.ts @@ -3,10 +3,10 @@ import { ApolloClient, NormalizedCacheObject, } from '@apollo/client' -import { useAuth } from '@island.is/auth/react' +import { useUserInfo } from '@island.is/react-spa/bff' export const useModuleProps = () => { - const { userInfo } = useAuth() + const userInfo = useUserInfo() const client = useApolloClient() as ApolloClient if (userInfo === null) { diff --git a/libs/portals/core/src/hooks/useNavigation.ts b/libs/portals/core/src/hooks/useNavigation.ts index fe7156246abe..344ec5840b6c 100644 --- a/libs/portals/core/src/hooks/useNavigation.ts +++ b/libs/portals/core/src/hooks/useNavigation.ts @@ -1,6 +1,6 @@ +import { useUserInfo } from '@island.is/react-spa/bff' import { useMemo } from 'react' import { useLocation } from 'react-router-dom' -import { useAuth } from '@island.is/auth/react' import { useRoutes } from '../components/PortalProvider' import { PortalNavigationItem } from '../types/portalCore' import { filterNavigationTree } from '../utils/filterNavigationTree/filterNavigationTree' @@ -9,7 +9,7 @@ export const useNavigation = ( navigation: PortalNavigationItem, dynamicRouteArray?: string[], ) => { - const { userInfo } = useAuth() + const userInfo = useUserInfo() const routes = useRoutes() const { pathname } = useLocation() diff --git a/libs/portals/core/src/screens/AccessDenied.tsx b/libs/portals/core/src/screens/AccessDenied.tsx index ac7b5b5854c4..8ae9d5caa7dd 100644 --- a/libs/portals/core/src/screens/AccessDenied.tsx +++ b/libs/portals/core/src/screens/AccessDenied.tsx @@ -1,5 +1,5 @@ import { useLocale } from '@island.is/localization' -import { useAuth } from '@island.is/auth/react' +import { useUserInfo } from '@island.is/react-spa/bff' import { checkDelegation } from '@island.is/shared/utils' import { m } from '../lib/messages' @@ -7,7 +7,7 @@ import { Problem } from '@island.is/react-spa/shared' export const AccessDenied = () => { const { formatMessage } = useLocale() - const { userInfo: user } = useAuth() + const user = useUserInfo() const isDelegation = user && checkDelegation(user) return ( diff --git a/libs/portals/core/src/screens/ModuleRoute.tsx b/libs/portals/core/src/screens/ModuleRoute.tsx index ef37780576d7..156082b039f1 100644 --- a/libs/portals/core/src/screens/ModuleRoute.tsx +++ b/libs/portals/core/src/screens/ModuleRoute.tsx @@ -5,7 +5,7 @@ import { PortalRoute } from '../types/portalCore' import { usePortalMeta } from '../components/PortalProvider' import { plausiblePageviewDetail } from '../utils/plausible' import { Box } from '@island.is/island-ui/core' -import { useAuth } from '@island.is/auth/react' +import { useUserInfo } from '@island.is/react-spa/bff' type ModuleRouteProps = { route: PortalRoute @@ -14,7 +14,7 @@ type ModuleRouteProps = { export const ModuleRoute = React.memo(({ route }: ModuleRouteProps) => { const location = useLocation() const { basePath, portalTitle } = usePortalMeta() - const { userInfo } = useAuth() + const userInfo = useUserInfo() const { formatMessage } = useLocale() useEffect(() => { diff --git a/libs/portals/shared-modules/delegations/src/components/access/AccessConfirmModal.tsx b/libs/portals/shared-modules/delegations/src/components/access/AccessConfirmModal.tsx index 740c1f015906..dbb8f00a3c9e 100644 --- a/libs/portals/shared-modules/delegations/src/components/access/AccessConfirmModal.tsx +++ b/libs/portals/shared-modules/delegations/src/components/access/AccessConfirmModal.tsx @@ -1,6 +1,6 @@ import { isDefined } from '@island.is/shared/utils' import { AuthDelegationScope } from '@island.is/api/schema' -import { useAuth } from '@island.is/auth/react' +import { useUserInfo } from '@island.is/react-spa/bff' import { Box, useBreakpoint } from '@island.is/island-ui/core' import { useLocale } from '@island.is/localization' import { formatNationalId, m as coreMessages } from '@island.is/portals/core' @@ -37,7 +37,7 @@ export const AccessConfirmModal = ({ ...rest }: AccessConfirmModalProps) => { const { formatMessage } = useLocale() - const { userInfo } = useAuth() + const userInfo = useUserInfo() const { md } = useBreakpoint() const [error, setError] = useState(formError ?? false) diff --git a/libs/portals/shared-modules/delegations/src/components/access/AccessDeleteModal/AccessDeleteModal.tsx b/libs/portals/shared-modules/delegations/src/components/access/AccessDeleteModal/AccessDeleteModal.tsx index 873d5e2aef64..363b9bd9c435 100644 --- a/libs/portals/shared-modules/delegations/src/components/access/AccessDeleteModal/AccessDeleteModal.tsx +++ b/libs/portals/shared-modules/delegations/src/components/access/AccessDeleteModal/AccessDeleteModal.tsx @@ -1,4 +1,4 @@ -import { useAuth } from '@island.is/auth/react' +import { useUserInfo } from '@island.is/react-spa/bff' import { Box, toast, useBreakpoint } from '@island.is/island-ui/core' import { useLocale } from '@island.is/localization' import { formatNationalId } from '@island.is/portals/core' @@ -29,7 +29,7 @@ export const AccessDeleteModal = ({ ...rest }: AccessDeleteModalProps) => { const { formatMessage, lang } = useLocale() - const { userInfo } = useAuth() + const userInfo = useUserInfo() const { md } = useBreakpoint() const [error, setError] = useState(false) const [deleteAuthDelegation, { loading }] = useDeleteAuthDelegationMutation() diff --git a/libs/portals/shared-modules/delegations/src/components/delegations/incoming/DelegationIncomingModal/DelegationIncomingModal.tsx b/libs/portals/shared-modules/delegations/src/components/delegations/incoming/DelegationIncomingModal/DelegationIncomingModal.tsx index 4a6fe199cc24..baf5c2860f59 100644 --- a/libs/portals/shared-modules/delegations/src/components/delegations/incoming/DelegationIncomingModal/DelegationIncomingModal.tsx +++ b/libs/portals/shared-modules/delegations/src/components/delegations/incoming/DelegationIncomingModal/DelegationIncomingModal.tsx @@ -1,5 +1,5 @@ import { useEffect } from 'react' -import { useAuth } from '@island.is/auth/react' +import { useUserInfo } from '@island.is/react-spa/bff' import { Box } from '@island.is/island-ui/core' import { useLocale } from '@island.is/localization' @@ -21,7 +21,7 @@ export const DelegationIncomingModal = ({ ...rest }: DelegationIncomingModalProps) => { const { formatMessage, lang } = useLocale() - const { userInfo } = useAuth() + const userInfo = useUserInfo() const [getAuthScopeTree, { data: scopeTreeData, loading: scopeTreeLoading }] = useAuthScopeTreeLazyQuery() diff --git a/libs/portals/shared-modules/delegations/src/screens/AccessControl.tsx b/libs/portals/shared-modules/delegations/src/screens/AccessControl.tsx index bda0137d5844..ea32968da1da 100644 --- a/libs/portals/shared-modules/delegations/src/screens/AccessControl.tsx +++ b/libs/portals/shared-modules/delegations/src/screens/AccessControl.tsx @@ -1,11 +1,10 @@ -import React from 'react' -import { useNavigate } from 'react-router-dom' -import { useLocation } from 'react-use' import { Box, Button, GridColumn, Tabs } from '@island.is/island-ui/core' -import { IntroHeader, usePortalMeta } from '@island.is/portals/core' import { useLocale, useNamespaces } from '@island.is/localization' -import { useAuth } from '@island.is/auth/react' +import { IntroHeader, usePortalMeta } from '@island.is/portals/core' +import { useUserInfo } from '@island.is/react-spa/bff' import { isDefined } from '@island.is/shared/utils' +import { useNavigate } from 'react-router-dom' +import { useLocation } from 'react-use' import { DelegationsIncoming } from '../components/delegations/incoming/DelegationsIncoming' import { DelegationsOutgoing } from '../components/delegations/outgoing/DelegationsOutgoing' import { m } from '../lib/messages' @@ -18,7 +17,7 @@ const AccessControl = () => { useNamespaces(['sp.access-control-delegations']) const { formatMessage } = useLocale() - const { userInfo } = useAuth() + const userInfo = useUserInfo() const navigate = useNavigate() const location = useLocation() const { basePath } = usePortalMeta() diff --git a/libs/portals/shared-modules/delegations/src/screens/GrantAccess/GrantAccess.tsx b/libs/portals/shared-modules/delegations/src/screens/GrantAccess/GrantAccess.tsx index e41de7ceeea3..5c86da9756e4 100644 --- a/libs/portals/shared-modules/delegations/src/screens/GrantAccess/GrantAccess.tsx +++ b/libs/portals/shared-modules/delegations/src/screens/GrantAccess/GrantAccess.tsx @@ -1,36 +1,38 @@ import cn from 'classnames' import * as kennitala from 'kennitala' -import get from 'lodash/get' import React, { useEffect, useState } from 'react' -import { defineMessage } from 'react-intl' import { Control, FormProvider, useForm } from 'react-hook-form' +import { defineMessage } from 'react-intl' import { useNavigate } from 'react-router-dom' -import { useUserInfo } from '@island.is/auth/react' import { Box, - Input, Icon, - toast, - Text, + Input, SkeletonLoader, + Text, + toast, useBreakpoint, } from '@island.is/island-ui/core' import { useLocale, useNamespaces } from '@island.is/localization' -import { IntroHeader } from '@island.is/portals/core' -import { formatNationalId, m as coreMessages } from '@island.is/portals/core' +import { + IntroHeader, + m as coreMessages, + formatNationalId, +} from '@island.is/portals/core' +import { useUserInfo } from '@island.is/react-spa/bff' import { Problem } from '@island.is/react-spa/shared' import { InputController, SelectController, } from '@island.is/shared/form-fields' -import { DelegationsFormFooter } from '../../components/delegations/DelegationsFormFooter' import { IdentityCard } from '../../components/IdentityCard/IdentityCard' -import { DomainOption, useDomains } from '../../hooks/useDomains/useDomains' +import { DelegationsFormFooter } from '../../components/delegations/DelegationsFormFooter' import { ALL_DOMAINS } from '../../constants/domain' -import { DelegationPaths } from '../../lib/paths' +import { DomainOption, useDomains } from '../../hooks/useDomains/useDomains' import { m } from '../../lib/messages' +import { DelegationPaths } from '../../lib/paths' import { useCreateAuthDelegationMutation, useIdentityLazyQuery, diff --git a/libs/react-spa/bff/src/lib/BffProvider.tsx b/libs/react-spa/bff/src/lib/BffProvider.tsx index bdabf9b624eb..71e32d60e941 100644 --- a/libs/react-spa/bff/src/lib/BffProvider.tsx +++ b/libs/react-spa/bff/src/lib/BffProvider.tsx @@ -2,7 +2,7 @@ import { useEffectOnce } from '@island.is/react-spa/shared' import { ReactNode, useCallback, useReducer } from 'react' import { LoadingScreen } from '@island.is/react/components' -import { createBffUrlGenerator } from './bff.utils' +import { createBffUrlGenerator, createQueryStr } from './bff.utils' import { BffContext } from './BffContext' import { ErrorScreen } from './ErrorScreen' import { reducer, initialState, ActionType } from './bff.state' @@ -33,7 +33,11 @@ export const BffProvider = ({ }) if (!res.ok) { - window.location.href = bffUrlGenerator('/login') + const qs = createQueryStr({ + target_link_uri: window.location.href, + }) + + window.location.href = bffUrlGenerator(`/login?${qs}`) return } @@ -66,9 +70,33 @@ export const BffProvider = ({ ) }, [bffUrlGenerator, state.userInfo]) - const switchUser = useCallback((_nationalId?: string) => { - // TODO - }, []) + const switchUser = useCallback( + (nationalId?: string) => { + dispatch({ + type: ActionType.SWITCH_USER, + }) + + const qs = createQueryStr( + nationalId !== undefined + ? { + login_hint: nationalId, + /** + * TODO: remove this. + * It is currently required to switch delegations, but we'd like + * the IDS to handle login_required and other potential road + * blocks. Now OidcSignIn is handling login_required. + */ + prompt: 'none', + } + : { + prompt: 'select_account', + }, + ) + + window.location.href = bffUrlGenerator(`/login?${qs}`) + }, + [bffUrlGenerator], + ) useEffectOnce(() => { checkLogin() @@ -92,6 +120,8 @@ export const BffProvider = ({ switchUser, }} > + +
{isLoggedIn && } {showErrorScreen ? ( diff --git a/libs/react-spa/bff/src/lib/bff.hooks.ts b/libs/react-spa/bff/src/lib/bff.hooks.ts index e21c4528a7e6..21cafce27445 100644 --- a/libs/react-spa/bff/src/lib/bff.hooks.ts +++ b/libs/react-spa/bff/src/lib/bff.hooks.ts @@ -1,22 +1,41 @@ import { useContext } from 'react' import { BffContext } from './BffContext' +import { AuthContext } from '@island.is/auth/react' +/** + * This hook is used to get the authentication context. + * It will determine what context to use based on the context that is available. + * We will remove support for AuthContext when other clients transition over to BFF. + */ export const useAuth = () => { - const context = useContext(BffContext) + const bffContext = useContext(BffContext) + const authContext = useContext(AuthContext) - if (!context) { + if (bffContext) { + return bffContext + } else if (authContext) { + return authContext + } else if (!bffContext) { throw new Error('useAuth must be used within a BffProvider') } - return context + throw new Error('useAuth must be used within a AuthProvider') } +/** + * This hook is used to get user information. + * It will determine what context to use based on the context that is available. + * We will remove support for AuthContext when other clients transition over to BFF. + */ export const useUserInfo = () => { - const context = useContext(BffContext) + const bffContext = useContext(BffContext) + const authContext = useContext(AuthContext) - if (!context?.userInfo) { - throw new Error('User info is not available. Is the user authenticated?') + if (bffContext?.userInfo) { + return bffContext.userInfo + } else if (authContext?.userInfo) { + return authContext.userInfo } - return context.userInfo + throw new Error('User info is not available. Is the user authenticated?') } diff --git a/libs/react-spa/bff/src/lib/bff.utils.ts b/libs/react-spa/bff/src/lib/bff.utils.ts index d65084661a35..05c13c2a808a 100644 --- a/libs/react-spa/bff/src/lib/bff.utils.ts +++ b/libs/react-spa/bff/src/lib/bff.utils.ts @@ -21,3 +21,7 @@ export const createBffUrlGenerator = (basePath: string) => { * Trim any leading and trailing slashes */ const sanitizePath = (path: string) => path.replace(/^\/+|\/+$/g, '') + +export const createQueryStr = (params: Record) => { + return new URLSearchParams(params).toString() +} diff --git a/libs/shared/types/src/lib/bff.ts b/libs/shared/types/src/lib/bff.ts index 62e8d0ce9d8b..ad03077a97b8 100644 --- a/libs/shared/types/src/lib/bff.ts +++ b/libs/shared/types/src/lib/bff.ts @@ -1,71 +1,24 @@ import { AuthDelegationType } from './delegation' export interface IdTokenClaims { - // Issuer - iss: string - - // Not before (timestamp) - nbf: number - - // Issued at (timestamp) - iat: number - - // Expiration time (timestamp) - exp: number - - // Audience - aud: string - - // Authentication methods references - amr: string[] - - // Access token hash - at_hash: string - // Session ID sid: string - - // Subject identifier - sub: string - - // Authentication time (timestamp) - auth_time: number - - // Identity provider - idp: string - - // Authentication context class reference - acr: string - - // Subject type (e.g., "person") - subjectType: 'person' | 'legalEntity' - - // National ID - nationalId: string - - // Full name - name: string - - // Gender (e.g., "male") - gender: string - // Birthdate in the format YYYY-MM-DD birthdate: string - - // Locale (e.g., "is") - locale: string - + nationalId: string + name: string + // Identity provider + idp: string actor?: { nationalId: string name: string } - - // Delegation type + subjectType: 'person' | 'legalEntity' delegationType?: AuthDelegationType[] } export type BffUser = { - // User scope property here for backwards compatibility + // User scope unparsed here for backwards compatibility scope: string scopes: string[] profile: IdTokenClaims & { From 54597d1c43bf52c78e8a480b1399422009571aaf Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Wed, 11 Sep 2024 15:51:53 +0000 Subject: [PATCH 031/248] feat(proxy-api): Support for proxy api, hooks update, regulations download connection with bff --- apps/api/src/app/environments/environment.ts | 14 +- .../regulation-documents.controller.ts | 2 +- apps/portals/admin/proxy.config.json | 2 +- .../src/components/Header/Header.tsx | 2 +- apps/services/bff/README.md | 2 +- apps/services/bff/infra/admin-portal.infra.ts | 16 ++- apps/services/bff/src/app/bff.config.ts | 12 ++ .../src/app/modules/auth/auth.controller.ts | 18 +-- .../bff/src/app/modules/auth/auth.service.ts | 70 ++++----- .../bff/src/app/modules/ids/ids.service.ts | 36 ++++- .../src/app/modules/proxy/proxy.controller.ts | 28 +++- .../src/app/modules/proxy/proxy.service.ts | 133 +++++++++++++----- .../modules/proxy/queries/api-proxy.query.ts | 6 + .../bff/src/app/modules/user/user.service.ts | 6 +- .../app/utils/{isExpired.ts => is-expired.ts} | 0 .../bff/src/app/utils/qs-validation-pipe.ts | 7 + .../bff/src/app/utils/validate-uri.ts | 20 +++ .../bff/src/environment/environment.ts | 2 +- .../src/components/DownloadDraftButton.tsx | 90 ++++++------ libs/portals/core/src/types/portalCore.ts | 6 +- libs/portals/core/src/utils/modules.ts | 6 +- .../src/utils/router/prepareRouterData.ts | 6 +- libs/react-spa/bff/src/lib/BffContext.tsx | 1 + libs/react-spa/bff/src/lib/BffProvider.tsx | 53 ++++--- libs/react-spa/bff/src/lib/bff.hooks.ts | 90 ++++++++++-- libs/react-spa/bff/src/lib/bff.state.ts | 9 +- libs/react-spa/bff/src/lib/bff.utils.ts | 2 +- libs/react/feature-flags/src/lib/context.tsx | 10 +- .../UserOnboardingModal/components/Header.tsx | 2 +- .../service-portal/information/src/module.tsx | 13 +- .../src/auth/UserMenu/UserButton.tsx | 14 +- .../src/auth/UserMenu/UserDelegations.tsx | 15 +- .../src/auth/UserMenu/UserDropdown.tsx | 37 +++-- .../auth/UserMenu/UserLanguageSwitcher.tsx | 9 +- .../components/src/auth/UserMenu/UserMenu.tsx | 12 +- libs/shared/types/src/lib/bff.ts | 5 +- libs/shared/utils/src/lib/isDelegation.ts | 4 +- 37 files changed, 487 insertions(+), 273 deletions(-) create mode 100644 apps/services/bff/src/app/modules/proxy/queries/api-proxy.query.ts rename apps/services/bff/src/app/utils/{isExpired.ts => is-expired.ts} (100%) create mode 100644 apps/services/bff/src/app/utils/qs-validation-pipe.ts create mode 100644 apps/services/bff/src/app/utils/validate-uri.ts diff --git a/apps/api/src/app/environments/environment.ts b/apps/api/src/app/environments/environment.ts index c79e3b5528a8..b3e082a5fc65 100644 --- a/apps/api/src/app/environments/environment.ts +++ b/apps/api/src/app/environments/environment.ts @@ -208,11 +208,15 @@ const devConfig = () => ({ passphrase: process.env.ISLYKILL_SERVICE_PASSPHRASE, basePath: process.env.ISLYKILL_SERVICE_BASEPATH, }, - enableCors: { - origin: 'http://localhost:3333', - methods: ['POST'], - credentials: true, - }, + enableCors: + process.env.BFF_CORS === 'true' + ? { + // Bff port number + origin: ['http://localhost:3010'], + methods: ['POST'], + credentials: true, + } + : undefined, }) export const getConfig = diff --git a/apps/download-service/src/app/modules/regulation-documents/regulation-documents.controller.ts b/apps/download-service/src/app/modules/regulation-documents/regulation-documents.controller.ts index 9fd2e66917a3..d4a071cfe177 100644 --- a/apps/download-service/src/app/modules/regulation-documents/regulation-documents.controller.ts +++ b/apps/download-service/src/app/modules/regulation-documents/regulation-documents.controller.ts @@ -91,7 +91,7 @@ export class RegulationDocumentsController { res.header('Content-Type', documentResponse.data.mimeType) res.header('Content-length', buffer.length.toString()) res.header('Content-Disposition', `inline; filename=${filename}`) - res.header('Cache-Control: no-cache') + res.header('Cache-Control', 'no-cache') return res.status(200).end(buffer) } diff --git a/apps/portals/admin/proxy.config.json b/apps/portals/admin/proxy.config.json index 91fa997b899c..8f1d53118e19 100644 --- a/apps/portals/admin/proxy.config.json +++ b/apps/portals/admin/proxy.config.json @@ -1,6 +1,6 @@ { "/stjornbord/bff/*": { - "target": "http://localhost:3333", + "target": "http://localhost:3010", "secure": false } } diff --git a/apps/service-portal/src/components/Header/Header.tsx b/apps/service-portal/src/components/Header/Header.tsx index 21c913b99da0..f3d09e9f596d 100644 --- a/apps/service-portal/src/components/Header/Header.tsx +++ b/apps/service-portal/src/components/Header/Header.tsx @@ -130,7 +130,7 @@ export const Header = ({ position }: Props) => { /> )} - {user && } + {user && } ) diff --git a/libs/portals/core/src/types/portalCore.ts b/libs/portals/core/src/types/portalCore.ts index 5dc5375c089d..fb426c2a1890 100644 --- a/libs/portals/core/src/types/portalCore.ts +++ b/libs/portals/core/src/types/portalCore.ts @@ -6,7 +6,7 @@ import { RouteObject } from 'react-router-dom' import type { Features } from '@island.is/react/feature-flags' import { IconProps } from '@island.is/island-ui/core' -import { User } from '@island.is/shared/types' +import { BffUser } from '@island.is/shared/types' import { OrganizationSlugType } from '@island.is/shared/constants' /** @@ -79,7 +79,7 @@ export interface PortalNavigationItem { * The props provided to a portal module */ export interface PortalModuleProps { - userInfo: User + userInfo: BffUser } export interface PortalModuleRoutesProps extends PortalModuleProps { @@ -166,7 +166,7 @@ export interface PortalModule { /** * Indicates if module is enabled or not */ - enabled?: (props: { userInfo: User; isCompany: boolean }) => boolean + enabled?: (props: { userInfo: BffUser; isCompany: boolean }) => boolean /** * The layout type of the module diff --git a/libs/portals/core/src/utils/modules.ts b/libs/portals/core/src/utils/modules.ts index 33c3b6449df2..09b85d297fb3 100644 --- a/libs/portals/core/src/utils/modules.ts +++ b/libs/portals/core/src/utils/modules.ts @@ -1,6 +1,6 @@ import { FormatMessage } from '@island.is/localization' import flatten from 'lodash/flatten' -import type { User } from '@island.is/shared/types' +import type { BffUser } from '@island.is/shared/types' import { FeatureFlagClient } from '@island.is/react/feature-flags' import type { PortalModule, PortalRoute } from '../types/portalCore' import { ApolloClient, NormalizedCacheObject } from '@apollo/client' @@ -8,7 +8,7 @@ import { ApolloClient, NormalizedCacheObject } from '@apollo/client' interface FilterEnabledModulesArgs { modules: PortalModule[] featureFlagClient: FeatureFlagClient - userInfo: User + userInfo: BffUser } export const filterEnabledModules = async ({ @@ -41,7 +41,7 @@ export const filterEnabledModules = async ({ } interface ArrangeRoutesArgs { - userInfo: User + userInfo: BffUser modules: PortalModule[] featureFlagClient: FeatureFlagClient client: ApolloClient diff --git a/libs/portals/core/src/utils/router/prepareRouterData.ts b/libs/portals/core/src/utils/router/prepareRouterData.ts index 7534f0de5233..d0959b3d9cb8 100644 --- a/libs/portals/core/src/utils/router/prepareRouterData.ts +++ b/libs/portals/core/src/utils/router/prepareRouterData.ts @@ -2,11 +2,11 @@ import { ApolloClient, NormalizedCacheObject } from '@apollo/client' import { FormatMessage } from '@island.is/localization' import { arrangeRoutes, filterEnabledModules } from '../modules' import { FeatureFlagClient } from '@island.is/feature-flags' -import { User } from '@island.is/shared/types' +import { BffUser } from '@island.is/shared/types' import { PortalModule, PortalRoute } from '../../types/portalCore' export type PrepareRouterDataProps = { - userInfo: User + userInfo: BffUser featureFlagClient: FeatureFlagClient modules: PortalModule[] client: ApolloClient @@ -16,7 +16,7 @@ export type PrepareRouterDataProps = { export type PrepareRouterDataReturnType = { modules: PortalModule[] routes: PortalRoute[] - userInfo: User + userInfo: BffUser formatMessage: FormatMessage } diff --git a/libs/react-spa/bff/src/lib/BffContext.tsx b/libs/react-spa/bff/src/lib/BffContext.tsx index 5b158f41dde0..613672ae56c9 100644 --- a/libs/react-spa/bff/src/lib/BffContext.tsx +++ b/libs/react-spa/bff/src/lib/BffContext.tsx @@ -6,6 +6,7 @@ export interface BffContextType extends BffReducerState { signIn(): void signOut(): void switchUser(nationalId?: string): void + bffUrlGenerator(relativePath?: string): string } export const BffContext = createContext(undefined) diff --git a/libs/react-spa/bff/src/lib/BffProvider.tsx b/libs/react-spa/bff/src/lib/BffProvider.tsx index 71e32d60e941..76fc6d99157a 100644 --- a/libs/react-spa/bff/src/lib/BffProvider.tsx +++ b/libs/react-spa/bff/src/lib/BffProvider.tsx @@ -70,33 +70,30 @@ export const BffProvider = ({ ) }, [bffUrlGenerator, state.userInfo]) - const switchUser = useCallback( - (nationalId?: string) => { - dispatch({ - type: ActionType.SWITCH_USER, - }) + const switchUser = (nationalId?: string) => { + dispatch({ + type: ActionType.SWITCH_USER, + }) - const qs = createQueryStr( - nationalId !== undefined - ? { - login_hint: nationalId, - /** - * TODO: remove this. - * It is currently required to switch delegations, but we'd like - * the IDS to handle login_required and other potential road - * blocks. Now OidcSignIn is handling login_required. - */ - prompt: 'none', - } - : { - prompt: 'select_account', - }, - ) - - window.location.href = bffUrlGenerator(`/login?${qs}`) - }, - [bffUrlGenerator], - ) + const qs = createQueryStr( + nationalId !== undefined + ? { + login_hint: nationalId, + /** + * TODO: remove this. + * It is currently required to switch delegations, but we'd like + * the IDS to handle login_required and other potential road + * blocks. Now OidcSignIn is handling login_required. + */ + prompt: 'none', + } + : { + prompt: 'select_account', + }, + ) + + window.location.href = bffUrlGenerator(`/login?${qs}`) + } useEffectOnce(() => { checkLogin() @@ -118,11 +115,9 @@ export const BffProvider = ({ signIn, signOut, switchUser, + bffUrlGenerator, }} > - -
- {isLoggedIn && } {showErrorScreen ? ( ) : showLoadingScreen ? ( diff --git a/libs/react-spa/bff/src/lib/bff.hooks.ts b/libs/react-spa/bff/src/lib/bff.hooks.ts index 21cafce27445..ecea08039e64 100644 --- a/libs/react-spa/bff/src/lib/bff.hooks.ts +++ b/libs/react-spa/bff/src/lib/bff.hooks.ts @@ -1,11 +1,63 @@ import { useContext } from 'react' -import { BffContext } from './BffContext' +import { BffContext, BffContextType } from './BffContext' import { AuthContext } from '@island.is/auth/react' +import { BffUser, User } from '@island.is/shared/types' /** - * This hook is used to get the authentication context. - * It will determine what context to use based on the context that is available. - * We will remove support for AuthContext when other clients transition over to BFF. + * Maps an object to a BffUser type. + */ +export const mapToBffUser = (input: User): BffUser => { + const { + profile: { + sid, + birthdate, + nationalId, + name, + idp, + actor, + subjectType, + delegationType, + locale, + }, + scope, + scopes, + } = input + + // Return a mapped BffUser object + return { + scope: scope || '', + scopes: scopes || [], + profile: { + sid: sid || '', + birthdate, + nationalId, + name, + idp, + actor, + subjectType, + delegationType, + dateOfBirth: birthdate ? new Date(birthdate) : undefined, + locale, + }, + } +} + +/** + * Dynamic hook to get the bff context. + */ +export const useBffContext = (hookName: string): BffContextType => { + const bffContext = useContext(BffContext) + + if (!bffContext) { + throw new Error(`${hookName} must be used within a BffProvider`) + } + + return bffContext +} + +/** + * This hook is used to get the BFF authentication context. + * It has backward compatibility with AuthContext. */ export const useAuth = () => { const bffContext = useContext(BffContext) @@ -13,29 +65,47 @@ export const useAuth = () => { if (bffContext) { return bffContext - } else if (authContext) { + } + + if (authContext) { return authContext - } else if (!bffContext) { - throw new Error('useAuth must be used within a BffProvider') } - throw new Error('useAuth must be used within a AuthProvider') + const errorMsg = (providerStr: string) => + `useAuth must be used within a ${providerStr}` + + if (!authContext) { + throw new Error(errorMsg('AuthProvider')) + } + + throw new Error(errorMsg('BffProvider')) } /** * This hook is used to get user information. * It will determine what context to use based on the context that is available. * We will remove support for AuthContext when other clients transition over to BFF. + * If AuthContext is being used then we will map the user info to the BffUser type. */ -export const useUserInfo = () => { +export const useUserInfo = (): BffUser => { const bffContext = useContext(BffContext) const authContext = useContext(AuthContext) if (bffContext?.userInfo) { return bffContext.userInfo } else if (authContext?.userInfo) { - return authContext.userInfo + return mapToBffUser(authContext.userInfo) } throw new Error('User info is not available. Is the user authenticated?') } + +/** + * This hook is used to get the bff url generator. + * The bff url generator is used to generate urls for the Bff in a conveinent way. + */ +export const useBffUrlGenerator = () => { + const { bffUrlGenerator } = useBffContext('useBffUrlGenerator') + + return bffUrlGenerator +} diff --git a/libs/react-spa/bff/src/lib/bff.state.ts b/libs/react-spa/bff/src/lib/bff.state.ts index 245b25cc2ec6..c433278aa811 100644 --- a/libs/react-spa/bff/src/lib/bff.state.ts +++ b/libs/react-spa/bff/src/lib/bff.state.ts @@ -1,4 +1,4 @@ -import { User } from '@island.is/shared/types' +import { BffUser } from '@island.is/shared/types' export type BffState = | 'logged-out' @@ -21,7 +21,7 @@ export enum ActionType { } export interface BffReducerState { - userInfo: User | null + userInfo: BffUser | null authState: BffState isAuthenticated: boolean baseUrl?: string @@ -43,7 +43,10 @@ export type Action = | ActionType.LOGGED_OUT | ActionType.SWITCH_USER } - | { type: ActionType.SIGNIN_SUCCESS | ActionType.USER_LOADED; payload: User } + | { + type: ActionType.SIGNIN_SUCCESS | ActionType.USER_LOADED + payload: BffUser + } | { type: ActionType.ERROR; payload: Error } export const reducer = ( diff --git a/libs/react-spa/bff/src/lib/bff.utils.ts b/libs/react-spa/bff/src/lib/bff.utils.ts index 05c13c2a808a..dd47a3589b9f 100644 --- a/libs/react-spa/bff/src/lib/bff.utils.ts +++ b/libs/react-spa/bff/src/lib/bff.utils.ts @@ -9,7 +9,7 @@ export const createBffUrlGenerator = (basePath: string) => { const sanitizedBasePath = sanitizePath(basePath) const origin = process.env.NODE_ENV === 'development' - ? 'http://localhost:3333' // When developing against the BFF locally, use localhost + ? 'http://localhost:3010' // When developing against the BFF locally, use localhost : sanitizePath(window.location.origin) // Use current window origin for production const baseUrl = `${origin}/${sanitizedBasePath}/bff` diff --git a/libs/react/feature-flags/src/lib/context.tsx b/libs/react/feature-flags/src/lib/context.tsx index c42ec62bc56a..8e5156cf44a3 100644 --- a/libs/react/feature-flags/src/lib/context.tsx +++ b/libs/react/feature-flags/src/lib/context.tsx @@ -1,13 +1,13 @@ -import React, { FC, createContext, useContext, useMemo } from 'react' -import * as ConfigCatJS from 'configcat-js' import { FeatureFlagClient, FeatureFlagUser, - SettingValue, SettingTypeOf, + SettingValue, createClientFactory, } from '@island.is/feature-flags' -import { useAuth } from '@island.is/auth/react' +import { useUserInfo } from '@island.is/react-spa/bff' +import * as ConfigCatJS from 'configcat-js' +import React, { FC, createContext, useContext, useMemo } from 'react' const createClient = createClientFactory(ConfigCatJS) @@ -26,7 +26,7 @@ export interface FeatureFlagContextProviderProps { export const FeatureFlagProvider: FC< React.PropsWithChildren > = ({ children, sdkKey, defaultUser: userProp }) => { - const { userInfo } = useAuth() + const userInfo = useUserInfo() const featureFlagClient = useMemo(() => { return createClient({ sdkKey }) }, [sdkKey]) diff --git a/libs/service-portal/information/src/components/PersonalInformation/UserOnboardingModal/components/Header.tsx b/libs/service-portal/information/src/components/PersonalInformation/UserOnboardingModal/components/Header.tsx index 5ac9e305fbe4..ce24187e7999 100644 --- a/libs/service-portal/information/src/components/PersonalInformation/UserOnboardingModal/components/Header.tsx +++ b/libs/service-portal/information/src/components/PersonalInformation/UserOnboardingModal/components/Header.tsx @@ -46,7 +46,7 @@ export const OnboardingHeader = ({ {!hideClose && ( - {user && } + {user && } import('./screens/UserInfoOverview/UserInfoOverview'), @@ -30,23 +29,23 @@ const UserNotificationsSettings = lazy(() => import('./screens/UserNotifications/UserNotifications'), ) -const sharedRoutes = (userInfo: User) => [ +const sharedRoutes = (scopes: string[]) => [ { name: m.mySettings, path: InformationPaths.SettingsOld, - enabled: userInfo.scopes.includes(UserProfileScope.write), + enabled: scopes.includes(UserProfileScope.write), element: , }, { name: m.mySettings, path: InformationPaths.Settings, - enabled: userInfo.scopes.includes(UserProfileScope.write), + enabled: scopes.includes(UserProfileScope.write), element: , }, { name: 'Notifications', path: InformationPaths.Notifications, - enabled: userInfo.scopes.includes(DocumentsScope.main), + enabled: scopes.includes(DocumentsScope.main), key: 'Notifications', element: , }, @@ -98,7 +97,7 @@ export const informationModule: PortalModule = { enabled: userInfo.scopes.includes(ApiScope.meDetails), element: , }, - ...sharedRoutes(userInfo), + ...sharedRoutes(userInfo.scopes), ], companyRoutes: ({ userInfo }) => [ { @@ -107,6 +106,6 @@ export const informationModule: PortalModule = { enabled: userInfo.scopes.includes(ApiScope.company), element: , }, - ...sharedRoutes(userInfo), + ...sharedRoutes(userInfo.scopes), ], } diff --git a/libs/shared/components/src/auth/UserMenu/UserButton.tsx b/libs/shared/components/src/auth/UserMenu/UserButton.tsx index 0a8b3909cac6..23b175d72d6b 100644 --- a/libs/shared/components/src/auth/UserMenu/UserButton.tsx +++ b/libs/shared/components/src/auth/UserMenu/UserButton.tsx @@ -1,19 +1,17 @@ -import React from 'react' import { + Box, Button, Hidden, Inline, UserAvatar, - Box, } from '@island.is/island-ui/core' -import { User } from '@island.is/shared/types' import { useLocale } from '@island.is/localization' +import { useUserInfo } from '@island.is/react-spa/bff' import { userMessages } from '@island.is/shared/translations' -import * as styles from './UserMenu.css' import { checkDelegation } from '@island.is/shared/utils' +import * as styles from './UserMenu.css' interface UserButtonProps { - user: User small: boolean onClick(): void iconOnlyMobile?: boolean @@ -22,11 +20,11 @@ interface UserButtonProps { export const UserButton = ({ onClick, - user, small, iconOnlyMobile = false, userMenuOpen, }: UserButtonProps) => { + const user = useUserInfo() const isDelegation = checkDelegation(user) const { profile } = user const { formatMessage } = useLocale() @@ -77,7 +75,9 @@ export const UserButton = ({ {isDelegation ? ( <>
{profile.name}
-
{profile.actor!.name}
+ {profile?.actor?.name && ( +
{profile.actor.name}
+ )} ) : ( profile.name diff --git a/libs/shared/components/src/auth/UserMenu/UserDelegations.tsx b/libs/shared/components/src/auth/UserMenu/UserDelegations.tsx index 229260989e8f..b4db29d4d10f 100644 --- a/libs/shared/components/src/auth/UserMenu/UserDelegations.tsx +++ b/libs/shared/components/src/auth/UserMenu/UserDelegations.tsx @@ -1,23 +1,20 @@ -import React from 'react' -import { Stack, Text, SkeletonLoader, Box } from '@island.is/island-ui/core' -import { User } from '@island.is/shared/types' +import { Box, Stack } from '@island.is/island-ui/core' import { useLocale } from '@island.is/localization' +import { useAuth, useUserInfo } from '@island.is/react-spa/bff' import { userMessages } from '@island.is/shared/translations' -import { UserTopicCard } from './UserTopicCard' import { UserDropdownItem } from './UserDropdownItem' -import { useAuth } from '@island.is/auth/react' +import { UserTopicCard } from './UserTopicCard' interface UserDelegationsProps { - user: User showActorButton: boolean onSwitchUser: (nationalId: string) => void } export const UserDelegations = ({ - user, showActorButton, onSwitchUser, }: UserDelegationsProps) => { + const user = useUserInfo() const { formatMessage } = useLocale() const { switchUser } = useAuth() const actor = user.profile.actor @@ -28,9 +25,9 @@ export const UserDelegations = ({ {showActorButton && !!actor && ( onSwitchUser(actor?.nationalId)} + onClick={() => onSwitchUser(actor.nationalId)} > - {actor?.name} + {actor.name} )} > onLogout?: () => void @@ -34,7 +34,6 @@ interface UserDropdownProps { } export const UserDropdown = ({ - user, dropdownState, setDropdownState, onSwitchUser, @@ -43,6 +42,7 @@ export const UserDropdown = ({ showActorButton, showDropdownLanguage, }: UserDropdownProps) => { + const user = useUserInfo() const { formatMessage } = useLocale() const isVisible = dropdownState === 'open' const onClose = () => { @@ -127,16 +127,13 @@ export const UserDropdown = ({
{showDropdownLanguage && ( - - {} - + {} )} diff --git a/libs/shared/components/src/auth/UserMenu/UserLanguageSwitcher.tsx b/libs/shared/components/src/auth/UserMenu/UserLanguageSwitcher.tsx index 9f3eada03a13..3ed67d33e9ec 100644 --- a/libs/shared/components/src/auth/UserMenu/UserLanguageSwitcher.tsx +++ b/libs/shared/components/src/auth/UserMenu/UserLanguageSwitcher.tsx @@ -1,18 +1,17 @@ -import React from 'react' import { Box, Button, Select } from '@island.is/island-ui/core' -import { User, Locale } from '@island.is/shared/types' import { useLocale } from '@island.is/localization' -import { useUpdateUserProfileMutation } from '../../../gen/schema' +import { useUserInfo } from '@island.is/react-spa/bff' import { sharedMessages } from '@island.is/shared/translations' +import { Locale } from '@island.is/shared/types' import { checkDelegation } from '@island.is/shared/utils' +import { useUpdateUserProfileMutation } from '../../../gen/schema' export const UserLanguageSwitcher = ({ - user, dropdown = false, }: { - user: User dropdown?: boolean }) => { + const user = useUserInfo() const { lang, formatMessage, changeLanguage } = useLocale() const [updateUserProfileMutation] = useUpdateUserProfileMutation() diff --git a/libs/shared/components/src/auth/UserMenu/UserMenu.tsx b/libs/shared/components/src/auth/UserMenu/UserMenu.tsx index b831fbdf4b32..c1170406b04c 100644 --- a/libs/shared/components/src/auth/UserMenu/UserMenu.tsx +++ b/libs/shared/components/src/auth/UserMenu/UserMenu.tsx @@ -1,6 +1,6 @@ -import React, { useEffect, useState } from 'react' import { Box, Hidden } from '@island.is/island-ui/core' -import { useAuth } from '@island.is/auth/react' +import { useAuth, useUserInfo } from '@island.is/react-spa/bff' +import { useEffect, useState } from 'react' import { UserButton } from './UserButton' import { UserDropdown } from './UserDropdown' import { UserLanguageSwitcher } from './UserLanguageSwitcher' @@ -29,7 +29,8 @@ export const UserMenu = ({ const [dropdownState, setDropdownState] = useState<'closed' | 'open'>( 'closed', ) - const { signOut, switchUser, userInfo: user } = useAuth() + const { signOut, switchUser } = useAuth() + const user = useUserInfo() const handleClick = () => { setDropdownState(dropdownState === 'open' ? 'closed' : 'open') @@ -52,19 +53,16 @@ export const UserMenu = ({ {showLanguageSwitcher && ( - + )} - { diff --git a/libs/shared/types/src/lib/bff.ts b/libs/shared/types/src/lib/bff.ts index ad03077a97b8..6d23f007a7db 100644 --- a/libs/shared/types/src/lib/bff.ts +++ b/libs/shared/types/src/lib/bff.ts @@ -4,7 +4,7 @@ export interface IdTokenClaims { // Session ID sid: string // Birthdate in the format YYYY-MM-DD - birthdate: string + birthdate?: string nationalId: string name: string // Identity provider @@ -15,6 +15,7 @@ export interface IdTokenClaims { } subjectType: 'person' | 'legalEntity' delegationType?: AuthDelegationType[] + locale?: string } export type BffUser = { @@ -22,6 +23,6 @@ export type BffUser = { scope: string scopes: string[] profile: IdTokenClaims & { - dateOfBirth: Date + dateOfBirth?: Date } } diff --git a/libs/shared/utils/src/lib/isDelegation.ts b/libs/shared/utils/src/lib/isDelegation.ts index 9708f075015b..fb36417a6f5d 100644 --- a/libs/shared/utils/src/lib/isDelegation.ts +++ b/libs/shared/utils/src/lib/isDelegation.ts @@ -1,5 +1,5 @@ -import { User } from '@island.is/shared/types' +import { BffUser, User } from '@island.is/shared/types' -export const checkDelegation = (user: User) => { +export const checkDelegation = (user: User | BffUser) => { return Boolean(user?.profile.actor) } From 6e438777166ca6f9d81858dd6a27860dc8b03f76 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Wed, 11 Sep 2024 15:55:47 +0000 Subject: [PATCH 032/248] Better naming env --- apps/api/src/app/environments/environment.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/src/app/environments/environment.ts b/apps/api/src/app/environments/environment.ts index b3e082a5fc65..d311d6631f10 100644 --- a/apps/api/src/app/environments/environment.ts +++ b/apps/api/src/app/environments/environment.ts @@ -209,7 +209,7 @@ const devConfig = () => ({ basePath: process.env.ISLYKILL_SERVICE_BASEPATH, }, enableCors: - process.env.BFF_CORS === 'true' + process.env.BFF_LOCAL_DEVELOPMENT_CORS === 'true' ? { // Bff port number origin: ['http://localhost:3010'], From b0ace651c3db0fa2cceb05af55e84b46039358cf Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Wed, 11 Sep 2024 20:11:11 +0000 Subject: [PATCH 033/248] Rename secrets in infra --- apps/api/src/app/environments/environment.ts | 1 - apps/services/bff/infra/admin-portal.infra.ts | 7 +++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/apps/api/src/app/environments/environment.ts b/apps/api/src/app/environments/environment.ts index d311d6631f10..16b769f4cc68 100644 --- a/apps/api/src/app/environments/environment.ts +++ b/apps/api/src/app/environments/environment.ts @@ -211,7 +211,6 @@ const devConfig = () => ({ enableCors: process.env.BFF_LOCAL_DEVELOPMENT_CORS === 'true' ? { - // Bff port number origin: ['http://localhost:3010'], methods: ['POST'], credentials: true, diff --git a/apps/services/bff/infra/admin-portal.infra.ts b/apps/services/bff/infra/admin-portal.infra.ts index f1ae60c07144..0a6310a1cf47 100644 --- a/apps/services/bff/infra/admin-portal.infra.ts +++ b/apps/services/bff/infra/admin-portal.infra.ts @@ -54,20 +54,19 @@ export const serviceSetup = (services: { '@admin.island.is/form-system', '@admin.island.is/form-system:admin', ]), - // BFF BFF_CALLBACKS_BASE_PATH: generateWebBaseUrls('/stjornbord/bff/callbacks'), BFF_LOGOUT_REDIRECT_PATH: generateWebBaseUrls(), BFF_PROXY_API_ENDPOINT: generateWebBaseUrls('/api/graphql'), BFF_API_URL_PREFIX: 'stjornbord/bff', - BFF_TOKEN_SECRET: '/k8s/services-bff/BFF_TOKEN_SECRET', BFF_ALLOWED_EXTERNAL_API_URLS: json([ ref((h) => `http://${h.svc(services.regulationsAdminBackend)}`), ]), }) .secrets({ - BFF_IDENTITY_SERVER_SECRET: - '/k8s/services-bff/BFF_IDENTITY_SERVER_SECRET', + // The secret should be a valid 32-byte base64 key. + // Generate key example: `openssl rand -base64 32` + BFF_TOKEN_SECRET_BASE64: '/k8s/services-bff/BFF_TOKEN_SECRET_BASE64', }) .readiness('/health/check') .liveness('/liveness') From f400e4637b6b7516d3f5e569e47c364dae5d949c Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Thu, 12 Sep 2024 10:07:04 +0000 Subject: [PATCH 034/248] Refactor after self review --- .../bff/src/app/modules/auth/auth.service.ts | 10 +++------ .../bff/src/app/modules/ids/ids.service.ts | 2 +- .../src/components/DownloadDraftButton.tsx | 3 ++- libs/react-spa/bff/src/lib/BffProvider.tsx | 21 ++++++++++++------- libs/react-spa/bff/src/lib/bff.state.ts | 1 - libs/react-spa/bff/src/lib/bff.utils.ts | 12 +++++++---- 6 files changed, 28 insertions(+), 21 deletions(-) diff --git a/apps/services/bff/src/app/modules/auth/auth.service.ts b/apps/services/bff/src/app/modules/auth/auth.service.ts index 3b41a6b38a7b..bbe881bc6a76 100644 --- a/apps/services/bff/src/app/modules/auth/auth.service.ts +++ b/apps/services/bff/src/app/modules/auth/auth.service.ts @@ -225,7 +225,9 @@ export class AuthService { state: encodeURIComponent(JSON.stringify({ sid })), }) - return res.redirect(`${this.baseUrl}/connect/endsession?${searchParams}`) + return res.redirect( + `${this.baseUrl}/connect/endsession?${searchParams.toString()}`, + ) } /** @@ -235,12 +237,6 @@ export class AuthService { * Finally, we redirect the user back to the original URL. */ async callbackLogout(res: Response, { state }: CallbackLogoutQuery) { - if (!state) { - this.logger.error('Logout failed: No state param provided') - - throw new BadRequestException('Logout failed') - } - const { sid } = JSON.parse(decodeURIComponent(state)) if (!sid) { diff --git a/apps/services/bff/src/app/modules/ids/ids.service.ts b/apps/services/bff/src/app/modules/ids/ids.service.ts index 3fc78abe5e3c..324870be8be2 100644 --- a/apps/services/bff/src/app/modules/ids/ids.service.ts +++ b/apps/services/bff/src/app/modules/ids/ids.service.ts @@ -43,7 +43,7 @@ export class IdsService { throw new BadRequestException(`HTTP error! Status: ${response.status}`) } - return await response.json() + return response.json() } catch (error) { this.logger.error( `Error making request to ${endpoint}:`, diff --git a/libs/portals/admin/regulations-admin/src/components/DownloadDraftButton.tsx b/libs/portals/admin/regulations-admin/src/components/DownloadDraftButton.tsx index 4e880ef397ab..0810678b5176 100644 --- a/libs/portals/admin/regulations-admin/src/components/DownloadDraftButton.tsx +++ b/libs/portals/admin/regulations-admin/src/components/DownloadDraftButton.tsx @@ -72,12 +72,13 @@ export const DownloadDraftButton = ({ draftId, reviewButton }: Props) => { }) .catch((error) => { console.error('Error occurred:', error) + toast.error(t(editorMsgs.signedDocumentDownloadFreshError)) }) .finally(() => { setIsFetchingFile(false) }) - } else { + } else if (data && !url) { toast.error(t(editorMsgs.signedDocumentDownloadFreshError)) } }, [data]) diff --git a/libs/react-spa/bff/src/lib/BffProvider.tsx b/libs/react-spa/bff/src/lib/BffProvider.tsx index 76fc6d99157a..f3251bd4dfde 100644 --- a/libs/react-spa/bff/src/lib/BffProvider.tsx +++ b/libs/react-spa/bff/src/lib/BffProvider.tsx @@ -33,11 +33,7 @@ export const BffProvider = ({ }) if (!res.ok) { - const qs = createQueryStr({ - target_link_uri: window.location.href, - }) - - window.location.href = bffUrlGenerator(`/login?${qs}`) + signIn() return } @@ -57,7 +53,11 @@ export const BffProvider = ({ } const signIn = useCallback(() => { - window.location.href = bffUrlGenerator('/login') + const qs = createQueryStr({ + target_link_uri: window.location.href, + }) + + window.location.href = bffUrlGenerator(`/login?${qs}`) }, [bffUrlGenerator]) const signOut = useCallback(() => { @@ -65,6 +65,10 @@ export const BffProvider = ({ return } + dispatch({ + type: ActionType.LOGGING_OUT, + }) + window.location.href = bffUrlGenerator( `/logout?sid=${state.userInfo.profile.sid}`, ) @@ -105,7 +109,10 @@ export const BffProvider = ({ const { authState } = state const showErrorScreen = authState === 'error' - const showLoadingScreen = authState === 'loading' || authState === 'switching' + const showLoadingScreen = + authState === 'loading' || + authState === 'switching' || + authState === 'logging-out' const isLoggedIn = authState === 'logged-in' return ( diff --git a/libs/react-spa/bff/src/lib/bff.state.ts b/libs/react-spa/bff/src/lib/bff.state.ts index c433278aa811..3fa4579791d4 100644 --- a/libs/react-spa/bff/src/lib/bff.state.ts +++ b/libs/react-spa/bff/src/lib/bff.state.ts @@ -24,7 +24,6 @@ export interface BffReducerState { userInfo: BffUser | null authState: BffState isAuthenticated: boolean - baseUrl?: string error?: Error } diff --git a/libs/react-spa/bff/src/lib/bff.utils.ts b/libs/react-spa/bff/src/lib/bff.utils.ts index dd47a3589b9f..078461d28a98 100644 --- a/libs/react-spa/bff/src/lib/bff.utils.ts +++ b/libs/react-spa/bff/src/lib/bff.utils.ts @@ -2,15 +2,16 @@ * Creates a function that can generate a BFF URLs based on the environment. * @usage * const bffBaseUrl = createBffUrlGenerator('/stjornbord) - * const userUrl = bffBaseUrl('/user') + * const userUrl = bffBaseUrl('/user') // http://localhost:3010/stjornbord/bff/user */ export const createBffUrlGenerator = (basePath: string) => { - // Trim any leading and trailing slashes from the basePath to avoid extra slashes const sanitizedBasePath = sanitizePath(basePath) const origin = process.env.NODE_ENV === 'development' - ? 'http://localhost:3010' // When developing against the BFF locally, use localhost - : sanitizePath(window.location.origin) // Use current window origin for production + ? // When developing against the BFF locally, use localhost + 'http://localhost:3010' + : // Use current window origin for production + sanitizePath(window.location.origin) const baseUrl = `${origin}/${sanitizedBasePath}/bff` @@ -22,6 +23,9 @@ export const createBffUrlGenerator = (basePath: string) => { */ const sanitizePath = (path: string) => path.replace(/^\/+|\/+$/g, '') +/** + * Creates a query string from an object + */ export const createQueryStr = (params: Record) => { return new URLSearchParams(params).toString() } From 188081d0570bd4b6fa7a55e65e80669330611a2b Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Mon, 23 Sep 2024 10:18:21 +0000 Subject: [PATCH 035/248] Fix test and env cleanup --- apps/services/bff/src/app/bff.config.ts | 15 ++++++++++-- .../bff/src/app/modules/auth/auth.service.ts | 7 +++--- .../src/app/services/crypto.service.spec.ts | 24 +++++++------------ .../bff/src/app/services/crypto.service.ts | 10 ++++---- .../bff/src/environment/environment.schema.ts | 11 +-------- .../bff/src/environment/environment.ts | 14 +++++------ .../bff/src/utils/removeTrailingSlash.ts | 7 ++++++ 7 files changed, 43 insertions(+), 45 deletions(-) create mode 100644 apps/services/bff/src/utils/removeTrailingSlash.ts diff --git a/apps/services/bff/src/app/bff.config.ts b/apps/services/bff/src/app/bff.config.ts index b98f5f6c2623..32495e5e8df8 100644 --- a/apps/services/bff/src/app/bff.config.ts +++ b/apps/services/bff/src/app/bff.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from '@island.is/nest/config' import { authSchema } from '../environment/environment.schema' import { z } from 'zod' -import { isProduction, environment } from '../environment' +import { environment } from '../environment' const BffConfigSchema = z.object({ redis: z.object({ @@ -10,6 +10,7 @@ const BffConfigSchema = z.object({ ssl: z.boolean(), }), graphqlApiEndpont: z.string(), + clientBasePath: z.string(), auth: authSchema, tokenSecretBase64: z.string(), // Determines if the BFF should support the PAR (Pushed Authorization Requests) flow or normal login flow @@ -24,6 +25,10 @@ export const BffConfig = defineConfig({ return { parSupportEnabled: env.optional('BFF_PAR_SUPPORT_ENABLED') === 'true' || false, + clientBasePath: env.required('BFF_CLIENT_BASE_PATH'), + /** + * Our main GraphQL API endpoint + */ graphqlApiEndpont: env.required('BFF_PROXY_API_ENDPOINT'), redis: { nodes: env.requiredJSON('REDIS_URL_NODE_01', [ @@ -34,10 +39,16 @@ export const BffConfig = defineConfig({ 'localhost:7004', 'localhost:7005', ]), - ssl: isProduction, + ssl: environment.production, }, auth: environment.auth, + /** + * The base64 encoded secret used for encrypting and decrypting. + */ tokenSecretBase64: env.required('BFF_TOKEN_SECRET_BASE64'), + /** + * Allowed external API URLs that the BFF can proxy requests to + */ allowedExternalApiUrls: env.requiredJSON( 'BFF_ALLOWED_EXTERNAL_API_URLS', [ diff --git a/apps/services/bff/src/app/modules/auth/auth.service.ts b/apps/services/bff/src/app/modules/auth/auth.service.ts index bbe881bc6a76..d9c009e15118 100644 --- a/apps/services/bff/src/app/modules/auth/auth.service.ts +++ b/apps/services/bff/src/app/modules/auth/auth.service.ts @@ -21,6 +21,7 @@ import { CallbackLoginQuery } from './queries/callback-login.query' import { CallbackLogoutQuery } from './queries/callback-logout.query' import { LoginQuery } from './queries/login.query' import { LogoutQuery } from './queries/logout.query' +import { removeTrailingSlash } from '../../../utils/removeTrailingSlash' @Injectable() export class AuthService { @@ -76,9 +77,9 @@ export class AuthService { * Get the origin URL from the request headers and add the global prefix */ private getOriginUrl(req: Request) { - return `${(req.headers['origin'] || req.headers['referer'] || '') - // Remove trailing slash and add the client base path - .replace(/\/$/, '')}${environment.clientBasePath}` + return `${removeTrailingSlash( + req.headers['origin'] || req.headers['referer'] || '', + )}${this.config.clientBasePath}` } /** diff --git a/apps/services/bff/src/app/services/crypto.service.spec.ts b/apps/services/bff/src/app/services/crypto.service.spec.ts index fbc97a26afaa..5dabab7e9c6e 100644 --- a/apps/services/bff/src/app/services/crypto.service.spec.ts +++ b/apps/services/bff/src/app/services/crypto.service.spec.ts @@ -1,7 +1,6 @@ import type { Logger } from '@island.is/logging' import { LOGGER_PROVIDER } from '@island.is/logging' import { Test, TestingModule } from '@nestjs/testing' -import { BffConfig } from '../bff.config' import { CryptoService } from './crypto.service' const DECRYPTED_TEXT = 'Hello, World!' @@ -28,23 +27,16 @@ const mockLogger = { error: jest.fn(), } as unknown as Logger -const invalidConfig = { - tokenSecretBase64: 'shortkey', -} - -const validConfig = { - // A valid 32-byte base64 key - tokenSecretBase64: 'ABHlmq6Ic6Ihip4OnTa1MeUXtHFex8IT/mFZrjhsme0=', -} +const createModule = async (valid = true): Promise => { + process.env.BFF_TOKEN_SECRET_BASE64 = valid + ? // A valid 32-byte base64 key + 'ABHlmq6Ic6Ihip4OnTa1MeUXtHFex8IT/mFZrjhsme0=' + : 'invalid_key' -const createModule = async ( - config: Record, -): Promise => { return Test.createTestingModule({ providers: [ CryptoService, { provide: LOGGER_PROVIDER, useValue: mockLogger }, - { provide: BffConfig.KEY, useValue: config }, ], }).compile() } @@ -52,7 +44,7 @@ const createModule = async ( describe('CryptoService Constructor', () => { it('should throw an error if "tokenSecretBase64" is not 32 bytes long', async () => { try { - const module = await createModule(invalidConfig) + const module = await createModule(false) module.get(CryptoService) // Fail the test if no error is thrown fail('Expected constructor to throw an error, but it did not.') @@ -65,7 +57,7 @@ describe('CryptoService Constructor', () => { it('should not throw an error if "tokenSecretBase64" is 32 bytes long', async () => { try { - const module = await createModule(validConfig) + const module = await createModule() module.get(CryptoService) // No error means the test passes } catch (error) { @@ -78,7 +70,7 @@ describe('CryptoService', () => { let service: CryptoService beforeEach(async () => { - const module = await createModule(validConfig) + const module = await createModule() service = module.get(CryptoService) }) diff --git a/apps/services/bff/src/app/services/crypto.service.ts b/apps/services/bff/src/app/services/crypto.service.ts index b1470f98b21d..892325435fc2 100644 --- a/apps/services/bff/src/app/services/crypto.service.ts +++ b/apps/services/bff/src/app/services/crypto.service.ts @@ -5,9 +5,8 @@ import { Injectable, InternalServerErrorException, } from '@nestjs/common' -import { ConfigType } from '@nestjs/config' import * as crypto from 'crypto' -import { BffConfig } from '../bff.config' +import { requiredString } from '../../utils/env' @Injectable() export class CryptoService { @@ -17,12 +16,11 @@ export class CryptoService { constructor( @Inject(LOGGER_PROVIDER) private logger: Logger, - - @Inject(BffConfig.KEY) - private readonly config: ConfigType, ) { + const tokenSecretBase64 = requiredString('BFF_TOKEN_SECRET_BASE64') + // Decode from base64 to binary - this.key = Buffer.from(this.config.tokenSecretBase64, 'base64') + this.key = Buffer.from(tokenSecretBase64, 'base64') // Ensure the key is exactly 32 bytes (256 bits) long if (this.key.length !== 32) { diff --git a/apps/services/bff/src/environment/environment.schema.ts b/apps/services/bff/src/environment/environment.schema.ts index 84483dadce25..b9934df9ee94 100644 --- a/apps/services/bff/src/environment/environment.schema.ts +++ b/apps/services/bff/src/environment/environment.schema.ts @@ -1,4 +1,3 @@ -import { graphql } from 'graphql' import { z } from 'zod' export const authSchema = z.strictObject({ @@ -18,14 +17,6 @@ export const authSchema = z.strictObject({ export const environmentSchema = z.strictObject({ production: z.boolean(), port: z.number(), - /** - * The client base path - */ - clientBasePath: z.string(), - /** - * Our main GraphQL API endpoint - */ - graphqlApiEndpont: z.string(), /** * The global prefix for the API */ @@ -39,7 +30,7 @@ export const environmentSchema = z.strictObject({ serviceName: z.string(), }), /** - * Identity server configuration + * Ids and Bff auth configuration */ auth: authSchema, /** diff --git a/apps/services/bff/src/environment/environment.ts b/apps/services/bff/src/environment/environment.ts index 40af9fbc9a89..b027f5d0262a 100644 --- a/apps/services/bff/src/environment/environment.ts +++ b/apps/services/bff/src/environment/environment.ts @@ -1,21 +1,19 @@ import { requiredString, requiredStringArray } from '../utils/env' +import { removeTrailingSlash } from '../utils/removeTrailingSlash' import type { BffEnvironmentSchema } from './environment.schema' -export const isProduction = process.env.NODE_ENV === 'production' +const isProduction = process.env.NODE_ENV === 'production' const port = parseInt(process.env.PORT as string, 10) || 3010 -const callbacksBaseRedirectPath = requiredString('BFF_CALLBACKS_BASE_PATH') - // Remove trailing slash if present - .replace(/\/$/, '') - +const callbacksBaseRedirectPath = removeTrailingSlash( + requiredString('BFF_CALLBACKS_BASE_PATH'), +) const issuer = requiredString('IDENTITY_SERVER_ISSUER_URL') const logoutRedirectUri = requiredString('BFF_LOGOUT_REDIRECT_PATH') export const environment: BffEnvironmentSchema = { production: isProduction, - clientBasePath: requiredString('BFF_CLIENT_BASE_PATH'), - graphqlApiEndpont: requiredString('BFF_PROXY_API_ENDPOINT'), - globalPrefix: requiredString('BFF_API_URL_PREFIX'), + globalPrefix: `${requiredString('BFF_CLIENT_BASE_PATH')}/bff`, audit: { groupName: requiredString('AUDIT_GROUP_NAME'), defaultNamespace: '@island.is/bff', diff --git a/apps/services/bff/src/utils/removeTrailingSlash.ts b/apps/services/bff/src/utils/removeTrailingSlash.ts new file mode 100644 index 000000000000..c12435ab918f --- /dev/null +++ b/apps/services/bff/src/utils/removeTrailingSlash.ts @@ -0,0 +1,7 @@ +/** + * Remove trailing slash from a string + * + * @example + * removeTrailingSlash('https://example.com/') // 'https://example.com' + */ +export const removeTrailingSlash = (str: string) => str.replace(/\/$/, '') From f1f4d017f3d19518a4112b3c5819d9903a786c72 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Mon, 23 Sep 2024 11:24:03 +0000 Subject: [PATCH 036/248] Fix user menu test --- libs/shared/components/src/auth/UserMenu/UserMenu.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/libs/shared/components/src/auth/UserMenu/UserMenu.tsx b/libs/shared/components/src/auth/UserMenu/UserMenu.tsx index c1170406b04c..875b16169a25 100644 --- a/libs/shared/components/src/auth/UserMenu/UserMenu.tsx +++ b/libs/shared/components/src/auth/UserMenu/UserMenu.tsx @@ -1,5 +1,5 @@ import { Box, Hidden } from '@island.is/island-ui/core' -import { useAuth, useUserInfo } from '@island.is/react-spa/bff' +import { useAuth } from '@island.is/react-spa/bff' import { useEffect, useState } from 'react' import { UserButton } from './UserButton' import { UserDropdown } from './UserDropdown' @@ -29,8 +29,7 @@ export const UserMenu = ({ const [dropdownState, setDropdownState] = useState<'closed' | 'open'>( 'closed', ) - const { signOut, switchUser } = useAuth() - const user = useUserInfo() + const { signOut, switchUser, userInfo: user } = useAuth() const handleClick = () => { setDropdownState(dropdownState === 'open' ? 'closed' : 'open') From 03dfb1580ab493547c1ba218ab001b577a9625e4 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Mon, 23 Sep 2024 14:27:49 +0000 Subject: [PATCH 037/248] Updates to environment and config --- apps/api/src/app/environments/environment.ts | 9 ---- apps/api/src/main.ts | 4 -- apps/services/bff/infra/admin-portal.infra.ts | 2 + apps/services/bff/src/app/app.module.ts | 13 ++--- apps/services/bff/src/app/bff.config.ts | 51 ++++++++++++++++--- .../src/app/modules/auth/auth.controller.ts | 3 +- .../bff/src/app/modules/auth/auth.service.ts | 27 +++------- .../bff/src/app/modules/ids/ids.service.ts | 26 ++++++---- .../bff/src/environment/environment.schema.ts | 46 +++++++---------- .../bff/src/environment/environment.ts | 41 +++------------ apps/services/bff/src/main.ts | 2 +- 11 files changed, 99 insertions(+), 125 deletions(-) diff --git a/apps/api/src/app/environments/environment.ts b/apps/api/src/app/environments/environment.ts index 16b769f4cc68..2d5faa33b286 100644 --- a/apps/api/src/app/environments/environment.ts +++ b/apps/api/src/app/environments/environment.ts @@ -96,7 +96,6 @@ const prodConfig = () => ({ passphrase: process.env.ISLYKILL_SERVICE_PASSPHRASE, basePath: process.env.ISLYKILL_SERVICE_BASEPATH, }, - enableCors: undefined, }) const devConfig = () => ({ @@ -208,14 +207,6 @@ const devConfig = () => ({ passphrase: process.env.ISLYKILL_SERVICE_PASSPHRASE, basePath: process.env.ISLYKILL_SERVICE_BASEPATH, }, - enableCors: - process.env.BFF_LOCAL_DEVELOPMENT_CORS === 'true' - ? { - origin: ['http://localhost:3010'], - methods: ['POST'], - credentials: true, - } - : undefined, }) export const getConfig = diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 0b95dc41361d..5fa1ec05053d 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,6 +1,5 @@ import { bootstrap } from '@island.is/infra-nest-server' import { AppModule } from './app/app.module' -import { getConfig as config } from './app/environments' bootstrap({ appModule: AppModule, @@ -8,7 +7,4 @@ bootstrap({ port: 4444, stripNonClassValidatorInputs: false, jsonBodyLimit: '300kb', - ...(!config.production && { - enableCors: config.enableCors, - }), }) diff --git a/apps/services/bff/infra/admin-portal.infra.ts b/apps/services/bff/infra/admin-portal.infra.ts index 0a6310a1cf47..388016195f71 100644 --- a/apps/services/bff/infra/admin-portal.infra.ts +++ b/apps/services/bff/infra/admin-portal.infra.ts @@ -67,6 +67,8 @@ export const serviceSetup = (services: { // The secret should be a valid 32-byte base64 key. // Generate key example: `openssl rand -base64 32` BFF_TOKEN_SECRET_BASE64: '/k8s/services-bff/BFF_TOKEN_SECRET_BASE64', + IDENTITY_SERVER_CLIENT_SECRET: + '/k8s/services-bff/IDENTITY_SERVER_CLIENT_SECRET', }) .readiness('/health/check') .liveness('/liveness') diff --git a/apps/services/bff/src/app/app.module.ts b/apps/services/bff/src/app/app.module.ts index ebd139bc4e1c..5711d9e2d390 100644 --- a/apps/services/bff/src/app/app.module.ts +++ b/apps/services/bff/src/app/app.module.ts @@ -1,22 +1,17 @@ -import { AuthModule as BaseAuthModule } from '@island.is/auth-nest-tools' -import { AuditModule } from '@island.is/nest/audit' -import { ConfigModule, IdsClientConfig } from '@island.is/nest/config' +import { ConfigModule } from '@island.is/nest/config' import { Module } from '@nestjs/common' -import { environment } from '../environment' import { BffConfig } from './bff.config' import { AuthModule as AppAuthModule } from './modules/auth/auth.module' -import { UserModule } from './modules/user/user.module' -import { ProxyModule } from './modules/proxy/proxy.module' import { CacheModule } from './modules/cache/cache.module' +import { ProxyModule } from './modules/proxy/proxy.module' +import { UserModule } from './modules/user/user.module' @Module({ imports: [ - AuditModule.forRoot(environment.audit), - BaseAuthModule.register(environment.auth), CacheModule.register(), ConfigModule.forRoot({ isGlobal: true, - load: [IdsClientConfig, BffConfig], + load: [BffConfig], }), UserModule, AppAuthModule, diff --git a/apps/services/bff/src/app/bff.config.ts b/apps/services/bff/src/app/bff.config.ts index 32495e5e8df8..aeee01b521de 100644 --- a/apps/services/bff/src/app/bff.config.ts +++ b/apps/services/bff/src/app/bff.config.ts @@ -1,8 +1,15 @@ import { defineConfig } from '@island.is/nest/config' -import { authSchema } from '../environment/environment.schema' import { z } from 'zod' import { environment } from '../environment' +import { removeTrailingSlash } from '../utils/removeTrailingSlash' + +export const idsSchema = z.strictObject({ + issuer: z.string(), + clientId: z.string(), + scopes: z.string().array(), + secret: z.string(), +}) const BffConfigSchema = z.object({ redis: z.object({ @@ -10,22 +17,43 @@ const BffConfigSchema = z.object({ ssl: z.boolean(), }), graphqlApiEndpont: z.string(), - clientBasePath: z.string(), - auth: authSchema, + /** + * Bff client base URL + */ + clientBaseUrl: z.string(), + ids: idsSchema, + /** + * The base64 encoded secret used for encrypting and decrypting tokens. + */ tokenSecretBase64: z.string(), - // Determines if the BFF should support the PAR (Pushed Authorization Requests) flow or normal login flow + /** + * Determines if the BFF should support the PAR (Pushed Authorization Requests) flow or normal login flow + */ parSupportEnabled: z.boolean().optional(), + /** + * Allowed external API URLs that the BFF can proxy requests to + */ allowedExternalApiUrls: z.array(z.string()), + allowedRedirectUris: z.string().array(), + logoutRedirectUri: z.string(), + callbacksRedirectUris: z.strictObject({ + login: z.string(), + logout: z.string(), + }), }) export const BffConfig = defineConfig({ name: 'BffConfig', schema: BffConfigSchema, load(env) { + const callbacksBaseRedirectPath = removeTrailingSlash( + env.required('BFF_CALLBACKS_BASE_PATH'), + ) + return { parSupportEnabled: env.optional('BFF_PAR_SUPPORT_ENABLED') === 'true' || false, - clientBasePath: env.required('BFF_CLIENT_BASE_PATH'), + clientBaseUrl: env.required('BFF_CLIENT_BASE_URL'), /** * Our main GraphQL API endpoint */ @@ -41,7 +69,18 @@ export const BffConfig = defineConfig({ ]), ssl: environment.production, }, - auth: environment.auth, + ids: { + issuer: env.required('IDENTITY_SERVER_ISSUER_URL'), + clientId: env.required('IDENTITY_SERVER_CLIENT_ID'), + secret: env.required('IDENTITY_SERVER_CLIENT_SECRET'), + scopes: env.requiredJSON('IDENTITY_SERVER_CLIENT_SCOPES'), + }, + allowedRedirectUris: env.requiredJSON('BFF_ALLOWED_REDIRECT_URIS'), + callbacksRedirectUris: { + login: `${callbacksBaseRedirectPath}/login`, + logout: `${callbacksBaseRedirectPath}/logout`, + }, + logoutRedirectUri: env.required('BFF_LOGOUT_REDIRECT_PATH'), /** * The base64 encoded secret used for encrypting and decrypting. */ diff --git a/apps/services/bff/src/app/modules/auth/auth.controller.ts b/apps/services/bff/src/app/modules/auth/auth.controller.ts index 98e50c76e132..af0bd3740e02 100644 --- a/apps/services/bff/src/app/modules/auth/auth.controller.ts +++ b/apps/services/bff/src/app/modules/auth/auth.controller.ts @@ -22,12 +22,11 @@ export class AuthController { @Get('login') async login( - @Req() req: Request, @Res() res: Response, @Query(qsValidationPipe) query: LoginQuery, ): Promise { - return this.authService.login({ req, res, query }) + return this.authService.login({ res, query }) } @Get('callbacks/login') diff --git a/apps/services/bff/src/app/modules/auth/auth.service.ts b/apps/services/bff/src/app/modules/auth/auth.service.ts index d9c009e15118..9aa5865b0126 100644 --- a/apps/services/bff/src/app/modules/auth/auth.service.ts +++ b/apps/services/bff/src/app/modules/auth/auth.service.ts @@ -21,7 +21,6 @@ import { CallbackLoginQuery } from './queries/callback-login.query' import { CallbackLogoutQuery } from './queries/callback-logout.query' import { LoginQuery } from './queries/login.query' import { LogoutQuery } from './queries/logout.query' -import { removeTrailingSlash } from '../../../utils/removeTrailingSlash' @Injectable() export class AuthService { @@ -39,7 +38,7 @@ export class AuthService { private readonly idsService: IdsService, private readonly cryptoService: CryptoService, ) { - this.baseUrl = this.config.auth.issuer + this.baseUrl = this.config.ids.issuer } /** @@ -73,15 +72,6 @@ export class AuthService { return value } - /** - * Get the origin URL from the request headers and add the global prefix - */ - private getOriginUrl(req: Request) { - return `${removeTrailingSlash( - req.headers['origin'] || req.headers['referer'] || '', - )}${this.config.clientBasePath}` - } - /** * This method initiates the login flow. * It validates the target_link_uri and generates a unique session id, for a login attempt. @@ -90,18 +80,16 @@ export class AuthService { * The user is then redirected to the identity server login page. */ async login({ - req, res, query: { target_link_uri: targetLinkUri, login_hint: loginHint, prompt }, }: { - req: Request res: Response query: LoginQuery }) { // Validate targetLinkUri if it is provided if ( targetLinkUri && - !validateUri(targetLinkUri, this.config.auth.allowedRedirectUris) + !validateUri(targetLinkUri, this.config.allowedRedirectUris) ) { this.logger.error('Invalid target_link_uri provided:', targetLinkUri) @@ -117,14 +105,11 @@ export class AuthService { codeVerifier, ) - // Get the calling URL - const originUrl = this.getOriginUrl(req) - await this.cacheService.save({ key: this.cacheService.createSessionKeyType('attempt', sid), value: { // Fallback if targetLinkUri is not provided - originUrl, + originUrl: `${this.config.clientBaseUrl}${environment.keyPath}`, // Code verifier to be used in the callback codeVerifier, targetLinkUri: targetLinkUri, @@ -146,7 +131,7 @@ export class AuthService { searchParams = new URLSearchParams({ request_uri: parResponse.request_uri, - client_id: this.config.auth.clientId, + client_id: this.config.ids.clientId, }) } else { searchParams = new URLSearchParams( @@ -222,7 +207,7 @@ export class AuthService { const searchParams = new URLSearchParams({ id_token_hint: cachedTokenResponse.id_token, - post_logout_redirect_uri: this.config.auth.callbacksRedirectUris.logout, + post_logout_redirect_uri: this.config.callbacksRedirectUris.logout, state: encodeURIComponent(JSON.stringify({ sid })), }) @@ -259,6 +244,6 @@ export class AuthService { // Delete session cookie res.clearCookie('sid') - return res.redirect(environment.auth.logoutRedirectUri) + return res.redirect(this.config.logoutRedirectUri) } } diff --git a/apps/services/bff/src/app/modules/ids/ids.service.ts b/apps/services/bff/src/app/modules/ids/ids.service.ts index 324870be8be2..5befebeb32e3 100644 --- a/apps/services/bff/src/app/modules/ids/ids.service.ts +++ b/apps/services/bff/src/app/modules/ids/ids.service.ts @@ -4,8 +4,8 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common' import { ConfigType } from '@nestjs/config' import { BffConfig } from '../../bff.config' -import { ParResponse, TokenResponse } from './ids.types' import { CryptoService } from '../../services/crypto.service' +import { ParResponse, TokenResponse } from './ids.types' @Injectable() export class IdsService { @@ -20,7 +20,7 @@ export class IdsService { private readonly cryptoService: CryptoService, ) { - this.baseUrl = this.config.auth.issuer + this.baseUrl = this.config.ids.issuer } /** @@ -76,9 +76,10 @@ export class IdsService { login_hint?: string prompt?: string } { + const { ids } = this.config return { - client_id: this.config.auth.clientId, - redirect_uri: this.config.auth.callbacksRedirectUris.login, + client_id: ids.clientId, + redirect_uri: this.config.callbacksRedirectUris.login, response_type: 'code', response_mode: 'query', scope: [ @@ -86,7 +87,7 @@ export class IdsService { 'profile', // Allows us to get refresh tokens 'offline_access', - ...this.config.auth.scopes, + ...ids.scopes, ].join(' '), state: sid, code_challenge: codeChallenge, @@ -106,7 +107,7 @@ export class IdsService { prompt?: string }) { return this.postRequest('/connect/par', { - client_secret: this.config.auth.secret, + client_secret: this.config.ids.secret, ...this.getLoginSearchParams(args), }) } @@ -119,12 +120,14 @@ export class IdsService { code: string codeVerifier: string }) { + const { ids } = this.config + return this.postRequest('/connect/token', { grant_type: 'authorization_code', code, - client_secret: this.config.auth.secret, - client_id: this.config.auth.clientId, - redirect_uri: this.config.auth.callbacksRedirectUris.login, + client_secret: ids.secret, + client_id: ids.clientId, + redirect_uri: this.config.callbacksRedirectUris.login, code_verifier: codeVerifier, }) } @@ -134,12 +137,13 @@ export class IdsService { */ public async refreshToken(refreshToken: string) { const decryptedRefreshToken = this.cryptoService.decrypt(refreshToken) + const { ids } = this.config return this.postRequest('/connect/token', { grant_type: 'refresh_token', refresh_token: decryptedRefreshToken, - client_secret: this.config.auth.secret, - client_id: this.config.auth.clientId, + client_secret: ids.secret, + client_id: ids.clientId, }) } } diff --git a/apps/services/bff/src/environment/environment.schema.ts b/apps/services/bff/src/environment/environment.schema.ts index b9934df9ee94..33dd00bae951 100644 --- a/apps/services/bff/src/environment/environment.schema.ts +++ b/apps/services/bff/src/environment/environment.schema.ts @@ -1,38 +1,26 @@ import { z } from 'zod' -export const authSchema = z.strictObject({ - issuer: z.string(), - clientId: z.string(), - audience: z.string().array(), - scopes: z.string().array(), - allowedRedirectUris: z.string().array(), - secret: z.string(), - callbacksRedirectUris: z.strictObject({ - login: z.string(), - logout: z.string(), - }), - logoutRedirectUri: z.string(), -}) +const KEY_PATH_ENV_VAR = 'BFF_CLIENT_KEY_PATH' export const environmentSchema = z.strictObject({ - production: z.boolean(), - port: z.number(), + production: z.boolean().default(false), + port: z.preprocess( + (val) => (val ? parseInt(val as string, 10) : 3010), + z + .number({ required_error: 'PORT must be a valid number' }) + .min(1000) + .max(10000), + ), /** * The global prefix for the API */ - globalPrefix: z.string(), - /** - * Audit configuration - */ - audit: z.strictObject({ - defaultNamespace: z.string(), - groupName: z.string(), - serviceName: z.string(), - }), - /** - * Ids and Bff auth configuration - */ - auth: authSchema, + keyPath: z + .string({ + required_error: `${KEY_PATH_ENV_VAR} is required`, + }) + .refine((val) => !val.endsWith('/bff'), { + message: `${KEY_PATH_ENV_VAR} must not end with /bff`, + }), /** * Enable CORS configuration */ @@ -45,4 +33,4 @@ export const environmentSchema = z.strictObject({ .optional(), }) -export type BffEnvironmentSchema = z.infer +export type BffEnvironment = z.infer diff --git a/apps/services/bff/src/environment/environment.ts b/apps/services/bff/src/environment/environment.ts index b027f5d0262a..5505ee19d36d 100644 --- a/apps/services/bff/src/environment/environment.ts +++ b/apps/services/bff/src/environment/environment.ts @@ -1,45 +1,20 @@ -import { requiredString, requiredStringArray } from '../utils/env' -import { removeTrailingSlash } from '../utils/removeTrailingSlash' -import type { BffEnvironmentSchema } from './environment.schema' +import { BffEnvironment, environmentSchema } from './environment.schema' const isProduction = process.env.NODE_ENV === 'production' -const port = parseInt(process.env.PORT as string, 10) || 3010 -const callbacksBaseRedirectPath = removeTrailingSlash( - requiredString('BFF_CALLBACKS_BASE_PATH'), -) -const issuer = requiredString('IDENTITY_SERVER_ISSUER_URL') -const logoutRedirectUri = requiredString('BFF_LOGOUT_REDIRECT_PATH') - -export const environment: BffEnvironmentSchema = { +const parsedEnvironment = environmentSchema.parse({ production: isProduction, - globalPrefix: `${requiredString('BFF_CLIENT_BASE_PATH')}/bff`, - audit: { - groupName: requiredString('AUDIT_GROUP_NAME'), - defaultNamespace: '@island.is/bff', - serviceName: 'services-bff', - }, + port: process.env.PORT, + keyPath: process.env.BFF_CLIENT_KEY_PATH, ...(!isProduction && { enableCors: { // Allowed origin(s) - origin: ['http://localhost:4200', issuer], + origin: ['http://localhost:4200'], methods: ['GET', 'POST'], // Allow cookies and credentials to be sent credentials: true, }, }), - port, - auth: { - issuer, - clientId: requiredString('IDENTITY_SERVER_CLIENT_ID'), - secret: requiredString('IDENTITY_SERVER_CLIENT_SECRET'), - scopes: requiredStringArray('IDENTITY_SERVER_CLIENT_SCOPES'), - audience: requiredStringArray('IDENTITY_SERVER_AUDIENCE'), - allowedRedirectUris: requiredStringArray('BFF_ALLOWED_REDIRECT_URIS'), - callbacksRedirectUris: { - login: `${callbacksBaseRedirectPath}/login`, - logout: `${callbacksBaseRedirectPath}/logout`, - }, - logoutRedirectUri, - }, -} +}) + +export const environment: BffEnvironment = parsedEnvironment diff --git a/apps/services/bff/src/main.ts b/apps/services/bff/src/main.ts index 9df3b5a1d233..c9e3c8080c79 100644 --- a/apps/services/bff/src/main.ts +++ b/apps/services/bff/src/main.ts @@ -7,7 +7,7 @@ bootstrap({ appModule: AppModule, name: 'bff', port: environment.port, - globalPrefix: environment.globalPrefix, + globalPrefix: `${environment.keyPath}/bff`, ...(!environment.production && { enableCors: environment.enableCors, }), From 30589f86eaf9a0f7a3d66320d301e18da13eff0c Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Mon, 23 Sep 2024 14:32:12 +0000 Subject: [PATCH 038/248] Update infra allowed external api urls to be hard coded --- apps/services/bff/infra/admin-portal.infra.ts | 20 +++++++------------ .../bff/src/app/modules/auth/auth.service.ts | 2 +- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/apps/services/bff/infra/admin-portal.infra.ts b/apps/services/bff/infra/admin-portal.infra.ts index 388016195f71..e185a177cde7 100644 --- a/apps/services/bff/infra/admin-portal.infra.ts +++ b/apps/services/bff/infra/admin-portal.infra.ts @@ -1,9 +1,4 @@ -import { - json, - service, - ServiceBuilder, - ref, -} from '../../../../infra/src/dsl/dsl' +import { ServiceBuilder, json, service } from '../../../../infra/src/dsl/dsl' const generateWebBaseUrls = (path = '') => { if (!path.startsWith('/')) { @@ -17,10 +12,7 @@ const generateWebBaseUrls = (path = '') => { } } -export const serviceSetup = (services: { - servicesBffAdminPortal: ServiceBuilder<'services-bff-admin-portal'> - regulationsAdminBackend: ServiceBuilder<'regulations-admin-backend'> -}): ServiceBuilder<'services-bff-admin-portal'> => +export const serviceSetup = (): ServiceBuilder<'services-bff-admin-portal'> => service('services-bff-admin-portal') .namespace('services-bff') .image('services-bff') @@ -59,9 +51,11 @@ export const serviceSetup = (services: { BFF_LOGOUT_REDIRECT_PATH: generateWebBaseUrls(), BFF_PROXY_API_ENDPOINT: generateWebBaseUrls('/api/graphql'), BFF_API_URL_PREFIX: 'stjornbord/bff', - BFF_ALLOWED_EXTERNAL_API_URLS: json([ - ref((h) => `http://${h.svc(services.regulationsAdminBackend)}`), - ]), + BFF_ALLOWED_EXTERNAL_API_URLS: { + dev: json(['https://api.dev01.devland.is']), + staging: json(['https://api.staging01.devland.is']), + prod: json(['https://api.island.is']), + }, }) .secrets({ // The secret should be a valid 32-byte base64 key. diff --git a/apps/services/bff/src/app/modules/auth/auth.service.ts b/apps/services/bff/src/app/modules/auth/auth.service.ts index 9aa5865b0126..7f5df4421d10 100644 --- a/apps/services/bff/src/app/modules/auth/auth.service.ts +++ b/apps/services/bff/src/app/modules/auth/auth.service.ts @@ -2,7 +2,7 @@ import type { Logger } from '@island.is/logging' import { LOGGER_PROVIDER } from '@island.is/logging' import { BadRequestException, Inject, Injectable } from '@nestjs/common' import { ConfigType } from '@nestjs/config' -import { Request, Response } from 'express' +import { Response } from 'express' import { jwtDecode } from 'jwt-decode' import { IdTokenClaims } from '@island.is/shared/types' From 85946942b8d15e7fee764e7f59ce210f43062c7c Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Mon, 23 Sep 2024 14:38:23 +0000 Subject: [PATCH 039/248] Simplify client urls with bff postfix in it --- apps/portals/admin/src/graphql.ts | 8 +------- libs/react-spa/bff/src/lib/bff.utils.ts | 11 ++--------- 2 files changed, 3 insertions(+), 16 deletions(-) diff --git a/apps/portals/admin/src/graphql.ts b/apps/portals/admin/src/graphql.ts index 0352b26a7eac..9f31915d3166 100644 --- a/apps/portals/admin/src/graphql.ts +++ b/apps/portals/admin/src/graphql.ts @@ -7,15 +7,9 @@ import { } from '@apollo/client' import { onError } from '@apollo/client/link/error' import { RetryLink } from '@apollo/client/link/retry' -import { createBffUrlGenerator } from '@island.is/react-spa/bff' -import { AdminPortalPaths } from './lib/paths' - -const bffUrlGenerator = createBffUrlGenerator(AdminPortalPaths.Base) - -const uri = bffUrlGenerator('/api/graphql') const httpLink = new HttpLink({ - uri: ({ operationName }) => `${uri}?op=${operationName}`, + uri: ({ operationName }) => `/stjornbord/bff/api/graphql?op=${operationName}`, fetch, credentials: 'include', }) diff --git a/libs/react-spa/bff/src/lib/bff.utils.ts b/libs/react-spa/bff/src/lib/bff.utils.ts index 078461d28a98..5520da04d6c3 100644 --- a/libs/react-spa/bff/src/lib/bff.utils.ts +++ b/libs/react-spa/bff/src/lib/bff.utils.ts @@ -1,19 +1,12 @@ /** - * Creates a function that can generate a BFF URLs based on the environment. + * Creates a function that can generate a BFF URLs. * @usage * const bffBaseUrl = createBffUrlGenerator('/stjornbord) * const userUrl = bffBaseUrl('/user') // http://localhost:3010/stjornbord/bff/user */ export const createBffUrlGenerator = (basePath: string) => { const sanitizedBasePath = sanitizePath(basePath) - const origin = - process.env.NODE_ENV === 'development' - ? // When developing against the BFF locally, use localhost - 'http://localhost:3010' - : // Use current window origin for production - sanitizePath(window.location.origin) - - const baseUrl = `${origin}/${sanitizedBasePath}/bff` + const baseUrl = `${window.location.origin}/${sanitizedBasePath}/bff` return (relativePath = '') => `${baseUrl}${relativePath}` } From 6c29c257b29325022bac276f3ec33d69094ca947 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Mon, 23 Sep 2024 15:04:44 +0000 Subject: [PATCH 040/248] Add ingress to project and remove logout redirect path in favour of client base url --- apps/services/bff/infra/admin-portal.infra.ts | 29 +++++++++++++++++-- apps/services/bff/src/app/bff.config.ts | 2 -- .../bff/src/app/modules/auth/auth.service.ts | 2 +- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/apps/services/bff/infra/admin-portal.infra.ts b/apps/services/bff/infra/admin-portal.infra.ts index e185a177cde7..cbc85c7a49c8 100644 --- a/apps/services/bff/infra/admin-portal.infra.ts +++ b/apps/services/bff/infra/admin-portal.infra.ts @@ -47,10 +47,10 @@ export const serviceSetup = (): ServiceBuilder<'services-bff-admin-portal'> => '@admin.island.is/form-system:admin', ]), // BFF + BFF_CLIENT_KEY_PATH: '/stjornbord', BFF_CALLBACKS_BASE_PATH: generateWebBaseUrls('/stjornbord/bff/callbacks'), - BFF_LOGOUT_REDIRECT_PATH: generateWebBaseUrls(), + BFF_CLIENT_BASE_URL: generateWebBaseUrls(), BFF_PROXY_API_ENDPOINT: generateWebBaseUrls('/api/graphql'), - BFF_API_URL_PREFIX: 'stjornbord/bff', BFF_ALLOWED_EXTERNAL_API_URLS: { dev: json(['https://api.dev01.devland.is']), staging: json(['https://api.staging01.devland.is']), @@ -81,3 +81,28 @@ export const serviceSetup = (): ServiceBuilder<'services-bff-admin-portal'> => memory: '256Mi', }, }) + .ingress({ + primary: { + host: { + dev: ['beta'], + staging: ['beta'], + prod: ['', 'www.island.is'], + }, + extraAnnotations: { + dev: { + 'nginx.ingress.kubernetes.io/proxy-buffering': 'on', + 'nginx.ingress.kubernetes.io/proxy-buffer-size': '8k', + }, + staging: { + 'nginx.ingress.kubernetes.io/enable-global-auth': 'false', + 'nginx.ingress.kubernetes.io/proxy-buffering': 'on', + 'nginx.ingress.kubernetes.io/proxy-buffer-size': '8k', + }, + prod: { + 'nginx.ingress.kubernetes.io/proxy-buffering': 'on', + 'nginx.ingress.kubernetes.io/proxy-buffer-size': '8k', + }, + }, + paths: ['/stjornbord/bff'], + }, + }) diff --git a/apps/services/bff/src/app/bff.config.ts b/apps/services/bff/src/app/bff.config.ts index aeee01b521de..8dc5c4a13a51 100644 --- a/apps/services/bff/src/app/bff.config.ts +++ b/apps/services/bff/src/app/bff.config.ts @@ -35,7 +35,6 @@ const BffConfigSchema = z.object({ */ allowedExternalApiUrls: z.array(z.string()), allowedRedirectUris: z.string().array(), - logoutRedirectUri: z.string(), callbacksRedirectUris: z.strictObject({ login: z.string(), logout: z.string(), @@ -80,7 +79,6 @@ export const BffConfig = defineConfig({ login: `${callbacksBaseRedirectPath}/login`, logout: `${callbacksBaseRedirectPath}/logout`, }, - logoutRedirectUri: env.required('BFF_LOGOUT_REDIRECT_PATH'), /** * The base64 encoded secret used for encrypting and decrypting. */ diff --git a/apps/services/bff/src/app/modules/auth/auth.service.ts b/apps/services/bff/src/app/modules/auth/auth.service.ts index 7f5df4421d10..38f0398b177b 100644 --- a/apps/services/bff/src/app/modules/auth/auth.service.ts +++ b/apps/services/bff/src/app/modules/auth/auth.service.ts @@ -244,6 +244,6 @@ export class AuthService { // Delete session cookie res.clearCookie('sid') - return res.redirect(this.config.logoutRedirectUri) + return res.redirect(this.config.clientBaseUrl) } } From 752bc301590732e2044b441a0b8979bff783d8b9 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Mon, 23 Sep 2024 15:05:39 +0000 Subject: [PATCH 041/248] Add docker express to services bff --- apps/services/bff/project.json | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/apps/services/bff/project.json b/apps/services/bff/project.json index d0261857f0b3..9c67fb18b5e9 100644 --- a/apps/services/bff/project.json +++ b/apps/services/bff/project.json @@ -4,7 +4,9 @@ "sourceRoot": "apps/services/bff/src", "projectType": "application", "prefix": "services-bff", - "tags": ["scope:services-bff"], + "tags": [ + "scope:services-bff" + ], "targets": { "build": { "executor": "./tools/executors/node:build", @@ -21,7 +23,9 @@ "inspect": false } }, - "outputs": ["{options.outputPath}"] + "outputs": [ + "{options.outputPath}" + ] }, "serve": { "executor": "@nx/js:node", @@ -38,7 +42,9 @@ "jestConfig": "apps/services/bff/jest.config.ts", "runInBand": true }, - "outputs": ["{workspaceRoot}/coverage/apps/services/bff"] + "outputs": [ + "{workspaceRoot}/coverage/apps/services/bff" + ] }, "dev-services": { "executor": "nx:run-commands", @@ -50,9 +56,14 @@ "dev": { "executor": "nx:run-commands", "options": { - "commands": ["yarn start services-bff"], + "commands": [ + "yarn start services-bff" + ], "parallel": true } + }, + "docker-express": { + "executor": "Intentionally left blank, only so this target is valid when using `nx show projects --with-target docker-express`" } } } From 0af58c8f9ecf1f3f65b82b247288ac590d5132df Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Mon, 23 Sep 2024 15:08:06 +0000 Subject: [PATCH 042/248] update config simpler syntax --- apps/services/bff/src/app/bff.config.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/services/bff/src/app/bff.config.ts b/apps/services/bff/src/app/bff.config.ts index 8dc5c4a13a51..a4c76123d530 100644 --- a/apps/services/bff/src/app/bff.config.ts +++ b/apps/services/bff/src/app/bff.config.ts @@ -50,8 +50,7 @@ export const BffConfig = defineConfig({ ) return { - parSupportEnabled: - env.optional('BFF_PAR_SUPPORT_ENABLED') === 'true' || false, + parSupportEnabled: env.optionalJSON('BFF_PAR_SUPPORT_ENABLED') ?? false, clientBaseUrl: env.required('BFF_CLIENT_BASE_URL'), /** * Our main GraphQL API endpoint From 7b86f8e60f89f5bc2bfb2e2a86207ec978b7cb44 Mon Sep 17 00:00:00 2001 From: andes-it Date: Mon, 23 Sep 2024 15:22:36 +0000 Subject: [PATCH 043/248] chore: nx format:write update dirty files --- apps/services/bff/project.json | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/apps/services/bff/project.json b/apps/services/bff/project.json index 9c67fb18b5e9..f7639e9050f0 100644 --- a/apps/services/bff/project.json +++ b/apps/services/bff/project.json @@ -4,9 +4,7 @@ "sourceRoot": "apps/services/bff/src", "projectType": "application", "prefix": "services-bff", - "tags": [ - "scope:services-bff" - ], + "tags": ["scope:services-bff"], "targets": { "build": { "executor": "./tools/executors/node:build", @@ -23,9 +21,7 @@ "inspect": false } }, - "outputs": [ - "{options.outputPath}" - ] + "outputs": ["{options.outputPath}"] }, "serve": { "executor": "@nx/js:node", @@ -42,9 +38,7 @@ "jestConfig": "apps/services/bff/jest.config.ts", "runInBand": true }, - "outputs": [ - "{workspaceRoot}/coverage/apps/services/bff" - ] + "outputs": ["{workspaceRoot}/coverage/apps/services/bff"] }, "dev-services": { "executor": "nx:run-commands", @@ -56,9 +50,7 @@ "dev": { "executor": "nx:run-commands", "options": { - "commands": [ - "yarn start services-bff" - ], + "commands": ["yarn start services-bff"], "parallel": true } }, From 9bee7c7092dc643e184ffe4eb8d7736f65acda3a Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Mon, 23 Sep 2024 20:58:42 +0000 Subject: [PATCH 044/248] Update config and redis dev setup --- apps/services/bff/infra/admin-portal.infra.ts | 1 + apps/services/bff/src/app/bff.config.ts | 47 ++++++++++++------- .../bff/src/app/modules/auth/auth.service.ts | 2 +- .../bff/src/app/modules/cache/cache.module.ts | 16 ++----- .../bff/src/environment/environment.ts | 2 +- 5 files changed, 39 insertions(+), 29 deletions(-) diff --git a/apps/services/bff/infra/admin-portal.infra.ts b/apps/services/bff/infra/admin-portal.infra.ts index cbc85c7a49c8..b5b1e5fa16a6 100644 --- a/apps/services/bff/infra/admin-portal.infra.ts +++ b/apps/services/bff/infra/admin-portal.infra.ts @@ -50,6 +50,7 @@ export const serviceSetup = (): ServiceBuilder<'services-bff-admin-portal'> => BFF_CLIENT_KEY_PATH: '/stjornbord', BFF_CALLBACKS_BASE_PATH: generateWebBaseUrls('/stjornbord/bff/callbacks'), BFF_CLIENT_BASE_URL: generateWebBaseUrls(), + BFF_LOGOUT_REDIRECT_URI: generateWebBaseUrls(), BFF_PROXY_API_ENDPOINT: generateWebBaseUrls('/api/graphql'), BFF_ALLOWED_EXTERNAL_API_URLS: { dev: json(['https://api.dev01.devland.is']), diff --git a/apps/services/bff/src/app/bff.config.ts b/apps/services/bff/src/app/bff.config.ts index a4c76123d530..15ec16f0694c 100644 --- a/apps/services/bff/src/app/bff.config.ts +++ b/apps/services/bff/src/app/bff.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from '@island.is/nest/config' import { z } from 'zod' -import { environment } from '../environment' +import { isProduction } from '../environment' import { removeTrailingSlash } from '../utils/removeTrailingSlash' export const idsSchema = z.strictObject({ @@ -12,11 +12,19 @@ export const idsSchema = z.strictObject({ }) const BffConfigSchema = z.object({ - redis: z.object({ - nodes: z.array(z.string()), - ssl: z.boolean(), - }), + redis: z + .object({ + name: z.string(), + nodes: z.array(z.string()), + ssl: z.boolean(), + }) + // Only required in production + .optional(), graphqlApiEndpont: z.string(), + /** + * The URL to redirect to after logging out + */ + logoutRedirectUri: z.string(), /** * Bff client base URL */ @@ -48,25 +56,32 @@ export const BffConfig = defineConfig({ const callbacksBaseRedirectPath = removeTrailingSlash( env.required('BFF_CALLBACKS_BASE_PATH'), ) + // Redis nodes are only required in production + // In development, we can use a local Redis server or + // rely on the default in-memory cache provided by CacheModule + const redisNodes = env.optionalJSON('BFF_REDIS_URL_NODES') return { parSupportEnabled: env.optionalJSON('BFF_PAR_SUPPORT_ENABLED') ?? false, clientBaseUrl: env.required('BFF_CLIENT_BASE_URL'), + logoutRedirectUri: env.required('BFF_LOGOUT_REDIRECT_URI'), /** * Our main GraphQL API endpoint */ graphqlApiEndpont: env.required('BFF_PROXY_API_ENDPOINT'), - redis: { - nodes: env.requiredJSON('REDIS_URL_NODE_01', [ - 'localhost:7000', - 'localhost:7001', - 'localhost:7002', - 'localhost:7003', - 'localhost:7004', - 'localhost:7005', - ]), - ssl: environment.production, - }, + redis: isProduction + ? { + name: env.required('BFF_REDIS_NAME'), + nodes: env.requiredJSON('BFF_REDIS_URL_NODES'), + ssl: true, + } + : redisNodes + ? { + name: env.optional('BFF_REDIS_NAME') ?? 'unnamed-bff', + nodes: redisNodes, + ssl: false, + } + : undefined, ids: { issuer: env.required('IDENTITY_SERVER_ISSUER_URL'), clientId: env.required('IDENTITY_SERVER_CLIENT_ID'), diff --git a/apps/services/bff/src/app/modules/auth/auth.service.ts b/apps/services/bff/src/app/modules/auth/auth.service.ts index 38f0398b177b..7f5df4421d10 100644 --- a/apps/services/bff/src/app/modules/auth/auth.service.ts +++ b/apps/services/bff/src/app/modules/auth/auth.service.ts @@ -244,6 +244,6 @@ export class AuthService { // Delete session cookie res.clearCookie('sid') - return res.redirect(this.config.clientBaseUrl) + return res.redirect(this.config.logoutRedirectUri) } } diff --git a/apps/services/bff/src/app/modules/cache/cache.module.ts b/apps/services/bff/src/app/modules/cache/cache.module.ts index ccd96e595a42..dc3fe66967b9 100644 --- a/apps/services/bff/src/app/modules/cache/cache.module.ts +++ b/apps/services/bff/src/app/modules/cache/cache.module.ts @@ -11,20 +11,14 @@ import { CacheService } from './cache.service' export class CacheModule { static register(): DynamicModule { const imports = - process.env.NODE_ENV === 'test' || process.env.INIT_SCHEMA === 'true' + process.env.NODE_ENV === 'test' ? [NestCacheModule.register()] : [ NestCacheModule.registerAsync({ - useFactory: ({ - redis: { ssl, nodes }, - }: ConfigType) => ({ - store: redisInsStore( - createRedisCluster({ - name: 'bff', - ssl, - nodes, - }), - ), + useFactory: ({ redis }: ConfigType) => ({ + store: redis + ? redisInsStore(createRedisCluster(redis)) + : undefined, }), inject: [BffConfig.KEY], }), diff --git a/apps/services/bff/src/environment/environment.ts b/apps/services/bff/src/environment/environment.ts index 5505ee19d36d..8c44d8eb6ca2 100644 --- a/apps/services/bff/src/environment/environment.ts +++ b/apps/services/bff/src/environment/environment.ts @@ -1,6 +1,6 @@ import { BffEnvironment, environmentSchema } from './environment.schema' -const isProduction = process.env.NODE_ENV === 'production' +export const isProduction = process.env.NODE_ENV === 'production' const parsedEnvironment = environmentSchema.parse({ production: isProduction, From e653bb9d4d80192a3206279edb2e38421ac754db Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Tue, 24 Sep 2024 10:10:02 +0000 Subject: [PATCH 045/248] Update crypto service to include algorithm in the encryption, explain better in comments what encrypt/decrypt is doing and update crypto test to not use mock --- .../src/app/services/crypto.service.spec.ts | 45 ++++++++---------- .../bff/src/app/services/crypto.service.ts | 46 ++++++++++++++++--- apps/services/bff/src/utils/env.ts | 40 ---------------- 3 files changed, 60 insertions(+), 71 deletions(-) delete mode 100644 apps/services/bff/src/utils/env.ts diff --git a/apps/services/bff/src/app/services/crypto.service.spec.ts b/apps/services/bff/src/app/services/crypto.service.spec.ts index 5dabab7e9c6e..87528e308640 100644 --- a/apps/services/bff/src/app/services/crypto.service.spec.ts +++ b/apps/services/bff/src/app/services/crypto.service.spec.ts @@ -1,42 +1,37 @@ import type { Logger } from '@island.is/logging' import { LOGGER_PROVIDER } from '@island.is/logging' import { Test, TestingModule } from '@nestjs/testing' +import crypto from 'crypto' import { CryptoService } from './crypto.service' +import { BffConfig } from '../bff.config' const DECRYPTED_TEXT = 'Hello, World!' -const ENCRYPTED_TEXT = 'bW9ja2VkZGl' -// Mock the crypto module -jest.mock('crypto', () => { - const actualCrypto = jest.requireActual('crypto') - - return { - ...actualCrypto, - createCipheriv: jest.fn(() => ({ - update: jest.fn().mockReturnValue(ENCRYPTED_TEXT), - final: jest.fn().mockReturnValue(''), - })), - createDecipheriv: jest.fn(() => ({ - update: jest.fn().mockReturnValue(DECRYPTED_TEXT), - final: jest.fn().mockReturnValue(''), - })), - } +Object.defineProperty(global, 'crypto', { + value: { + getRandomValues: (arr: Buffer) => crypto.randomBytes(arr.length), + }, }) +const invalidConfig = { + tokenSecretBase64: 'shortkey', +} + +const validConfig = { + // A valid 32-byte base64 key + tokenSecretBase64: 'ABHlmq6Ic6Ihip4OnTa1MeUXtHFex8IT/mFZrjhsme0=', +} + const mockLogger = { error: jest.fn(), } as unknown as Logger -const createModule = async (valid = true): Promise => { - process.env.BFF_TOKEN_SECRET_BASE64 = valid - ? // A valid 32-byte base64 key - 'ABHlmq6Ic6Ihip4OnTa1MeUXtHFex8IT/mFZrjhsme0=' - : 'invalid_key' - +const createModule = async (config = validConfig): Promise => { return Test.createTestingModule({ providers: [ CryptoService, { provide: LOGGER_PROVIDER, useValue: mockLogger }, + { provide: BffConfig.KEY, useValue: config }, ], }).compile() } @@ -44,7 +39,7 @@ const createModule = async (valid = true): Promise => { describe('CryptoService Constructor', () => { it('should throw an error if "tokenSecretBase64" is not 32 bytes long', async () => { try { - const module = await createModule(false) + const module = await createModule(invalidConfig) module.get(CryptoService) // Fail the test if no error is thrown fail('Expected constructor to throw an error, but it did not.') @@ -77,9 +72,9 @@ describe('CryptoService', () => { describe('encrypt', () => { it('should encrypt and return a string containing IV and encrypted text', () => { const encryptedText = service.encrypt(DECRYPTED_TEXT) - const [ivBase64, encrypted] = encryptedText.split(':') + const [algorithm, ivBase64, encrypted] = encryptedText.split(':') - // Verify the length of the IV and the encrypted part + expect(algorithm).toEqual('aes-256-cbc') // IV in base64 (16 bytes) should be 24 characters long expect(ivBase64).toHaveLength(24) expect(encrypted.length).toBeGreaterThan(0) diff --git a/apps/services/bff/src/app/services/crypto.service.ts b/apps/services/bff/src/app/services/crypto.service.ts index 892325435fc2..84d2b1d33802 100644 --- a/apps/services/bff/src/app/services/crypto.service.ts +++ b/apps/services/bff/src/app/services/crypto.service.ts @@ -5,8 +5,9 @@ import { Injectable, InternalServerErrorException, } from '@nestjs/common' +import { ConfigType } from '@nestjs/config' import * as crypto from 'crypto' -import { requiredString } from '../../utils/env' +import { BffConfig } from '../bff.config' @Injectable() export class CryptoService { @@ -16,11 +17,12 @@ export class CryptoService { constructor( @Inject(LOGGER_PROVIDER) private logger: Logger, - ) { - const tokenSecretBase64 = requiredString('BFF_TOKEN_SECRET_BASE64') + @Inject(BffConfig.KEY) + private readonly config: ConfigType, + ) { // Decode from base64 to binary - this.key = Buffer.from(tokenSecretBase64, 'base64') + this.key = Buffer.from(this.config.tokenSecretBase64, 'base64') // Ensure the key is exactly 32 bytes (256 bits) long if (this.key.length !== 32) { @@ -32,16 +34,32 @@ export class CryptoService { /** * Encrypts a given text using the AES-256-CBC encryption algorithm. + * + * @param text The plain text to encrypt. * @returns IV encrypted text for decryption + * + * @example + * const encrypted = cryptoService.encrypt('Hello, World!') + * Output: 'aes-256-cbc:Ghs8TV5veHqJkGthWklAAw==:YWJjZGVmMTIzNDU2Nzg5MGFiY2RlZjEyMzQ1Njc4OTA=' */ encrypt(text: string): string { try { + // Generate a random 16-byte initialization vector (IV) for the encryption + // IV is a unique value used with the key to make each encryption unique, even with the same plaintext and key. const iv = crypto.randomBytes(16) + + // Create a Cipher object using the algorithm, encryption key, and initialization vector (IV) const cipher = crypto.createCipheriv(this.algorithm, this.key, iv) + + // Encrypt the text in 'utf8' format and encode the result as base64 let encrypted = cipher.update(text, 'utf8', 'base64') + + // Finalize the encryption, appending any remaining data encrypted += cipher.final('base64') - return `${iv.toString('base64')}:${encrypted}` + // Return the algorithm, IV, and the encrypted text, separated by colons + // The IV is used in decryption. + return `${this.algorithm}:${iv.toString('base64')}:${encrypted}` } catch (error) { this.logger.error('Error encrypting text:', error) @@ -51,16 +69,32 @@ export class CryptoService { /** * Decrypts a given text using the AES-256-CBC decryption algorithm. + * + * @param encryptedText The IV encrypted text to decrypt. * @returns The original plain text. + * + * @example + * const decrypted = cryptoService.decrypt('aes-256-cbc:Ghs8TV5veHqJkGthWklAAw==:YWJjZGVmMTIzNDU2Nzg5MGFiY2RlZjEyMzQ1Njc4OTA=') + * Output: 'Hello, World!' */ decrypt(encryptedText: string): string { try { - const [ivBase64, encrypted] = encryptedText.split(':') + // Split the input into the algorithm, IV (initialization vector), and the encrypted text + const [_algorithm, ivBase64, encrypted] = encryptedText.split(':') + + // Convert the base64-encoded IV back into a Buffer const iv = Buffer.from(ivBase64, 'base64') + + // Create a Decipher object using the same algorithm, key, and IV const decipher = crypto.createDecipheriv(this.algorithm, this.key, iv) + + // Decrypt the encrypted text from base64 to utf8 format let decrypted = decipher.update(encrypted, 'base64', 'utf8') + + // Finalize the decryption, appending any remaining data decrypted += decipher.final('utf8') + // Return the decrypted plaintext return decrypted } catch (error) { this.logger.error('Error decrypting text:', error) diff --git a/apps/services/bff/src/utils/env.ts b/apps/services/bff/src/utils/env.ts deleted file mode 100644 index 1ab6fa409451..000000000000 --- a/apps/services/bff/src/utils/env.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Validates that an environment variable is a string and returns it as value - */ -export const requiredString = (key: string): string => { - const value = process.env[key] - - if (value === undefined || value === null) { - throw new Error( - `Environment variable ${key} is required but was not found.`, - ) - } - - return value -} - -/** - * Validates that an environment variable is a JSON-stringified array of strings and returns it as value - */ -export const requiredStringArray = (key: string): string[] => { - const value = requiredString(key) - - try { - // Parse the JSON string into an array - const parsedArray = JSON.parse(value) - - // Ensure that the parsed value is an array of strings - if ( - !Array.isArray(parsedArray) || - !parsedArray.every((item) => typeof item === 'string') - ) { - throw new Error() - } - - return parsedArray - } catch (error) { - throw new Error( - `Environment variable ${key} is not a valid JSON-stringified array of strings`, - ) - } -} From 0bf3b4219a84d18d7e1e50b5402ee953745eea87 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Tue, 24 Sep 2024 10:30:32 +0000 Subject: [PATCH 046/248] Remove CORS entirely in favour of client proxy config --- .../services/bff/src/environment/environment.schema.ts | 10 ---------- apps/services/bff/src/environment/environment.ts | 9 --------- apps/services/bff/src/main.ts | 3 --- 3 files changed, 22 deletions(-) diff --git a/apps/services/bff/src/environment/environment.schema.ts b/apps/services/bff/src/environment/environment.schema.ts index 33dd00bae951..f1efb21fdd07 100644 --- a/apps/services/bff/src/environment/environment.schema.ts +++ b/apps/services/bff/src/environment/environment.schema.ts @@ -21,16 +21,6 @@ export const environmentSchema = z.strictObject({ .refine((val) => !val.endsWith('/bff'), { message: `${KEY_PATH_ENV_VAR} must not end with /bff`, }), - /** - * Enable CORS configuration - */ - enableCors: z - .object({ - origin: z.string().array(), - methods: z.enum(['GET', 'POST']).array(), - credentials: z.boolean().optional(), - }) - .optional(), }) export type BffEnvironment = z.infer diff --git a/apps/services/bff/src/environment/environment.ts b/apps/services/bff/src/environment/environment.ts index 8c44d8eb6ca2..64402a053c15 100644 --- a/apps/services/bff/src/environment/environment.ts +++ b/apps/services/bff/src/environment/environment.ts @@ -6,15 +6,6 @@ const parsedEnvironment = environmentSchema.parse({ production: isProduction, port: process.env.PORT, keyPath: process.env.BFF_CLIENT_KEY_PATH, - ...(!isProduction && { - enableCors: { - // Allowed origin(s) - origin: ['http://localhost:4200'], - methods: ['GET', 'POST'], - // Allow cookies and credentials to be sent - credentials: true, - }, - }), }) export const environment: BffEnvironment = parsedEnvironment diff --git a/apps/services/bff/src/main.ts b/apps/services/bff/src/main.ts index c9e3c8080c79..1f615d01932a 100644 --- a/apps/services/bff/src/main.ts +++ b/apps/services/bff/src/main.ts @@ -8,9 +8,6 @@ bootstrap({ name: 'bff', port: environment.port, globalPrefix: `${environment.keyPath}/bff`, - ...(!environment.production && { - enableCors: environment.enableCors, - }), healthCheck: { timeout: 1000, }, From 6fb7657554f4cff09e5a00f6deab66222bbffcbf Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Tue, 24 Sep 2024 16:00:03 +0000 Subject: [PATCH 047/248] Update error handling in bff backend, refactor infra and handle error query param in client --- apps/services/bff/infra/admin-portal.infra.ts | 37 +--- .../bff/infra/utils/createPortalEnv.ts | 39 ++++ .../bff/src/app/modules/auth/auth.service.ts | 205 ++++++++++++------ .../auth/queries/callback-login.query.ts | 20 +- .../src/app/modules/cache/cache.service.ts | 4 +- .../bff/src/app/modules/ids/ids.service.ts | 6 +- .../bff/src/app/modules/user/user.service.ts | 6 +- .../src/app/utils/create-error-query-str.ts | 17 ++ .../bff/src/app/utils/validate-uri.ts | 22 +- libs/react-spa/bff/src/lib/BffProvider.tsx | 22 +- libs/react-spa/bff/src/lib/bff.state.ts | 1 + 11 files changed, 247 insertions(+), 132 deletions(-) create mode 100644 apps/services/bff/infra/utils/createPortalEnv.ts create mode 100644 apps/services/bff/src/app/utils/create-error-query-str.ts diff --git a/apps/services/bff/infra/admin-portal.infra.ts b/apps/services/bff/infra/admin-portal.infra.ts index b5b1e5fa16a6..33e529d8c34e 100644 --- a/apps/services/bff/infra/admin-portal.infra.ts +++ b/apps/services/bff/infra/admin-portal.infra.ts @@ -1,16 +1,5 @@ import { ServiceBuilder, json, service } from '../../../../infra/src/dsl/dsl' - -const generateWebBaseUrls = (path = '') => { - if (!path.startsWith('/')) { - path = `/${path}` - } - - return { - dev: `https://beta.dev01.devland.is${path}`, - staging: `https://beta.staging01.devland.is${path}`, - prod: `https://island.is${path}`, - } -} +import { createPortalEnv } from './utils/createPortalEnv' export const serviceSetup = (): ServiceBuilder<'services-bff-admin-portal'> => service('services-bff-admin-portal') @@ -18,13 +7,7 @@ export const serviceSetup = (): ServiceBuilder<'services-bff-admin-portal'> => .image('services-bff') .redis() .env({ - // Idenity server - IDENTITY_SERVER_CLIENT_ID: '@admin.island.is/bff', - IDENTITY_SERVER_ISSUER_URL: { - dev: 'https://identity-server.dev01.devland.is', - staging: 'https://identity-server.staging01.devland.is', - prod: 'https://innskra.island.is', - }, + ...createPortalEnv('stjornbord'), IDENTITY_SERVER_CLIENT_SCOPES: json([ '@admin.island.is/delegation-system', '@admin.island.is/delegation-system:admin', @@ -46,24 +29,14 @@ export const serviceSetup = (): ServiceBuilder<'services-bff-admin-portal'> => '@admin.island.is/form-system', '@admin.island.is/form-system:admin', ]), - // BFF - BFF_CLIENT_KEY_PATH: '/stjornbord', - BFF_CALLBACKS_BASE_PATH: generateWebBaseUrls('/stjornbord/bff/callbacks'), - BFF_CLIENT_BASE_URL: generateWebBaseUrls(), - BFF_LOGOUT_REDIRECT_URI: generateWebBaseUrls(), - BFF_PROXY_API_ENDPOINT: generateWebBaseUrls('/api/graphql'), - BFF_ALLOWED_EXTERNAL_API_URLS: { - dev: json(['https://api.dev01.devland.is']), - staging: json(['https://api.staging01.devland.is']), - prod: json(['https://api.island.is']), - }, }) .secrets({ // The secret should be a valid 32-byte base64 key. // Generate key example: `openssl rand -base64 32` - BFF_TOKEN_SECRET_BASE64: '/k8s/services-bff/BFF_TOKEN_SECRET_BASE64', + BFF_TOKEN_SECRET_BASE64: + '/k8s/services-bff/admin-portal/BFF_TOKEN_SECRET_BASE64', IDENTITY_SERVER_CLIENT_SECRET: - '/k8s/services-bff/IDENTITY_SERVER_CLIENT_SECRET', + '/k8s/services-bff/admin-portal/IDENTITY_SERVER_CLIENT_SECRET', }) .readiness('/health/check') .liveness('/liveness') diff --git a/apps/services/bff/infra/utils/createPortalEnv.ts b/apps/services/bff/infra/utils/createPortalEnv.ts new file mode 100644 index 000000000000..413ecb7bf9e4 --- /dev/null +++ b/apps/services/bff/infra/utils/createPortalEnv.ts @@ -0,0 +1,39 @@ +type PortalKeys = 'stjornbord' | 'minarsidur' + +const defaultEnvUrls = { + dev: 'https://beta.dev01.devland.is', + staging: 'https://beta.staging01.devland.is', + prod: 'https://island.is', +} + +export const createPortalEnv = (key: PortalKeys) => { + return { + // Idenity server + IDENTITY_SERVER_CLIENT_ID: `@admin.island.is/bff-${key}`, + IDENTITY_SERVER_ISSUER_URL: { + dev: 'https://identity-server.dev01.devland.is', + staging: 'https://identity-server.staging01.devland.is', + prod: 'https://innskra.island.is', + }, + // BFF + BFF_ALLOWED_REDIRECT_URIS: defaultEnvUrls, + BFF_CLIENT_BASE_URL: defaultEnvUrls, + BFF_LOGOUT_REDIRECT_URI: defaultEnvUrls, + BFF_CLIENT_KEY_PATH: `/${key}`, + BFF_CALLBACKS_BASE_PATH: { + dev: `https://beta.dev01.devland.is/${key}/bff/callbacks`, + staging: `https://beta.staging01.devland.is/${key}/bff/callbacks`, + prod: `https://island.is/${key}/bff/callbacks`, + }, + BFF_PROXY_API_ENDPOINT: { + dev: 'https://beta.dev01.devland.is/api/graphql', + staging: 'https://beta.staging01.devland.is/api/graphql', + prod: 'https://island.is/api/graphql', + }, + BFF_ALLOWED_EXTERNAL_API_URLS: { + dev: 'https://api.dev01.devland.is', + staging: 'https://api.staging01.devland.is', + prod: 'https://api.island.is', + }, + } +} diff --git a/apps/services/bff/src/app/modules/auth/auth.service.ts b/apps/services/bff/src/app/modules/auth/auth.service.ts index 7f5df4421d10..1c77c874a864 100644 --- a/apps/services/bff/src/app/modules/auth/auth.service.ts +++ b/apps/services/bff/src/app/modules/auth/auth.service.ts @@ -11,6 +11,10 @@ import { uuid } from 'uuidv4' import { environment } from '../../../environment' import { BffConfig } from '../../bff.config' import { CryptoService } from '../../services/crypto.service' +import { + CreateErrorQueryStrArgs, + createErrorQueryStr, +} from '../../utils/create-error-query-str' import { validateUri } from '../../utils/validate-uri' import { CacheService } from '../cache/cache.service' import { IdsService } from '../ids/ids.service' @@ -41,6 +45,33 @@ export class AuthService { this.baseUrl = this.config.ids.issuer } + /** + * Creates the client base URL with the path appended. + */ + private createClientBaseUrl() { + return `${this.config.clientBaseUrl}${environment.keyPath}` + } + + /** + * Redirects the user to the client base URL with an error query string. + */ + private createClientBaseUrlWithError(args: CreateErrorQueryStrArgs) { + return `${this.createClientBaseUrl()}?${createErrorQueryStr(args)}` + } + + /** + * Redirects the user to the client base URL with an error query string. + */ + private redirectWithError( + res: Response, + args?: Partial, + ) { + const code = args?.code || 500 + const error = args?.error || 'Login failed!' + + return res.redirect(this.createClientBaseUrlWithError({ code, error })) + } + /** * Formats and updates the token cache with new token response data. */ @@ -93,60 +124,68 @@ export class AuthService { ) { this.logger.error('Invalid target_link_uri provided:', targetLinkUri) - throw new BadRequestException('Login failed') + return this.redirectWithError(res, { + code: 400, + }) } - // Generate a unique session id to be used in the login flow - const sid = uuid() - - // Generate a code verifier and code challenge to enhance security - const codeVerifier = await this.pkceService.generateCodeVerifier() - const codeChallenge = await this.pkceService.generateCodeChallenge( - codeVerifier, - ) + try { + // Generate a unique session id to be used in the login flow + const sid = uuid() - await this.cacheService.save({ - key: this.cacheService.createSessionKeyType('attempt', sid), - value: { - // Fallback if targetLinkUri is not provided - originUrl: `${this.config.clientBaseUrl}${environment.keyPath}`, - // Code verifier to be used in the callback + // Generate a code verifier and code challenge to enhance security + const codeVerifier = await this.pkceService.generateCodeVerifier() + const codeChallenge = await this.pkceService.generateCodeChallenge( codeVerifier, - targetLinkUri: targetLinkUri, - ...(loginHint && { loginHint }), - ...(prompt && { prompt }), - }, - ttl: 60 * 60 * 24 * 7 * 1000, // 1 week - }) - - let searchParams: URLSearchParams + ) - if (this.config.parSupportEnabled) { - const parResponse = await this.idsService.getPar({ - sid, - codeChallenge, - loginHint, - prompt, + await this.cacheService.save({ + key: this.cacheService.createSessionKeyType('attempt', sid), + value: { + // Fallback if targetLinkUri is not provided + originUrl: this.createClientBaseUrl(), + // Code verifier to be used in the callback + codeVerifier, + targetLinkUri: targetLinkUri, + ...(loginHint && { loginHint }), + ...(prompt && { prompt }), + }, + ttl: 60 * 60 * 24 * 7 * 1000, // 1 week }) - searchParams = new URLSearchParams({ - request_uri: parResponse.request_uri, - client_id: this.config.ids.clientId, - }) - } else { - searchParams = new URLSearchParams( - this.idsService.getLoginSearchParams({ + let searchParams: URLSearchParams + + if (this.config.parSupportEnabled) { + const parResponse = await this.idsService.getPar({ sid, codeChallenge, loginHint, prompt, - }), + }) + + searchParams = new URLSearchParams({ + request_uri: parResponse.request_uri, + client_id: this.config.ids.clientId, + }) + } else { + searchParams = new URLSearchParams( + this.idsService.getLoginSearchParams({ + sid, + codeChallenge, + loginHint, + prompt, + }), + ) + } + + return res.redirect( + `${this.baseUrl}/connect/authorize?${searchParams.toString()}`, ) - } + } catch (error) { + this.logger.error('Login failed: ', error) - return res.redirect( - `${this.baseUrl}/connect/authorize?${searchParams.toString()}`, - ) + return this.redirectWithError(res) + } } /** @@ -158,37 +197,67 @@ export class AuthService { * Finally, we redirect the user back to the original URL. */ async callbackLogin(res: Response, query: CallbackLoginQuery) { - // Get login attempt from cache - const loginAttemptData = await this.cacheService.get<{ - targetLinkUri?: string - loginHint?: string - codeVerifier: string - originUrl: string - }>(this.cacheService.createSessionKeyType('attempt', query.state)) - - // Get tokens and user information from the authorization code - const tokenResponse = await this.idsService.getTokens({ - code: query.code, - codeVerifier: loginAttemptData.codeVerifier, - }) + const idsError = query.invalid_request - const value = await this.updateTokenCache(tokenResponse) + // IDS might respond with an error if the request is missing a required parameter. + if (idsError) { + this.logger.error('Callback login IDS invalid request: ', idsError) - // Clean up the login attempt from the cache since we have a successful login. - await this.cacheService.delete( - this.cacheService.createSessionKeyType('attempt', query.state), - ) + return this.redirectWithError(res, { + code: 500, + error: idsError, + }) + } - // Create session cookie with successful login session id - res.cookie('sid', value.userProfile.sid, { - httpOnly: true, - secure: true, - sameSite: 'strict', - }) + // Validate query params + if (!query.code || !query.state) { + const missingParam = !query.code ? 'code' : 'state' + this.logger.error( + `Callback login failed: No query param "${missingParam}" provided.`, + ) - return res.redirect( - loginAttemptData.targetLinkUri || loginAttemptData.originUrl, - ) + return this.redirectWithError(res, { + code: 400, + }) + } + + try { + // Get login attempt from cache + const loginAttemptData = await this.cacheService.get<{ + targetLinkUri?: string + loginHint?: string + codeVerifier: string + originUrl: string + }>(this.cacheService.createSessionKeyType('attempt', query.state)) + + // Get tokens and user information from the authorization code + const tokenResponse = await this.idsService.getTokens({ + code: query.code, + codeVerifier: loginAttemptData.codeVerifier, + }) + + const value = await this.updateTokenCache(tokenResponse) + + // Clean up the login attempt from the cache since we have a successful login. + await this.cacheService.delete( + this.cacheService.createSessionKeyType('attempt', query.state), + ) + + // Create session cookie with successful login session id + res.cookie('sid', value.userProfile.sid, { + httpOnly: true, + secure: true, + sameSite: 'strict', + }) + + return res.redirect( + loginAttemptData.targetLinkUri || loginAttemptData.originUrl, + ) + } catch (error) { + this.logger.error('Callback login failed: ', error) + + return this.redirectWithError(res) + } } /** diff --git a/apps/services/bff/src/app/modules/auth/queries/callback-login.query.ts b/apps/services/bff/src/app/modules/auth/queries/callback-login.query.ts index 286a5c38091f..044e1268a793 100644 --- a/apps/services/bff/src/app/modules/auth/queries/callback-login.query.ts +++ b/apps/services/bff/src/app/modules/auth/queries/callback-login.query.ts @@ -1,15 +1,25 @@ -import { IsString } from 'class-validator' +import { IsOptional, IsString } from 'class-validator' export class CallbackLoginQuery { + @IsOptional() @IsString() - code!: string + code?: string + @IsOptional() @IsString() - scope!: string + scope?: string + @IsOptional() @IsString() - state!: string + state?: string + @IsOptional() @IsString() - session_state!: string + session_state?: string + + // IDS responds with an error if the request is invalid + // @see https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1 + @IsOptional() + @IsString() + invalid_request?: string } diff --git a/apps/services/bff/src/app/modules/cache/cache.service.ts b/apps/services/bff/src/app/modules/cache/cache.service.ts index 3dfe05cd5efd..9b0f0432971d 100644 --- a/apps/services/bff/src/app/modules/cache/cache.service.ts +++ b/apps/services/bff/src/app/modules/cache/cache.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Inject, Injectable } from '@nestjs/common' +import { Inject, Injectable } from '@nestjs/common' import { Cache as CacheManager } from 'cache-manager' import { CACHE_MANAGER } from '@nestjs/cache-manager' @@ -37,7 +37,7 @@ export class CacheService { const value = await this.cacheManager.get(key) if (!value) { - throw new BadRequestException('Not found') + throw new Error(`Cache key "${key}" not found.`) } return value as Value diff --git a/apps/services/bff/src/app/modules/ids/ids.service.ts b/apps/services/bff/src/app/modules/ids/ids.service.ts index 5befebeb32e3..942c986a1bb1 100644 --- a/apps/services/bff/src/app/modules/ids/ids.service.ts +++ b/apps/services/bff/src/app/modules/ids/ids.service.ts @@ -1,6 +1,6 @@ import type { Logger } from '@island.is/logging' import { LOGGER_PROVIDER } from '@island.is/logging' -import { BadRequestException, Inject, Injectable } from '@nestjs/common' +import { Inject, Injectable } from '@nestjs/common' import { ConfigType } from '@nestjs/config' import { BffConfig } from '../../bff.config' @@ -40,7 +40,7 @@ export class IdsService { }) if (!response.ok) { - throw new BadRequestException(`HTTP error! Status: ${response.status}`) + throw new Error(`HTTP error! Status: ${response.status}`) } return response.json() @@ -50,7 +50,7 @@ export class IdsService { JSON.stringify(error), ) - throw new BadRequestException(`Failed to fetch from ${endpoint}`) + throw new Error(`Failed to fetch from ${endpoint}`) } } diff --git a/apps/services/bff/src/app/modules/user/user.service.ts b/apps/services/bff/src/app/modules/user/user.service.ts index f08842b7d143..b5893076439f 100644 --- a/apps/services/bff/src/app/modules/user/user.service.ts +++ b/apps/services/bff/src/app/modules/user/user.service.ts @@ -47,10 +47,6 @@ export class UserService { this.cacheService.createSessionKeyType('current', sid), ) - if (!cachedTokenResponse.userProfile) { - throw new Error('userProfile not found in cache') - } - // Check if the access token is expired if (isExpired(cachedTokenResponse.accessTokenExp)) { // Get new token data with refresh token @@ -67,7 +63,7 @@ export class UserService { return this.formatUserResponse(cachedTokenResponse) } catch (error) { - this.logger.error('Error getting user from cache: ', error) + this.logger.error('Get user error: ', error) throw new UnauthorizedException() } diff --git a/apps/services/bff/src/app/utils/create-error-query-str.ts b/apps/services/bff/src/app/utils/create-error-query-str.ts new file mode 100644 index 000000000000..cf7a8b927e02 --- /dev/null +++ b/apps/services/bff/src/app/utils/create-error-query-str.ts @@ -0,0 +1,17 @@ +export type CreateErrorQueryStrArgs = { + code: number + error: string +} + +/** + * This utility function creates a query string with the bff_error and bff_error_description parameters + */ +export const createErrorQueryStr = ({ + code, + error, +}: CreateErrorQueryStrArgs) => { + return new URLSearchParams({ + bff_error_code: code.toString(), + bff_error_description: error, + }).toString() +} diff --git a/apps/services/bff/src/app/utils/validate-uri.ts b/apps/services/bff/src/app/utils/validate-uri.ts index 7ae32811df06..f044fb86de4a 100644 --- a/apps/services/bff/src/app/utils/validate-uri.ts +++ b/apps/services/bff/src/app/utils/validate-uri.ts @@ -1,20 +1,10 @@ /** * Validates the URI against allowed URIs to ensure blocking of external URLs. + * + * @param uri - The URI to validate. + * @param allowedUris - An array of allowed URIs. + * @returns True if the URI starts with any of the allowed URIs; otherwise, false. */ -export const validateUri = async (uri: string, allowedUris: string[]) => { - // Convert wildcard patterns to regular expressions - const regexPatterns = allowedUris.map((pattern) => { - // Escape special regex characters and replace '*' with a regex pattern to match any characters - const regexPattern = pattern - // Escape special characters for regex - .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - // Convert '*' to '.*' to match any characters - .replace(/\\\*/g, '.*') - - // Create a regex from the pattern and ensure it matches the entire URL - return new RegExp(`^${regexPattern}$`) - }) - - // Check if the URL matches any of the allowed patterns - return regexPatterns.some((regex) => regex.test(uri)) +export const validateUri = (uri: string, allowedUris: string[]) => { + return allowedUris.some((allowedUri) => uri.startsWith(allowedUri)) } diff --git a/libs/react-spa/bff/src/lib/BffProvider.tsx b/libs/react-spa/bff/src/lib/BffProvider.tsx index f3251bd4dfde..a36efc02cc41 100644 --- a/libs/react-spa/bff/src/lib/BffProvider.tsx +++ b/libs/react-spa/bff/src/lib/BffProvider.tsx @@ -99,8 +99,28 @@ export const BffProvider = ({ window.location.href = bffUrlGenerator(`/login?${qs}`) } + const checkQueryStringError = () => { + const urlParams = new URLSearchParams(window.location.search) + const error = urlParams.get('bff_error_code') + const errorDescription = urlParams.get('bff_error_description') + + if (error) { + dispatch({ + type: ActionType.ERROR, + payload: new Error(`${error}: ${errorDescription}`), + }) + } + + // Returns true if there is an error + return !!error + } + useEffectOnce(() => { - checkLogin() + const hasError = checkQueryStringError() + + if (!hasError) { + checkLogin() + } }) const onRetry = () => { diff --git a/libs/react-spa/bff/src/lib/bff.state.ts b/libs/react-spa/bff/src/lib/bff.state.ts index 3fa4579791d4..41d46fe480bc 100644 --- a/libs/react-spa/bff/src/lib/bff.state.ts +++ b/libs/react-spa/bff/src/lib/bff.state.ts @@ -94,6 +94,7 @@ export const reducer = ( case ActionType.ERROR: return withState({ + authState: 'error', error: action.payload, }) From e7915c64e1add7436cb33ca70ceb82a2c5c16dae Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Tue, 24 Sep 2024 20:42:55 +0000 Subject: [PATCH 048/248] When proxy service errors then handle as unauthorized. Update targetUrl to be defensive, i.e. no undefined possible. --- .../src/app/modules/proxy/proxy.service.ts | 46 ++++++++++--------- .../bff/src/app/services/crypto.service.ts | 4 +- 2 files changed, 26 insertions(+), 24 deletions(-) diff --git a/apps/services/bff/src/app/modules/proxy/proxy.service.ts b/apps/services/bff/src/app/modules/proxy/proxy.service.ts index 26280c629305..7333b13c27c9 100644 --- a/apps/services/bff/src/app/modules/proxy/proxy.service.ts +++ b/apps/services/bff/src/app/modules/proxy/proxy.service.ts @@ -48,20 +48,28 @@ export class ProxyService { throw new UnauthorizedException() } - let cachedTokenResponse = await this.cacheService.get( - this.cacheService.createSessionKeyType('current', sid), - ) - - if (isExpired(cachedTokenResponse.accessTokenExp)) { - const tokenResponse = await this.idsService.refreshToken( - cachedTokenResponse.refresh_token, - ) - cachedTokenResponse = await this.authService.updateTokenCache( - tokenResponse, - ) - } + try { + let cachedTokenResponse = + await this.cacheService.get( + this.cacheService.createSessionKeyType('current', sid), + ) + + if (isExpired(cachedTokenResponse.accessTokenExp)) { + const tokenResponse = await this.idsService.refreshToken( + cachedTokenResponse.refresh_token, + ) + + cachedTokenResponse = await this.authService.updateTokenCache( + tokenResponse, + ) + } + + return this.cryptoService.decrypt(cachedTokenResponse.access_token) + } catch (error) { + this.logger.error('Error getting access token:', error) - return this.cryptoService.decrypt(cachedTokenResponse.access_token) + throw new UnauthorizedException() + } } /** @@ -144,8 +152,9 @@ export class ProxyService { res: Response }): Promise { const accessToken = await this.getAccessToken(req) - const targetUrl = `${this.config.graphqlApiEndpont}?${ - req.url.split('?')[1] + const queryString = req.url.split('?')[1] + const targetUrl = `${this.config.graphqlApiEndpont}${ + queryString ? `?${queryString}` : '' }` this.executeStreamRequest({ @@ -178,19 +187,12 @@ export class ProxyService { } const accessToken = await this.getAccessToken(req) - const isDownloadService = url.includes('/download/v1/regulation') this.executeStreamRequest({ accessToken, targetUrl: url, req, res, - ...(isDownloadService && { - body: { - // The download service expects the accessToken to be passed in the body as "__accessToken". - __accessToken: accessToken, - }, - }), }) } } diff --git a/apps/services/bff/src/app/services/crypto.service.ts b/apps/services/bff/src/app/services/crypto.service.ts index 84d2b1d33802..2b64e70ab312 100644 --- a/apps/services/bff/src/app/services/crypto.service.ts +++ b/apps/services/bff/src/app/services/crypto.service.ts @@ -63,7 +63,7 @@ export class CryptoService { } catch (error) { this.logger.error('Error encrypting text:', error) - throw new InternalServerErrorException() + throw new Error('Failed to encrypt the text.') } } @@ -99,7 +99,7 @@ export class CryptoService { } catch (error) { this.logger.error('Error decrypting text:', error) - throw new InternalServerErrorException() + throw new Error('Failed to decrypt the text.') } } } From e709f2b57b3a71192dfe5e3c692790e68a029659 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Tue, 24 Sep 2024 20:51:29 +0000 Subject: [PATCH 049/248] Remove unnecessary Uint8Array conversion --- apps/services/bff/src/app/modules/auth/pkce.service.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/services/bff/src/app/modules/auth/pkce.service.ts b/apps/services/bff/src/app/modules/auth/pkce.service.ts index 2ddfca528c31..78de1d73f8c8 100644 --- a/apps/services/bff/src/app/modules/auth/pkce.service.ts +++ b/apps/services/bff/src/app/modules/auth/pkce.service.ts @@ -26,11 +26,8 @@ export class PKCEService { // Use Web Crypto API for async hashing const hashBuffer = await crypto.webcrypto.subtle.digest('SHA-256', data) - // Convert the buffer to a Uint8Array - const hashArray = new Uint8Array(hashBuffer) - // and then Base64 URL encode - return this.base64UrlEncode(Buffer.from(hashArray)) + return this.base64UrlEncode(Buffer.from(hashBuffer)) } /** From 7f869b44865717c6b97cca1c429a9931699ef29e Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Wed, 25 Sep 2024 10:10:41 +0000 Subject: [PATCH 050/248] Simplify the BFFUser object to not have dateOfBirth and remove double scope field which was due to backwards compatibility --- .../bff/src/app/modules/user/user.service.ts | 14 ++++---------- .../admin/application-system/src/module.tsx | 4 ++-- libs/react-spa/bff/src/lib/bff.hooks.ts | 3 --- libs/shared/types/src/lib/bff.ts | 6 +----- 4 files changed, 7 insertions(+), 20 deletions(-) diff --git a/apps/services/bff/src/app/modules/user/user.service.ts b/apps/services/bff/src/app/modules/user/user.service.ts index b5893076439f..2aa4ed9a6861 100644 --- a/apps/services/bff/src/app/modules/user/user.service.ts +++ b/apps/services/bff/src/app/modules/user/user.service.ts @@ -21,16 +21,10 @@ export class UserService { private readonly authService: AuthService, ) {} - private formatUserResponse(value: CachedTokenResponse): BffUser { + private mapToBffUser(value: CachedTokenResponse): BffUser { return { - scope: value.scope, scopes: value.scopes, - profile: { - ...value.userProfile, - ...(value.userProfile.birthdate && { - dateOfBirth: new Date(value.userProfile.birthdate), - }), - }, + profile: value.userProfile, } } @@ -58,10 +52,10 @@ export class UserService { const value: CachedTokenResponse = await this.authService.updateTokenCache(tokenResponse) - return this.formatUserResponse(value) + return this.mapToBffUser(value) } - return this.formatUserResponse(cachedTokenResponse) + return this.mapToBffUser(cachedTokenResponse) } catch (error) { this.logger.error('Get user error: ', error) diff --git a/libs/portals/admin/application-system/src/module.tsx b/libs/portals/admin/application-system/src/module.tsx index 908f7350de91..23cf6281bc34 100644 --- a/libs/portals/admin/application-system/src/module.tsx +++ b/libs/portals/admin/application-system/src/module.tsx @@ -19,7 +19,7 @@ const allowedScopes: string[] = [ AdminPortalScope.applicationSystemInstitution, ] const getScreen = ({ userInfo }: PortalModuleRoutesProps): React.ReactNode => { - if (userInfo.scope?.includes(AdminPortalScope.applicationSystemInstitution)) { + if (userInfo.scopes.includes(AdminPortalScope.applicationSystemInstitution)) { return } return @@ -51,7 +51,7 @@ export const applicationSystemAdminModule: PortalModule = { name: m.statistics, path: ApplicationSystemPaths.Statistics, element: , - enabled: props.userInfo.scope?.includes( + enabled: props.userInfo.scopes.includes( AdminPortalScope.applicationSystemAdmin, ), }, diff --git a/libs/react-spa/bff/src/lib/bff.hooks.ts b/libs/react-spa/bff/src/lib/bff.hooks.ts index ecea08039e64..ef5487a8201a 100644 --- a/libs/react-spa/bff/src/lib/bff.hooks.ts +++ b/libs/react-spa/bff/src/lib/bff.hooks.ts @@ -19,13 +19,11 @@ export const mapToBffUser = (input: User): BffUser => { delegationType, locale, }, - scope, scopes, } = input // Return a mapped BffUser object return { - scope: scope || '', scopes: scopes || [], profile: { sid: sid || '', @@ -36,7 +34,6 @@ export const mapToBffUser = (input: User): BffUser => { actor, subjectType, delegationType, - dateOfBirth: birthdate ? new Date(birthdate) : undefined, locale, }, } diff --git a/libs/shared/types/src/lib/bff.ts b/libs/shared/types/src/lib/bff.ts index 6d23f007a7db..436aa0ae8907 100644 --- a/libs/shared/types/src/lib/bff.ts +++ b/libs/shared/types/src/lib/bff.ts @@ -19,10 +19,6 @@ export interface IdTokenClaims { } export type BffUser = { - // User scope unparsed here for backwards compatibility - scope: string scopes: string[] - profile: IdTokenClaims & { - dateOfBirth?: Date - } + profile: IdTokenClaims } From 9c45bd97ebedb1a0e321fc61b38eff16f37882e0 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Wed, 25 Sep 2024 10:44:58 +0000 Subject: [PATCH 051/248] Update cookies to share constants, update options to be more secure --- apps/services/bff/src/app/bff.config.ts | 2 +- .../services/bff/src/app/constants/cookies.ts | 1 + .../bff/src/app/modules/auth/auth.service.ts | 25 +++++++++++++------ .../bff/src/app/modules/user/user.service.ts | 3 ++- .../{ => app}/utils/removeTrailingSlash.ts | 0 5 files changed, 22 insertions(+), 9 deletions(-) create mode 100644 apps/services/bff/src/app/constants/cookies.ts rename apps/services/bff/src/{ => app}/utils/removeTrailingSlash.ts (100%) diff --git a/apps/services/bff/src/app/bff.config.ts b/apps/services/bff/src/app/bff.config.ts index 15ec16f0694c..c6b5437a93ff 100644 --- a/apps/services/bff/src/app/bff.config.ts +++ b/apps/services/bff/src/app/bff.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from '@island.is/nest/config' import { z } from 'zod' import { isProduction } from '../environment' -import { removeTrailingSlash } from '../utils/removeTrailingSlash' +import { removeTrailingSlash } from './utils/removeTrailingSlash' export const idsSchema = z.strictObject({ issuer: z.string(), diff --git a/apps/services/bff/src/app/constants/cookies.ts b/apps/services/bff/src/app/constants/cookies.ts new file mode 100644 index 000000000000..51f8292b554f --- /dev/null +++ b/apps/services/bff/src/app/constants/cookies.ts @@ -0,0 +1 @@ +export const SESSION_COOKIE_NAME = 'sid' diff --git a/apps/services/bff/src/app/modules/auth/auth.service.ts b/apps/services/bff/src/app/modules/auth/auth.service.ts index 1c77c874a864..76e7c66af35a 100644 --- a/apps/services/bff/src/app/modules/auth/auth.service.ts +++ b/apps/services/bff/src/app/modules/auth/auth.service.ts @@ -2,7 +2,7 @@ import type { Logger } from '@island.is/logging' import { LOGGER_PROVIDER } from '@island.is/logging' import { BadRequestException, Inject, Injectable } from '@nestjs/common' import { ConfigType } from '@nestjs/config' -import { Response } from 'express' +import { CookieOptions, Response } from 'express' import { jwtDecode } from 'jwt-decode' import { IdTokenClaims } from '@island.is/shared/types' @@ -10,6 +10,7 @@ import omit from 'lodash/omit' import { uuid } from 'uuidv4' import { environment } from '../../../environment' import { BffConfig } from '../../bff.config' +import { SESSION_COOKIE_NAME } from '../../constants/cookies' import { CryptoService } from '../../services/crypto.service' import { CreateErrorQueryStrArgs, @@ -45,6 +46,16 @@ export class AuthService { this.baseUrl = this.config.ids.issuer } + private getCookieOptions(): CookieOptions { + return { + httpOnly: true, + secure: true, + // 'strict' (Maximum Security) The cookie will only be sent for requests originating from the same site (same domain and subdomain). + sameSite: 'strict', + path: environment.keyPath, + } + } + /** * Creates the client base URL with the path appended. */ @@ -244,11 +255,11 @@ export class AuthService { ) // Create session cookie with successful login session id - res.cookie('sid', value.userProfile.sid, { - httpOnly: true, - secure: true, - sameSite: 'strict', - }) + res.cookie( + SESSION_COOKIE_NAME, + value.userProfile.sid, + this.getCookieOptions(), + ) return res.redirect( loginAttemptData.targetLinkUri || loginAttemptData.originUrl, @@ -311,7 +322,7 @@ export class AuthService { await this.cacheService.delete(currentLoginCacheKey) // Delete session cookie - res.clearCookie('sid') + res.clearCookie(SESSION_COOKIE_NAME, this.getCookieOptions()) return res.redirect(this.config.logoutRedirectUri) } diff --git a/apps/services/bff/src/app/modules/user/user.service.ts b/apps/services/bff/src/app/modules/user/user.service.ts index 2aa4ed9a6861..63bdc49273ea 100644 --- a/apps/services/bff/src/app/modules/user/user.service.ts +++ b/apps/services/bff/src/app/modules/user/user.service.ts @@ -9,6 +9,7 @@ import { AuthService } from '../auth/auth.service' import { CachedTokenResponse } from '../auth/auth.types' import { CacheService } from '../cache/cache.service' import { IdsService } from '../ids/ids.service' +import { SESSION_COOKIE_NAME } from '../../constants/cookies' @Injectable() export class UserService { @@ -29,7 +30,7 @@ export class UserService { } public async getUser(req: Request): Promise { - const sid = req.cookies['sid'] + const sid = req.cookies[SESSION_COOKIE_NAME] if (!sid) { throw new UnauthorizedException() diff --git a/apps/services/bff/src/utils/removeTrailingSlash.ts b/apps/services/bff/src/app/utils/removeTrailingSlash.ts similarity index 100% rename from apps/services/bff/src/utils/removeTrailingSlash.ts rename to apps/services/bff/src/app/utils/removeTrailingSlash.ts From 203df7a8e9453b8a5927a57af1183d3064588755 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Wed, 25 Sep 2024 10:49:31 +0000 Subject: [PATCH 052/248] access token expire time latency by 5 sec --- apps/services/bff/src/app/modules/auth/auth.service.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/services/bff/src/app/modules/auth/auth.service.ts b/apps/services/bff/src/app/modules/auth/auth.service.ts index 76e7c66af35a..2c822f0a7974 100644 --- a/apps/services/bff/src/app/modules/auth/auth.service.ts +++ b/apps/services/bff/src/app/modules/auth/auth.service.ts @@ -99,9 +99,8 @@ export class AuthService { refresh_token: this.cryptoService.encrypt(tokenResponse.refresh_token), scopes: tokenResponse.scope.split(' '), userProfile, - accessTokenExp: new Date( - Date.now() + tokenResponse.expires_in * 1000, - ).getTime(), + // Subtract 5 seconds from the token expiration time to account for latency. + accessTokenExp: Date.now() + (tokenResponse.expires_in * 1000 - 5000), } // Save the tokenResponse to the cache From 615acb204b9bbeb713fcd0a3b67afe8f32019226 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Wed, 25 Sep 2024 10:51:08 +0000 Subject: [PATCH 053/248] remove omit --- apps/services/bff/src/app/modules/auth/auth.service.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/services/bff/src/app/modules/auth/auth.service.ts b/apps/services/bff/src/app/modules/auth/auth.service.ts index 2c822f0a7974..9e6b442be433 100644 --- a/apps/services/bff/src/app/modules/auth/auth.service.ts +++ b/apps/services/bff/src/app/modules/auth/auth.service.ts @@ -6,7 +6,6 @@ import { CookieOptions, Response } from 'express' import { jwtDecode } from 'jwt-decode' import { IdTokenClaims } from '@island.is/shared/types' -import omit from 'lodash/omit' import { uuid } from 'uuidv4' import { environment } from '../../../environment' import { BffConfig } from '../../bff.config' @@ -92,7 +91,7 @@ export class AuthService { const userProfile: IdTokenClaims = jwtDecode(tokenResponse.id_token) const value: CachedTokenResponse = { - ...omit(tokenResponse, ['access_token', 'refresh_token']), + ...tokenResponse, // Encrypt the access and refresh tokens before saving them to the cache // to prevent unauthorized access to the tokens if cached service is compromised. access_token: this.cryptoService.encrypt(tokenResponse.access_token), From 46c279a09813cbba89e76de83f8a170a248353de Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Wed, 25 Sep 2024 13:52:23 +0000 Subject: [PATCH 054/248] Update user profile cache ttl --- .../bff/infra/utils/createPortalEnv.ts | 3 +++ apps/services/bff/src/app/bff.config.ts | 18 ++++++++++++++++++ apps/services/bff/src/app/constants/time.ts | 4 ++++ .../bff/src/app/modules/auth/auth.service.ts | 6 ++++-- 4 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 apps/services/bff/src/app/constants/time.ts diff --git a/apps/services/bff/infra/utils/createPortalEnv.ts b/apps/services/bff/infra/utils/createPortalEnv.ts index 413ecb7bf9e4..8e766b399b9b 100644 --- a/apps/services/bff/infra/utils/createPortalEnv.ts +++ b/apps/services/bff/infra/utils/createPortalEnv.ts @@ -1,3 +1,5 @@ +import { DEFAULT_CACHE_USER_PROFILE_TTL_MS } from '../../src/app/constants/time' + type PortalKeys = 'stjornbord' | 'minarsidur' const defaultEnvUrls = { @@ -35,5 +37,6 @@ export const createPortalEnv = (key: PortalKeys) => { staging: 'https://api.staging01.devland.is', prod: 'https://api.island.is', }, + BFF_CACHE_USER_PROFILE_TTL_MS: DEFAULT_CACHE_USER_PROFILE_TTL_MS.toString(), } } diff --git a/apps/services/bff/src/app/bff.config.ts b/apps/services/bff/src/app/bff.config.ts index c6b5437a93ff..884630a78f58 100644 --- a/apps/services/bff/src/app/bff.config.ts +++ b/apps/services/bff/src/app/bff.config.ts @@ -3,6 +3,7 @@ import { defineConfig } from '@island.is/nest/config' import { z } from 'zod' import { isProduction } from '../environment' import { removeTrailingSlash } from './utils/removeTrailingSlash' +import { DEFAULT_CACHE_USER_PROFILE_TTL_MS } from './constants/time' export const idsSchema = z.strictObject({ issuer: z.string(), @@ -47,6 +48,16 @@ const BffConfigSchema = z.object({ login: z.string(), logout: z.string(), }), + /** + * Time-to-live (TTL) for caching the user profile, in milliseconds. + * This value determines how long the user profile will be cached before it is considered stale. + * + * Note: The TTL should be aligned with the lifespan of the Ids client refresh token + the Ids session lifetime. + * We also subtract 5 seconds from the TTL to handle latency and clock drift. + * + * @default 28800000 (8 hours) + 3600 (1 hour) - 5 seconds = 28803595 + */ + cacheUserProfileTTLms: z.number().default(DEFAULT_CACHE_USER_PROFILE_TTL_MS), }) export const BffConfig = defineConfig({ @@ -107,6 +118,13 @@ export const BffConfig = defineConfig({ 'http://localhost:3377/download/v1/*', ], ), + /** + * Time-to-live (TTL) for caching the user profile, in milliseconds. + */ + cacheUserProfileTTLms: env.requiredJSON( + 'BFF_CACHE_USER_PROFILE_TTL_MS', + DEFAULT_CACHE_USER_PROFILE_TTL_MS, + ), } }, }) diff --git a/apps/services/bff/src/app/constants/time.ts b/apps/services/bff/src/app/constants/time.ts new file mode 100644 index 000000000000..eb7f030208d3 --- /dev/null +++ b/apps/services/bff/src/app/constants/time.ts @@ -0,0 +1,4 @@ +export const FIVE_SECONDS_IN_MS = 5000 + +// (8 hours) + 3600 (1 hour) - 5 seconds = 28803595 +export const DEFAULT_CACHE_USER_PROFILE_TTL_MS = 28803595 diff --git a/apps/services/bff/src/app/modules/auth/auth.service.ts b/apps/services/bff/src/app/modules/auth/auth.service.ts index 9e6b442be433..76dab5115b0d 100644 --- a/apps/services/bff/src/app/modules/auth/auth.service.ts +++ b/apps/services/bff/src/app/modules/auth/auth.service.ts @@ -25,6 +25,7 @@ import { CallbackLoginQuery } from './queries/callback-login.query' import { CallbackLogoutQuery } from './queries/callback-logout.query' import { LoginQuery } from './queries/login.query' import { LogoutQuery } from './queries/logout.query' +import { FIVE_SECONDS_IN_MS } from '../../constants/time' @Injectable() export class AuthService { @@ -99,14 +100,15 @@ export class AuthService { scopes: tokenResponse.scope.split(' '), userProfile, // Subtract 5 seconds from the token expiration time to account for latency. - accessTokenExp: Date.now() + (tokenResponse.expires_in * 1000 - 5000), + accessTokenExp: + Date.now() + (tokenResponse.expires_in * 1000 - FIVE_SECONDS_IN_MS), } // Save the tokenResponse to the cache await this.cacheService.save({ key: this.cacheService.createSessionKeyType('current', userProfile.sid), value, - ttl: 60 * 60 * 1000, // 1 hour + ttl: this.config.cacheUserProfileTTLms, }) return value From c37b4adee823f18c4cf008bb6d255f8114ab0d08 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Wed, 25 Sep 2024 14:02:00 +0000 Subject: [PATCH 055/248] update cache ttl again and rename baseUrl to issuerUrl in ids service --- apps/services/bff/src/app/bff.config.ts | 4 ++-- apps/services/bff/src/app/constants/time.ts | 8 ++++++-- apps/services/bff/src/app/modules/ids/ids.service.ts | 6 +++--- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/apps/services/bff/src/app/bff.config.ts b/apps/services/bff/src/app/bff.config.ts index 884630a78f58..acef1455c51c 100644 --- a/apps/services/bff/src/app/bff.config.ts +++ b/apps/services/bff/src/app/bff.config.ts @@ -52,10 +52,10 @@ const BffConfigSchema = z.object({ * Time-to-live (TTL) for caching the user profile, in milliseconds. * This value determines how long the user profile will be cached before it is considered stale. * - * Note: The TTL should be aligned with the lifespan of the Ids client refresh token + the Ids session lifetime. + * Note: The TTL should be aligned with the lifespan of the Ids client refresh token. * We also subtract 5 seconds from the TTL to handle latency and clock drift. * - * @default 28800000 (8 hours) + 3600 (1 hour) - 5 seconds = 28803595 + * @default 1 hour - 5 seconds = 359995000ms */ cacheUserProfileTTLms: z.number().default(DEFAULT_CACHE_USER_PROFILE_TTL_MS), }) diff --git a/apps/services/bff/src/app/constants/time.ts b/apps/services/bff/src/app/constants/time.ts index eb7f030208d3..fc8db2e0baf2 100644 --- a/apps/services/bff/src/app/constants/time.ts +++ b/apps/services/bff/src/app/constants/time.ts @@ -1,4 +1,8 @@ export const FIVE_SECONDS_IN_MS = 5000 -// (8 hours) + 3600 (1 hour) - 5 seconds = 28803595 -export const DEFAULT_CACHE_USER_PROFILE_TTL_MS = 28803595 +export const ONE_HOUR_IN_MS = 3600000 + +// Time-to-live (TTL) for caching the user profile, in milliseconds. +// We subtract 5 seconds from the TTL to handle latency and clock drift. +export const DEFAULT_CACHE_USER_PROFILE_TTL_MS = + ONE_HOUR_IN_MS - FIVE_SECONDS_IN_MS diff --git a/apps/services/bff/src/app/modules/ids/ids.service.ts b/apps/services/bff/src/app/modules/ids/ids.service.ts index 942c986a1bb1..46fa2d944386 100644 --- a/apps/services/bff/src/app/modules/ids/ids.service.ts +++ b/apps/services/bff/src/app/modules/ids/ids.service.ts @@ -9,7 +9,7 @@ import { ParResponse, TokenResponse } from './ids.types' @Injectable() export class IdsService { - private readonly baseUrl + private readonly issuerUrl constructor( @Inject(LOGGER_PROVIDER) @@ -20,7 +20,7 @@ export class IdsService { private readonly cryptoService: CryptoService, ) { - this.baseUrl = this.config.ids.issuer + this.issuerUrl = this.config.ids.issuer } /** @@ -31,7 +31,7 @@ export class IdsService { body: Record, ): Promise { try { - const response = await fetch(`${this.baseUrl}${endpoint}`, { + const response = await fetch(`${this.issuerUrl}${endpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', From 536f8b97c9ac17414143e2f3bb966c83a63f0f48 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Wed, 25 Sep 2024 14:04:32 +0000 Subject: [PATCH 056/248] reaname var --- apps/services/bff/src/app/modules/auth/auth.service.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/services/bff/src/app/modules/auth/auth.service.ts b/apps/services/bff/src/app/modules/auth/auth.service.ts index 76dab5115b0d..6ea2700b1ab6 100644 --- a/apps/services/bff/src/app/modules/auth/auth.service.ts +++ b/apps/services/bff/src/app/modules/auth/auth.service.ts @@ -142,7 +142,7 @@ export class AuthService { try { // Generate a unique session id to be used in the login flow - const sid = uuid() + const loginId = uuid() // Generate a code verifier and code challenge to enhance security const codeVerifier = await this.pkceService.generateCodeVerifier() @@ -151,7 +151,7 @@ export class AuthService { ) await this.cacheService.save({ - key: this.cacheService.createSessionKeyType('attempt', sid), + key: this.cacheService.createSessionKeyType('attempt', loginId), value: { // Fallback if targetLinkUri is not provided originUrl: this.createClientBaseUrl(), @@ -168,7 +168,7 @@ export class AuthService { if (this.config.parSupportEnabled) { const parResponse = await this.idsService.getPar({ - sid, + sid: loginId, codeChallenge, loginHint, prompt, @@ -181,7 +181,7 @@ export class AuthService { } else { searchParams = new URLSearchParams( this.idsService.getLoginSearchParams({ - sid, + sid: loginId, codeChallenge, loginHint, prompt, From 95bbb67b339bbe86ff8b9cdd0ce3f4013fc6db16 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Wed, 25 Sep 2024 14:07:32 +0000 Subject: [PATCH 057/248] remove params from cache attempt that where not used in the callback --- apps/services/bff/src/app/modules/auth/auth.service.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/services/bff/src/app/modules/auth/auth.service.ts b/apps/services/bff/src/app/modules/auth/auth.service.ts index 6ea2700b1ab6..95c372b56cf0 100644 --- a/apps/services/bff/src/app/modules/auth/auth.service.ts +++ b/apps/services/bff/src/app/modules/auth/auth.service.ts @@ -157,9 +157,7 @@ export class AuthService { originUrl: this.createClientBaseUrl(), // Code verifier to be used in the callback codeVerifier, - targetLinkUri: targetLinkUri, - ...(loginHint && { loginHint }), - ...(prompt && { prompt }), + targetLinkUri, }, ttl: 60 * 60 * 24 * 7 * 1000, // 1 week }) @@ -236,7 +234,6 @@ export class AuthService { // Get login attempt from cache const loginAttemptData = await this.cacheService.get<{ targetLinkUri?: string - loginHint?: string codeVerifier: string originUrl: string }>(this.cacheService.createSessionKeyType('attempt', query.state)) From e65fb76b3edcff13159e2162bfe09c348b9fd202 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Wed, 25 Sep 2024 14:19:32 +0000 Subject: [PATCH 058/248] Clean up old session in login callback if it exists --- .../src/app/modules/auth/auth.controller.ts | 3 ++- .../bff/src/app/modules/auth/auth.service.ts | 24 ++++++++++++++++--- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/apps/services/bff/src/app/modules/auth/auth.controller.ts b/apps/services/bff/src/app/modules/auth/auth.controller.ts index af0bd3740e02..bc618f8fa32e 100644 --- a/apps/services/bff/src/app/modules/auth/auth.controller.ts +++ b/apps/services/bff/src/app/modules/auth/auth.controller.ts @@ -31,11 +31,12 @@ export class AuthController { @Get('callbacks/login') async callbackLogin( + @Req() req: Request, @Res() res: Response, @Query(qsValidationPipe) query: CallbackLoginQuery, ): Promise { - return this.authService.callbackLogin(res, query) + return this.authService.callbackLogin({ req, res, query }) } @Get('logout') diff --git a/apps/services/bff/src/app/modules/auth/auth.service.ts b/apps/services/bff/src/app/modules/auth/auth.service.ts index 95c372b56cf0..9797106ead1d 100644 --- a/apps/services/bff/src/app/modules/auth/auth.service.ts +++ b/apps/services/bff/src/app/modules/auth/auth.service.ts @@ -2,7 +2,7 @@ import type { Logger } from '@island.is/logging' import { LOGGER_PROVIDER } from '@island.is/logging' import { BadRequestException, Inject, Injectable } from '@nestjs/common' import { ConfigType } from '@nestjs/config' -import { CookieOptions, Response } from 'express' +import { CookieOptions, Request, Response } from 'express' import { jwtDecode } from 'jwt-decode' import { IdTokenClaims } from '@island.is/shared/types' @@ -10,6 +10,7 @@ import { uuid } from 'uuidv4' import { environment } from '../../../environment' import { BffConfig } from '../../bff.config' import { SESSION_COOKIE_NAME } from '../../constants/cookies' +import { FIVE_SECONDS_IN_MS } from '../../constants/time' import { CryptoService } from '../../services/crypto.service' import { CreateErrorQueryStrArgs, @@ -25,7 +26,6 @@ import { CallbackLoginQuery } from './queries/callback-login.query' import { CallbackLogoutQuery } from './queries/callback-logout.query' import { LoginQuery } from './queries/login.query' import { LogoutQuery } from './queries/logout.query' -import { FIVE_SECONDS_IN_MS } from '../../constants/time' @Injectable() export class AuthService { @@ -205,7 +205,15 @@ export class AuthService { * We then save the tokens as well as decoded id token to the cache and create a session cookie. * Finally, we redirect the user back to the original URL. */ - async callbackLogin(res: Response, query: CallbackLoginQuery) { + async callbackLogin({ + req, + res, + query, + }: { + req: Request + res: Response + query: CallbackLoginQuery + }) { const idsError = query.invalid_request // IDS might respond with an error if the request is missing a required parameter. @@ -251,6 +259,16 @@ export class AuthService { this.cacheService.createSessionKeyType('attempt', query.state), ) + // Check if there is an old session cookie + const oldSessionCookie = req.cookies[SESSION_COOKIE_NAME] + + if (oldSessionCookie) { + // Clean up the old session from the cache + await this.cacheService.delete( + this.cacheService.createSessionKeyType('current', oldSessionCookie), + ) + } + // Create session cookie with successful login session id res.cookie( SESSION_COOKIE_NAME, From 609ce8469c8328bfcf65db7868bffae3e9cb22e0 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Wed, 25 Sep 2024 20:50:56 +0000 Subject: [PATCH 059/248] Fix login callback cache clean up and revoke refresh token --- apps/services/bff/src/app/constants/time.ts | 6 +- .../bff/src/app/modules/auth/auth.service.ts | 74 +++++++++++++------ .../src/app/modules/cache/cache.service.ts | 18 ++++- .../bff/src/app/modules/ids/ids.service.ts | 62 +++++++++++++--- .../bff/src/environment/environment.schema.ts | 1 + .../bff/src/environment/environment.ts | 1 + 6 files changed, 124 insertions(+), 38 deletions(-) diff --git a/apps/services/bff/src/app/constants/time.ts b/apps/services/bff/src/app/constants/time.ts index fc8db2e0baf2..78c7e347e1dc 100644 --- a/apps/services/bff/src/app/constants/time.ts +++ b/apps/services/bff/src/app/constants/time.ts @@ -1,6 +1,6 @@ -export const FIVE_SECONDS_IN_MS = 5000 - -export const ONE_HOUR_IN_MS = 3600000 +export const FIVE_SECONDS_IN_MS = 5 * 1000 +export const ONE_HOUR_IN_MS = 60 * 60 * 1000 +export const ONE_WEEK_IN_MS = ONE_HOUR_IN_MS * 24 * 7 // Time-to-live (TTL) for caching the user profile, in milliseconds. // We subtract 5 seconds from the TTL to handle latency and clock drift. diff --git a/apps/services/bff/src/app/modules/auth/auth.service.ts b/apps/services/bff/src/app/modules/auth/auth.service.ts index 9797106ead1d..6df07f687e06 100644 --- a/apps/services/bff/src/app/modules/auth/auth.service.ts +++ b/apps/services/bff/src/app/modules/auth/auth.service.ts @@ -10,7 +10,7 @@ import { uuid } from 'uuidv4' import { environment } from '../../../environment' import { BffConfig } from '../../bff.config' import { SESSION_COOKIE_NAME } from '../../constants/cookies' -import { FIVE_SECONDS_IN_MS } from '../../constants/time' +import { FIVE_SECONDS_IN_MS, ONE_WEEK_IN_MS } from '../../constants/time' import { CryptoService } from '../../services/crypto.service' import { CreateErrorQueryStrArgs, @@ -50,8 +50,9 @@ export class AuthService { return { httpOnly: true, secure: true, - // 'strict' (Maximum Security) The cookie will only be sent for requests originating from the same site (same domain and subdomain). - sameSite: 'strict', + // The lax setting allows cookies to be sent on top-level navigations (such as redirects), + // while still providing some protection against CSRF attacks. + sameSite: 'lax', path: environment.keyPath, } } @@ -141,8 +142,9 @@ export class AuthService { } try { - // Generate a unique session id to be used in the login flow - const loginId = uuid() + // Generate a unique session id to be used as a login attempt, + // e.g. to store data in the cache with key 'attempt_sid' to be used in the callback login. + const attemptLoginId = uuid() // Generate a code verifier and code challenge to enhance security const codeVerifier = await this.pkceService.generateCodeVerifier() @@ -151,7 +153,7 @@ export class AuthService { ) await this.cacheService.save({ - key: this.cacheService.createSessionKeyType('attempt', loginId), + key: this.cacheService.createSessionKeyType('attempt', attemptLoginId), value: { // Fallback if targetLinkUri is not provided originUrl: this.createClientBaseUrl(), @@ -159,14 +161,14 @@ export class AuthService { codeVerifier, targetLinkUri, }, - ttl: 60 * 60 * 24 * 7 * 1000, // 1 week + ttl: ONE_WEEK_IN_MS, // 1 week }) let searchParams: URLSearchParams if (this.config.parSupportEnabled) { const parResponse = await this.idsService.getPar({ - sid: loginId, + sid: attemptLoginId, codeChallenge, loginHint, prompt, @@ -179,7 +181,7 @@ export class AuthService { } else { searchParams = new URLSearchParams( this.idsService.getLoginSearchParams({ - sid: loginId, + sid: attemptLoginId, codeChallenge, loginHint, prompt, @@ -202,7 +204,8 @@ export class AuthService { * This method is called from the identity server after the user has logged in * and the authorization code has been issued. * The authorization code is then exchanged for tokens. - * We then save the tokens as well as decoded id token to the cache and create a session cookie. + * We save the tokens and user information in the cache and create a session cookie. + * We also clean up cache keys not being used anymore. * Finally, we redirect the user back to the original URL. */ async callbackLogin({ @@ -252,29 +255,42 @@ export class AuthService { codeVerifier: loginAttemptData.codeVerifier, }) - const value = await this.updateTokenCache(tokenResponse) + const updatedTokenResponse = await this.updateTokenCache(tokenResponse) // Clean up the login attempt from the cache since we have a successful login. await this.cacheService.delete( this.cacheService.createSessionKeyType('attempt', query.state), ) - // Check if there is an old session cookie + // Create session cookie with successful login session id + res.cookie( + SESSION_COOKIE_NAME, + updatedTokenResponse.userProfile.sid, + this.getCookieOptions(), + ) + + // Check if there is an old session cookie and clean up the cache const oldSessionCookie = req.cookies[SESSION_COOKIE_NAME] - if (oldSessionCookie) { - // Clean up the old session from the cache + if ( + oldSessionCookie && + oldSessionCookie !== updatedTokenResponse.userProfile.sid + ) { + // Clean up the old session key from the cache await this.cacheService.delete( this.cacheService.createSessionKeyType('current', oldSessionCookie), ) - } - // Create session cookie with successful login session id - res.cookie( - SESSION_COOKIE_NAME, - value.userProfile.sid, - this.getCookieOptions(), - ) + // Revoke the refresh token on the identity server, since we have a new session + // We deliberately do not await this operation to make the login flow faster, + // since this operation is not critical part to await. + // If the operation fails, we log the error. + this.idsService + .revokeToken(updatedTokenResponse.refresh_token, 'refresh_token') + .catch((error) => { + this.logger.error('Failed to revoke refresh token:', error) + }) + } return res.redirect( loginAttemptData.targetLinkUri || loginAttemptData.originUrl, @@ -298,7 +314,21 @@ export class AuthService { ) const cachedTokenResponse = - await this.cacheService.get(currentLoginCacheKey) + await this.cacheService.get( + currentLoginCacheKey, + // Do not throw an error if the key is not found + false, + ) + + if (!cachedTokenResponse) { + this.logger.error( + `Logout failed: ${this.cacheService.createKeyError( + currentLoginCacheKey, + )}`, + ) + + return res.redirect(this.config.callbacksRedirectUris.login) + } const searchParams = new URLSearchParams({ id_token_hint: cachedTokenResponse.id_token, diff --git a/apps/services/bff/src/app/modules/cache/cache.service.ts b/apps/services/bff/src/app/modules/cache/cache.service.ts index 9b0f0432971d..8b45e7541b6a 100644 --- a/apps/services/bff/src/app/modules/cache/cache.service.ts +++ b/apps/services/bff/src/app/modules/cache/cache.service.ts @@ -10,6 +10,10 @@ export class CacheService { private readonly cacheManager: CacheManager, ) {} + public createKeyError(key: string) { + return `Cache key "${key}" not found.` + } + /** * Creates s unique key with session id. * Type is either 'attempt' or 'current'. @@ -33,11 +37,19 @@ export class CacheService { await this.cacheManager.set(key, value, ttl) } - public async get(key: string) { + /** + * Gets a value from the cache. + * + * @param key The key to get the value for. + * @param throwError If true, throws an error if the key is not found. + * + * @returns cache value + */ + public async get(key: string, throwError = true): Promise { const value = await this.cacheManager.get(key) - if (!value) { - throw new Error(`Cache key "${key}" not found.`) + if (!value && throwError) { + throw new Error(this.createKeyError(key)) } return value as Value diff --git a/apps/services/bff/src/app/modules/ids/ids.service.ts b/apps/services/bff/src/app/modules/ids/ids.service.ts index 46fa2d944386..5065e53302e8 100644 --- a/apps/services/bff/src/app/modules/ids/ids.service.ts +++ b/apps/services/bff/src/app/modules/ids/ids.service.ts @@ -6,9 +6,15 @@ import { ConfigType } from '@nestjs/config' import { BffConfig } from '../../bff.config' import { CryptoService } from '../../services/crypto.service' import { ParResponse, TokenResponse } from './ids.types' +import { + EnhancedFetchAPI, + createEnhancedFetch, +} from '@island.is/clients/middlewares' +import { environment } from '../../../environment' @Injectable() export class IdsService { + private readonly enhancedFetch: EnhancedFetchAPI private readonly issuerUrl constructor( @@ -21,6 +27,9 @@ export class IdsService { private readonly cryptoService: CryptoService, ) { this.issuerUrl = this.config.ids.issuer + this.enhancedFetch = createEnhancedFetch({ + name: `bff-${environment.name}-ids-serivce`, + }) } /** @@ -31,19 +40,24 @@ export class IdsService { body: Record, ): Promise { try { - const response = await fetch(`${this.issuerUrl}${endpoint}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', + const response = await this.enhancedFetch( + `${this.issuerUrl}${endpoint}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams(body).toString(), }, - body: new URLSearchParams(body).toString(), - }) + ) - if (!response.ok) { - throw new Error(`HTTP error! Status: ${response.status}`) + const contentType = response.headers.get('content-type') || '' + + if (contentType.includes('application/json')) { + return response.json() as Promise } - return response.json() + return response.text() as Promise } catch (error) { this.logger.error( `Error making request to ${endpoint}:`, @@ -112,7 +126,12 @@ export class IdsService { }) } - // Fetches tokens using the authorization code and code verifier + /** + * Fetches the tokens from the Ids + * + * @param obj.code - The code from the Ids + * @param obj.codeVerifier - The code verifier from the Ids + */ public async getTokens({ code, codeVerifier, @@ -134,6 +153,8 @@ export class IdsService { /** * Use the refresh token to get a new tokens + * + * @param refreshToken - The refresh token */ public async refreshToken(refreshToken: string) { const decryptedRefreshToken = this.cryptoService.decrypt(refreshToken) @@ -146,4 +167,25 @@ export class IdsService { client_id: ids.clientId, }) } + + /** + * This endpoint allows revoking access tokens (reference tokens only) and refresh token. + * + * @param token - The token to revoke + * @param tokenTypeHint - The type of token to revoke (access_token or refresh_token) + */ + public async revokeToken( + token: string, + tokenTypeHint: 'access_token' | 'refresh_token', + ) { + const decryptedToken = this.cryptoService.decrypt(token) + const { ids } = this.config + + return this.postRequest('/connect/revocation', { + token: decryptedToken, + token_type_hint: tokenTypeHint, + client_secret: ids.secret, + client_id: ids.clientId, + }) + } } diff --git a/apps/services/bff/src/environment/environment.schema.ts b/apps/services/bff/src/environment/environment.schema.ts index f1efb21fdd07..d7cf2b91ad9a 100644 --- a/apps/services/bff/src/environment/environment.schema.ts +++ b/apps/services/bff/src/environment/environment.schema.ts @@ -21,6 +21,7 @@ export const environmentSchema = z.strictObject({ .refine((val) => !val.endsWith('/bff'), { message: `${KEY_PATH_ENV_VAR} must not end with /bff`, }), + name: z.string({ required_error: 'BFF_NAME is required' }), }) export type BffEnvironment = z.infer diff --git a/apps/services/bff/src/environment/environment.ts b/apps/services/bff/src/environment/environment.ts index 64402a053c15..f98c1992f1dc 100644 --- a/apps/services/bff/src/environment/environment.ts +++ b/apps/services/bff/src/environment/environment.ts @@ -6,6 +6,7 @@ const parsedEnvironment = environmentSchema.parse({ production: isProduction, port: process.env.PORT, keyPath: process.env.BFF_CLIENT_KEY_PATH, + name: process.env.BFF_NAME, }) export const environment: BffEnvironment = parsedEnvironment From 1e94f35b05efc8d9ba6750b5252e8f6078f72793 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Thu, 26 Sep 2024 10:12:02 +0000 Subject: [PATCH 060/248] Update logout flow to clean up, revoke tokens and better validation. Also deletes the logout callback --- .../src/app/modules/auth/auth.controller.ts | 12 +- .../bff/src/app/modules/auth/auth.service.ts | 112 ++++++++++-------- .../bff/src/app/modules/user/user.service.ts | 6 + 3 files changed, 72 insertions(+), 58 deletions(-) diff --git a/apps/services/bff/src/app/modules/auth/auth.controller.ts b/apps/services/bff/src/app/modules/auth/auth.controller.ts index bc618f8fa32e..d0eca27838db 100644 --- a/apps/services/bff/src/app/modules/auth/auth.controller.ts +++ b/apps/services/bff/src/app/modules/auth/auth.controller.ts @@ -41,19 +41,11 @@ export class AuthController { @Get('logout') async logout( + @Req() req: Request, @Res() res: Response, @Query(qsValidationPipe) query: LogoutQuery, ): Promise { - return this.authService.logout({ res, query }) - } - - @Get('callbacks/logout') - async callbackLogout( - @Res() res: Response, - @Query(qsValidationPipe) - query: CallbackLogoutQuery, - ): Promise { - return this.authService.callbackLogout(res, query) + return this.authService.logout({ req, res, query }) } } diff --git a/apps/services/bff/src/app/modules/auth/auth.service.ts b/apps/services/bff/src/app/modules/auth/auth.service.ts index 6df07f687e06..852a1d879a90 100644 --- a/apps/services/bff/src/app/modules/auth/auth.service.ts +++ b/apps/services/bff/src/app/modules/auth/auth.service.ts @@ -115,6 +115,18 @@ export class AuthService { return value } + /** + * Revoke the refresh token on the identity server, since we have a new session + * We deliberately do not await this operation to make the login flow faster, + * since this operation is not critical part to await. + * If the operation fails, we log the error. + */ + private revokeRefreshToken(token: string) { + this.idsService.revokeToken(token, 'refresh_token').catch((error) => { + this.logger.error('Failed to revoke refresh token:', error) + }) + } + /** * This method initiates the login flow. * It validates the target_link_uri and generates a unique session id, for a login attempt. @@ -281,15 +293,7 @@ export class AuthService { this.cacheService.createSessionKeyType('current', oldSessionCookie), ) - // Revoke the refresh token on the identity server, since we have a new session - // We deliberately do not await this operation to make the login flow faster, - // since this operation is not critical part to await. - // If the operation fails, we log the error. - this.idsService - .revokeToken(updatedTokenResponse.refresh_token, 'refresh_token') - .catch((error) => { - this.logger.error('Failed to revoke refresh token:', error) - }) + this.revokeRefreshToken(updatedTokenResponse.refresh_token) } return res.redirect( @@ -303,14 +307,44 @@ export class AuthService { } /** - * This method initiates the logout flow. - * It gets necessary data from the cache and constructs a logout URL. - * The user is then redirected to the identity server logout page. + * This method handles user logout. What it does: + * + * - Validates the session id in the query param and the session cookie + * - Cleans up the cache and cookies + * - Revokes the current session refresh token + * - Redirects the user to the identity server end session endpoint */ - async logout({ res, query: { sid } }: { res: Response; query: LogoutQuery }) { + async logout({ + req, + res, + query, + }: { + req: Request + res: Response + query: LogoutQuery + }) { + const sidCookie = req.cookies[SESSION_COOKIE_NAME] + + if (!sidCookie) { + this.logger.error('Logout failed: No session cookie found') + + return res.redirect(this.config.logoutRedirectUri) + } + + if (sidCookie !== query.sid) { + this.logger.error( + `Logout failed: Cookie sid "${sidCookie}" does not match the session id in query param "${query.sid}"`, + ) + + return this.redirectWithError(res, { + code: 400, + error: 'Logout failed!', + }) + } + const currentLoginCacheKey = this.cacheService.createSessionKeyType( 'current', - sid, + query.sid, ) const cachedTokenResponse = @@ -327,48 +361,30 @@ export class AuthService { )}`, ) - return res.redirect(this.config.callbacksRedirectUris.login) + return res.redirect(this.config.logoutRedirectUri) } + /** + * Clean up! + * + * - Revoke the refresh token on the identity server + * - Delete the current login from the cache + * - Clear the session cookie + * + * Note! We deliberately do not await this operation to make the logout flow faster. + */ + res.clearCookie(SESSION_COOKIE_NAME, this.getCookieOptions()) + this.cacheService.delete(currentLoginCacheKey) + this.revokeRefreshToken(cachedTokenResponse.refresh_token) + const searchParams = new URLSearchParams({ id_token_hint: cachedTokenResponse.id_token, - post_logout_redirect_uri: this.config.callbacksRedirectUris.logout, - state: encodeURIComponent(JSON.stringify({ sid })), + post_logout_redirect_uri: this.config.logoutRedirectUri, + state: encodeURIComponent(JSON.stringify({ sid: query.sid })), }) return res.redirect( `${this.baseUrl}/connect/endsession?${searchParams.toString()}`, ) } - - /** - * Callback for the logout flow. - * This method is called from the identity server after the user has logged out. - * We clean up the current login from the cache and delete the session cookie. - * Finally, we redirect the user back to the original URL. - */ - async callbackLogout(res: Response, { state }: CallbackLogoutQuery) { - const { sid } = JSON.parse(decodeURIComponent(state)) - - if (!sid) { - this.logger.error( - 'Logout failed: Invalid state param provided. No sid (session id) found', - ) - - throw new BadRequestException('Logout failed') - } - - const currentLoginCacheKey = this.cacheService.createSessionKeyType( - 'current', - state, - ) - - // Clean up current login from the cache since we have a successful logout. - await this.cacheService.delete(currentLoginCacheKey) - - // Delete session cookie - res.clearCookie(SESSION_COOKIE_NAME, this.getCookieOptions()) - - return res.redirect(this.config.logoutRedirectUri) - } } diff --git a/apps/services/bff/src/app/modules/user/user.service.ts b/apps/services/bff/src/app/modules/user/user.service.ts index 63bdc49273ea..80b499c3aef9 100644 --- a/apps/services/bff/src/app/modules/user/user.service.ts +++ b/apps/services/bff/src/app/modules/user/user.service.ts @@ -40,8 +40,14 @@ export class UserService { const cachedTokenResponse = await this.cacheService.get( this.cacheService.createSessionKeyType('current', sid), + // Do not throw error if the key is not found + false, ) + if (!cachedTokenResponse) { + throw new UnauthorizedException() + } + // Check if the access token is expired if (isExpired(cachedTokenResponse.accessTokenExp)) { // Get new token data with refresh token From c878c37aa0b4864d0c85eba69499106790ed3dae Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Thu, 26 Sep 2024 10:15:52 +0000 Subject: [PATCH 061/248] remove unused import --- apps/services/bff/src/app/modules/auth/auth.service.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/services/bff/src/app/modules/auth/auth.service.ts b/apps/services/bff/src/app/modules/auth/auth.service.ts index 852a1d879a90..f6e03fa01501 100644 --- a/apps/services/bff/src/app/modules/auth/auth.service.ts +++ b/apps/services/bff/src/app/modules/auth/auth.service.ts @@ -1,6 +1,6 @@ import type { Logger } from '@island.is/logging' import { LOGGER_PROVIDER } from '@island.is/logging' -import { BadRequestException, Inject, Injectable } from '@nestjs/common' +import { Inject, Injectable } from '@nestjs/common' import { ConfigType } from '@nestjs/config' import { CookieOptions, Request, Response } from 'express' import { jwtDecode } from 'jwt-decode' @@ -23,7 +23,6 @@ import { TokenResponse } from '../ids/ids.types' import { CachedTokenResponse } from './auth.types' import { PKCEService } from './pkce.service' import { CallbackLoginQuery } from './queries/callback-login.query' -import { CallbackLogoutQuery } from './queries/callback-logout.query' import { LoginQuery } from './queries/login.query' import { LogoutQuery } from './queries/logout.query' From f3816960b929c0a6be08885d4ecf6c7c0516c1af Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Thu, 26 Sep 2024 10:20:20 +0000 Subject: [PATCH 062/248] Simplify error in favour of enhanced fetch --- apps/services/bff/src/app/modules/ids/ids.service.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/apps/services/bff/src/app/modules/ids/ids.service.ts b/apps/services/bff/src/app/modules/ids/ids.service.ts index 5065e53302e8..6662651af065 100644 --- a/apps/services/bff/src/app/modules/ids/ids.service.ts +++ b/apps/services/bff/src/app/modules/ids/ids.service.ts @@ -59,12 +59,7 @@ export class IdsService { return response.text() as Promise } catch (error) { - this.logger.error( - `Error making request to ${endpoint}:`, - JSON.stringify(error), - ) - - throw new Error(`Failed to fetch from ${endpoint}`) + throw new Error(error) } } From 63e71970f4c75051f23b5fab4568b56ea92aeefc Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Thu, 26 Sep 2024 10:51:21 +0000 Subject: [PATCH 063/248] created enhanced fetch module, moved pkce service to services, updated proxy service and a little refactor --- apps/services/bff/src/app/app.module.ts | 2 ++ .../bff/src/app/modules/auth/auth.module.ts | 2 +- .../bff/src/app/modules/auth/auth.service.ts | 2 +- .../enhancedFetch/enhanced-fetch.module.ts | 9 +++++++++ .../enhancedFetch/enhanced-fetch.provider.ts | 17 +++++++++++++++++ .../bff/src/app/modules/ids/ids.service.ts | 16 ++++++---------- .../src/app/modules/proxy/proxy.controller.ts | 2 +- .../bff/src/app/modules/proxy/proxy.service.ts | 14 +++++--------- .../auth => services}/pkce.service.spec.ts | 0 .../{modules/auth => services}/pkce.service.ts | 0 10 files changed, 42 insertions(+), 22 deletions(-) create mode 100644 apps/services/bff/src/app/modules/enhancedFetch/enhanced-fetch.module.ts create mode 100644 apps/services/bff/src/app/modules/enhancedFetch/enhanced-fetch.provider.ts rename apps/services/bff/src/app/{modules/auth => services}/pkce.service.spec.ts (100%) rename apps/services/bff/src/app/{modules/auth => services}/pkce.service.ts (100%) diff --git a/apps/services/bff/src/app/app.module.ts b/apps/services/bff/src/app/app.module.ts index 5711d9e2d390..923f19224d97 100644 --- a/apps/services/bff/src/app/app.module.ts +++ b/apps/services/bff/src/app/app.module.ts @@ -5,6 +5,7 @@ import { AuthModule as AppAuthModule } from './modules/auth/auth.module' import { CacheModule } from './modules/cache/cache.module' import { ProxyModule } from './modules/proxy/proxy.module' import { UserModule } from './modules/user/user.module' +import { EnhancedFetchModule } from './modules/enhancedFetch/enhanced-fetch.module' @Module({ imports: [ @@ -16,6 +17,7 @@ import { UserModule } from './modules/user/user.module' UserModule, AppAuthModule, ProxyModule, + EnhancedFetchModule, ], }) export class AppModule {} diff --git a/apps/services/bff/src/app/modules/auth/auth.module.ts b/apps/services/bff/src/app/modules/auth/auth.module.ts index 90dff2eb72da..9785e7063252 100644 --- a/apps/services/bff/src/app/modules/auth/auth.module.ts +++ b/apps/services/bff/src/app/modules/auth/auth.module.ts @@ -2,7 +2,7 @@ import { Module } from '@nestjs/common' import { IdsService } from '../ids/ids.service' import { AuthController } from './auth.controller' import { AuthService } from './auth.service' -import { PKCEService } from './pkce.service' +import { PKCEService } from '../../services/pkce.service' import { CryptoService } from '../../services/crypto.service' @Module({ diff --git a/apps/services/bff/src/app/modules/auth/auth.service.ts b/apps/services/bff/src/app/modules/auth/auth.service.ts index f6e03fa01501..22ff0bcd1084 100644 --- a/apps/services/bff/src/app/modules/auth/auth.service.ts +++ b/apps/services/bff/src/app/modules/auth/auth.service.ts @@ -12,6 +12,7 @@ import { BffConfig } from '../../bff.config' import { SESSION_COOKIE_NAME } from '../../constants/cookies' import { FIVE_SECONDS_IN_MS, ONE_WEEK_IN_MS } from '../../constants/time' import { CryptoService } from '../../services/crypto.service' +import { PKCEService } from '../../services/pkce.service' import { CreateErrorQueryStrArgs, createErrorQueryStr, @@ -21,7 +22,6 @@ import { CacheService } from '../cache/cache.service' import { IdsService } from '../ids/ids.service' import { TokenResponse } from '../ids/ids.types' import { CachedTokenResponse } from './auth.types' -import { PKCEService } from './pkce.service' import { CallbackLoginQuery } from './queries/callback-login.query' import { LoginQuery } from './queries/login.query' import { LogoutQuery } from './queries/logout.query' diff --git a/apps/services/bff/src/app/modules/enhancedFetch/enhanced-fetch.module.ts b/apps/services/bff/src/app/modules/enhancedFetch/enhanced-fetch.module.ts new file mode 100644 index 000000000000..df6ef6c4c9fb --- /dev/null +++ b/apps/services/bff/src/app/modules/enhancedFetch/enhanced-fetch.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common' +import { EnhancedFetchProvider } from './enhanced-fetch.provider' + +@Global() +@Module({ + providers: [EnhancedFetchProvider], + exports: [EnhancedFetchProvider], +}) +export class EnhancedFetchModule {} diff --git a/apps/services/bff/src/app/modules/enhancedFetch/enhanced-fetch.provider.ts b/apps/services/bff/src/app/modules/enhancedFetch/enhanced-fetch.provider.ts new file mode 100644 index 000000000000..3bb439fa2712 --- /dev/null +++ b/apps/services/bff/src/app/modules/enhancedFetch/enhanced-fetch.provider.ts @@ -0,0 +1,17 @@ +import { Provider } from '@nestjs/common' + +import { + createEnhancedFetch, + type EnhancedFetchAPI, +} from '@island.is/clients/middlewares' +import { environment } from '../../../environment' + +export const ENHANCED_FETCH_PROVIDER_KEY = 'enhanced-fetch-provider' + +export const EnhancedFetchProvider: Provider = { + provide: ENHANCED_FETCH_PROVIDER_KEY, + useFactory: () => + createEnhancedFetch({ + name: `bff-${environment.name}-serivce`, + }), +} diff --git a/apps/services/bff/src/app/modules/ids/ids.service.ts b/apps/services/bff/src/app/modules/ids/ids.service.ts index 6662651af065..c1c044faf487 100644 --- a/apps/services/bff/src/app/modules/ids/ids.service.ts +++ b/apps/services/bff/src/app/modules/ids/ids.service.ts @@ -3,33 +3,29 @@ import { LOGGER_PROVIDER } from '@island.is/logging' import { Inject, Injectable } from '@nestjs/common' import { ConfigType } from '@nestjs/config' +import { EnhancedFetchAPI } from '@island.is/clients/middlewares' import { BffConfig } from '../../bff.config' import { CryptoService } from '../../services/crypto.service' +import { ENHANCED_FETCH_PROVIDER_KEY } from '../enhancedFetch/enhanced-fetch.provider' import { ParResponse, TokenResponse } from './ids.types' -import { - EnhancedFetchAPI, - createEnhancedFetch, -} from '@island.is/clients/middlewares' -import { environment } from '../../../environment' @Injectable() export class IdsService { - private readonly enhancedFetch: EnhancedFetchAPI private readonly issuerUrl constructor( @Inject(LOGGER_PROVIDER) - private logger: Logger, + private readonly logger: Logger, @Inject(BffConfig.KEY) private readonly config: ConfigType, + @Inject(ENHANCED_FETCH_PROVIDER_KEY) + private readonly enhancedFetch: EnhancedFetchAPI, + private readonly cryptoService: CryptoService, ) { this.issuerUrl = this.config.ids.issuer - this.enhancedFetch = createEnhancedFetch({ - name: `bff-${environment.name}-ids-serivce`, - }) } /** diff --git a/apps/services/bff/src/app/modules/proxy/proxy.controller.ts b/apps/services/bff/src/app/modules/proxy/proxy.controller.ts index cc3c74d83a23..f24b7a19b8ea 100644 --- a/apps/services/bff/src/app/modules/proxy/proxy.controller.ts +++ b/apps/services/bff/src/app/modules/proxy/proxy.controller.ts @@ -33,6 +33,6 @@ export class ProxyController { @Req() req: Request, @Res() res: Response, ): Promise { - return this.proxyService.proxyRequest({ req, res }) + return this.proxyService.proxyGraphQLRequest({ req, res }) } } diff --git a/apps/services/bff/src/app/modules/proxy/proxy.service.ts b/apps/services/bff/src/app/modules/proxy/proxy.service.ts index 7333b13c27c9..5bc5ab72fa43 100644 --- a/apps/services/bff/src/app/modules/proxy/proxy.service.ts +++ b/apps/services/bff/src/app/modules/proxy/proxy.service.ts @@ -19,7 +19,7 @@ import { CacheService } from '../cache/cache.service' import { IdsService } from '../ids/ids.service' import { ApiQuery } from './queries/api-proxy.query' -const whiteListedHeaders = ['access-control-allow-origin'] +const droppedResponseHeaders = ['access-control-allow-origin'] @Injectable() export class ProxyService { @@ -99,16 +99,12 @@ export class ProxyService { body: JSON.stringify(body ?? req.body), }) - if (!response.ok) { - throw new Error(`HTTP error! Status: ${response.status}`) - } - // Set the status code of the response res.status(response.status) response.headers.forEach((value, key) => { - // Only set headers that are not in the whiteListedHeaders array - if (!whiteListedHeaders.includes(key.toLowerCase())) { + // Only set headers that are not in the droppedResponseHeaders array + if (!droppedResponseHeaders.includes(key.toLowerCase())) { res.setHeader(key, value) } }) @@ -122,7 +118,7 @@ export class ProxyService { // This check ensures that `res.end()` is only called if the response has not already been ended. if (!res.writableEnded) { // Ensure the response is properly ended if an error occurs - res.end('An error occurred while streaming data.') + res.end() } }) @@ -144,7 +140,7 @@ export class ProxyService { * Proxies an incoming HTTP POST request to a target GraphQL API, handling authentication, token refresh, * and response streaming. */ - public async proxyRequest({ + public async proxyGraphQLRequest({ req, res, }: { diff --git a/apps/services/bff/src/app/modules/auth/pkce.service.spec.ts b/apps/services/bff/src/app/services/pkce.service.spec.ts similarity index 100% rename from apps/services/bff/src/app/modules/auth/pkce.service.spec.ts rename to apps/services/bff/src/app/services/pkce.service.spec.ts diff --git a/apps/services/bff/src/app/modules/auth/pkce.service.ts b/apps/services/bff/src/app/services/pkce.service.ts similarity index 100% rename from apps/services/bff/src/app/modules/auth/pkce.service.ts rename to apps/services/bff/src/app/services/pkce.service.ts From 4379b55ebc2dc597728fb3158db822fb7f43ca08 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Thu, 26 Sep 2024 10:59:14 +0000 Subject: [PATCH 064/248] par support flag not optional --- apps/services/bff/src/app/bff.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/services/bff/src/app/bff.config.ts b/apps/services/bff/src/app/bff.config.ts index acef1455c51c..5ea709bd7b30 100644 --- a/apps/services/bff/src/app/bff.config.ts +++ b/apps/services/bff/src/app/bff.config.ts @@ -38,7 +38,7 @@ const BffConfigSchema = z.object({ /** * Determines if the BFF should support the PAR (Pushed Authorization Requests) flow or normal login flow */ - parSupportEnabled: z.boolean().optional(), + parSupportEnabled: z.boolean(), /** * Allowed external API URLs that the BFF can proxy requests to */ From 2bb7deee3c76c973d14da24a4381e9ece0292e35 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Thu, 26 Sep 2024 11:00:33 +0000 Subject: [PATCH 065/248] Fix typo --- apps/services/bff/src/app/bff.config.ts | 4 ++-- apps/services/bff/src/app/modules/proxy/proxy.service.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/services/bff/src/app/bff.config.ts b/apps/services/bff/src/app/bff.config.ts index 5ea709bd7b30..150208b3f55a 100644 --- a/apps/services/bff/src/app/bff.config.ts +++ b/apps/services/bff/src/app/bff.config.ts @@ -21,7 +21,7 @@ const BffConfigSchema = z.object({ }) // Only required in production .optional(), - graphqlApiEndpont: z.string(), + graphqlApiEndpoint: z.string(), /** * The URL to redirect to after logging out */ @@ -79,7 +79,7 @@ export const BffConfig = defineConfig({ /** * Our main GraphQL API endpoint */ - graphqlApiEndpont: env.required('BFF_PROXY_API_ENDPOINT'), + graphqlApiEndpoint: env.required('BFF_PROXY_API_ENDPOINT'), redis: isProduction ? { name: env.required('BFF_REDIS_NAME'), diff --git a/apps/services/bff/src/app/modules/proxy/proxy.service.ts b/apps/services/bff/src/app/modules/proxy/proxy.service.ts index 5bc5ab72fa43..4bf3f7daf620 100644 --- a/apps/services/bff/src/app/modules/proxy/proxy.service.ts +++ b/apps/services/bff/src/app/modules/proxy/proxy.service.ts @@ -149,7 +149,7 @@ export class ProxyService { }): Promise { const accessToken = await this.getAccessToken(req) const queryString = req.url.split('?')[1] - const targetUrl = `${this.config.graphqlApiEndpont}${ + const targetUrl = `${this.config.graphqlApiEndpoint}${ queryString ? `?${queryString}` : '' }` From 0d1e967d3b723066a3814d23fd90bdc23ecb68f7 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Thu, 26 Sep 2024 11:04:01 +0000 Subject: [PATCH 066/248] Add better validation to crypto decryption function --- apps/services/bff/src/app/services/crypto.service.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/services/bff/src/app/services/crypto.service.ts b/apps/services/bff/src/app/services/crypto.service.ts index 2b64e70ab312..8ca73afaec51 100644 --- a/apps/services/bff/src/app/services/crypto.service.ts +++ b/apps/services/bff/src/app/services/crypto.service.ts @@ -82,6 +82,12 @@ export class CryptoService { // Split the input into the algorithm, IV (initialization vector), and the encrypted text const [_algorithm, ivBase64, encrypted] = encryptedText.split(':') + // If encryptedText is malformed, this could lead to runtime errors or security vulnerabilities. + // This check ensures all parts are present before proceeding. + if (!ivBase64 || !encrypted) { + throw new Error('Invalid encrypted text format.') + } + // Convert the base64-encoded IV back into a Buffer const iv = Buffer.from(ivBase64, 'base64') From 08f03308a845186bc6f713e86aba576410103814 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Thu, 26 Sep 2024 11:23:17 +0000 Subject: [PATCH 067/248] Update validate uri to be more secure, create test for validate uri. Update port range in environment --- .../bff/src/app/utils/validate-uri.spec.ts | 58 +++++++++++++++++++ .../bff/src/app/utils/validate-uri.ts | 34 +++++++++-- .../bff/src/environment/environment.schema.ts | 4 +- 3 files changed, 89 insertions(+), 7 deletions(-) create mode 100644 apps/services/bff/src/app/utils/validate-uri.spec.ts diff --git a/apps/services/bff/src/app/utils/validate-uri.spec.ts b/apps/services/bff/src/app/utils/validate-uri.spec.ts new file mode 100644 index 000000000000..480dff40b260 --- /dev/null +++ b/apps/services/bff/src/app/utils/validate-uri.spec.ts @@ -0,0 +1,58 @@ +import { validateUri } from './validate-uri' + +describe('validateUri', () => { + const allowedUris = [ + 'https://beta.dev01.devland.is/stjornbord', + 'https://api.stjornbord.island.is', + ] + + it('should return true for valid allowed URI', () => { + expect( + validateUri('https://beta.dev01.devland.is/stjornbord', allowedUris), + ).toBe(true) + }) + + it('should return false for subdomain attacks', () => { + expect( + validateUri( + // A subdomain that could pass if validation isn't strict + 'https://beta.dev01.devland.is.evil.is/stjornbord', + allowedUris, + ), + ).toBe(false) + }) + + it('should return false for protocol mismatch', () => { + expect( + validateUri('http://beta.dev01.devland.is/stjornbord', allowedUris), + ).toBe(false) + }) + + it('should return false for path traversal', () => { + expect( + validateUri( + // Path traversal should fail + 'https://beta.dev01.devland.is/stjornbord/../admin', + allowedUris, + ), + ).toBe(false) + }) + + it('should return false for invalid hostname', () => { + expect( + validateUri('https://malicious-site.com/stjornbord', allowedUris), + ).toBe(false) + }) + + it('should return true for another valid allowed URI', () => { + expect(validateUri('https://api.stjornbord.island.is', allowedUris)).toBe( + true, + ) + }) + + it('should return false for partially matching but different path', () => { + expect( + validateUri('https://beta.dev01.devland.is/different-path', allowedUris), + ).toBe(false) + }) +}) diff --git a/apps/services/bff/src/app/utils/validate-uri.ts b/apps/services/bff/src/app/utils/validate-uri.ts index f044fb86de4a..2a14b6bf7529 100644 --- a/apps/services/bff/src/app/utils/validate-uri.ts +++ b/apps/services/bff/src/app/utils/validate-uri.ts @@ -1,10 +1,34 @@ +import { URL } from 'url' + /** - * Validates the URI against allowed URIs to ensure blocking of external URLs. + * Validates the URI against allowed URIs to block external URLs. + * + * Security Considerations: + * - Subdomain Attacks: Prevents bypassing validation via subdomains (e.g., `evil.example.com`). + * - Path Traversal: Prevents accessing unintended paths, e.g. https://island.is.evil.is/some-path could pass validation if https://island.is is the allowed URL when using startWith() for example. + * - Protocol Hijacking: Ensures the protocol (e.g., `https`) matches exactly. + * + * This function compares both the protocol, hostname, and optionally, the pathname. * * @param uri - The URI to validate. - * @param allowedUris - An array of allowed URIs. - * @returns True if the URI starts with any of the allowed URIs; otherwise, false. + * @param allowedUris - List of allowed URIs. + * @returns True if the URI is valid, false otherwise. */ -export const validateUri = (uri: string, allowedUris: string[]) => { - return allowedUris.some((allowedUri) => uri.startsWith(allowedUri)) +export const validateUri = (uri: string, allowedUris: string[]): boolean => { + try { + const parsedUri = new URL(uri) + + return allowedUris.some((allowedUri) => { + const parsedAllowedUri = new URL(allowedUri) + const isSameProtocol = parsedUri.protocol === parsedAllowedUri.protocol + const isSameHostname = parsedUri.hostname === parsedAllowedUri.hostname + const isSamePathname = + parsedUri.pathname === parsedAllowedUri.pathname || + parsedUri.pathname.startsWith(parsedAllowedUri.pathname) + + return isSameProtocol && isSameHostname && isSamePathname + }) + } catch { + return false + } } diff --git a/apps/services/bff/src/environment/environment.schema.ts b/apps/services/bff/src/environment/environment.schema.ts index d7cf2b91ad9a..c660cfd14845 100644 --- a/apps/services/bff/src/environment/environment.schema.ts +++ b/apps/services/bff/src/environment/environment.schema.ts @@ -8,8 +8,8 @@ export const environmentSchema = z.strictObject({ (val) => (val ? parseInt(val as string, 10) : 3010), z .number({ required_error: 'PORT must be a valid number' }) - .min(1000) - .max(10000), + .min(1) + .max(65535), ), /** * The global prefix for the API From 389d3c1288078cbb280638e5f3f4ca170fad9d51 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Thu, 26 Sep 2024 11:24:51 +0000 Subject: [PATCH 068/248] Remove state param from logout to ensure it will not be passed to redirect uri --- apps/services/bff/src/app/modules/auth/auth.service.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/services/bff/src/app/modules/auth/auth.service.ts b/apps/services/bff/src/app/modules/auth/auth.service.ts index 22ff0bcd1084..a2930db4da0f 100644 --- a/apps/services/bff/src/app/modules/auth/auth.service.ts +++ b/apps/services/bff/src/app/modules/auth/auth.service.ts @@ -379,7 +379,6 @@ export class AuthService { const searchParams = new URLSearchParams({ id_token_hint: cachedTokenResponse.id_token, post_logout_redirect_uri: this.config.logoutRedirectUri, - state: encodeURIComponent(JSON.stringify({ sid: query.sid })), }) return res.redirect( From b411003b039e4323e114c2cb0261fc64c31405d0 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Thu, 26 Sep 2024 11:38:31 +0000 Subject: [PATCH 069/248] Adding more tests and increasing security in the function --- .../bff/src/app/utils/validate-uri.spec.ts | 26 +++++++++++++++++++ .../bff/src/app/utils/validate-uri.ts | 15 +++++++++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/apps/services/bff/src/app/utils/validate-uri.spec.ts b/apps/services/bff/src/app/utils/validate-uri.spec.ts index 480dff40b260..cdb41687af6e 100644 --- a/apps/services/bff/src/app/utils/validate-uri.spec.ts +++ b/apps/services/bff/src/app/utils/validate-uri.spec.ts @@ -6,6 +6,32 @@ describe('validateUri', () => { 'https://api.stjornbord.island.is', ] + it('should return false if uri is not a string', () => { + expect(validateUri(123 as any, allowedUris)).toBe(false) + }) + + it('should return false if allowedUris is not an array', () => { + expect( + validateUri('https://beta.dev01.devland.is/stjornbord', {} as any), + ).toBe(false) + }) + + it('should return false if allowedUri is not a string', () => { + const invalidAllowedUris = [123 as any, 'https://api.stjornbord.island.is'] + expect( + validateUri( + 'https://beta.dev01.devland.is/stjornbord', + invalidAllowedUris, + ), + ).toBe(false) + }) + + it('should return true for URI with the same hostname in different caseing', () => { + expect( + validateUri('https://Beta.Dev01.Devland.Is/stjornbord', allowedUris), + ).toBe(true) + }) + it('should return true for valid allowed URI', () => { expect( validateUri('https://beta.dev01.devland.is/stjornbord', allowedUris), diff --git a/apps/services/bff/src/app/utils/validate-uri.ts b/apps/services/bff/src/app/utils/validate-uri.ts index 2a14b6bf7529..0a084f35b005 100644 --- a/apps/services/bff/src/app/utils/validate-uri.ts +++ b/apps/services/bff/src/app/utils/validate-uri.ts @@ -15,11 +15,22 @@ import { URL } from 'url' * @returns True if the URI is valid, false otherwise. */ export const validateUri = (uri: string, allowedUris: string[]): boolean => { + // input validation for extra security + if (!uri || typeof uri !== 'string' || !Array.isArray(allowedUris)) { + return false + } + try { - const parsedUri = new URL(uri) + // Normalize the URI to lowercase for case-insensitive comparison + const parsedUri = new URL(uri.trim().toLowerCase()) return allowedUris.some((allowedUri) => { - const parsedAllowedUri = new URL(allowedUri) + if (typeof allowedUri !== 'string') { + return false + } + + const parsedAllowedUri = new URL(allowedUri.trim().toLowerCase()) + const isSameProtocol = parsedUri.protocol === parsedAllowedUri.protocol const isSameHostname = parsedUri.hostname === parsedAllowedUri.hostname const isSamePathname = From efda624515661bd4016ae393e6aa322e5dea392a Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Thu, 26 Sep 2024 11:53:12 +0000 Subject: [PATCH 070/248] Refactor after reading comments from coderabbit --- apps/services/bff/src/app/bff.config.ts | 2 +- .../bff/src/app/modules/auth/auth.service.ts | 2 +- .../enhancedFetch/enhanced-fetch.provider.ts | 2 +- .../bff/src/app/services/pkce.service.ts | 28 ++++++-------- .../src/app/utils/remove-trailing-slash.ts | 13 +++++++ .../bff/src/app/utils/removeTrailingSlash.ts | 7 ---- libs/react-spa/bff/src/lib/BffProvider.tsx | 38 ++++++++++++------- libs/react-spa/bff/src/lib/bff.state.ts | 7 ---- 8 files changed, 51 insertions(+), 48 deletions(-) create mode 100644 apps/services/bff/src/app/utils/remove-trailing-slash.ts delete mode 100644 apps/services/bff/src/app/utils/removeTrailingSlash.ts diff --git a/apps/services/bff/src/app/bff.config.ts b/apps/services/bff/src/app/bff.config.ts index 150208b3f55a..83839b02a111 100644 --- a/apps/services/bff/src/app/bff.config.ts +++ b/apps/services/bff/src/app/bff.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from '@island.is/nest/config' import { z } from 'zod' import { isProduction } from '../environment' -import { removeTrailingSlash } from './utils/removeTrailingSlash' +import { removeTrailingSlash } from './utils/remove-trailing-slash' import { DEFAULT_CACHE_USER_PROFILE_TTL_MS } from './constants/time' export const idsSchema = z.strictObject({ diff --git a/apps/services/bff/src/app/modules/auth/auth.service.ts b/apps/services/bff/src/app/modules/auth/auth.service.ts index a2930db4da0f..ff0c156c1ff8 100644 --- a/apps/services/bff/src/app/modules/auth/auth.service.ts +++ b/apps/services/bff/src/app/modules/auth/auth.service.ts @@ -6,7 +6,7 @@ import { CookieOptions, Request, Response } from 'express' import { jwtDecode } from 'jwt-decode' import { IdTokenClaims } from '@island.is/shared/types' -import { uuid } from 'uuidv4' +import { v4 as uuid } from 'uuid' import { environment } from '../../../environment' import { BffConfig } from '../../bff.config' import { SESSION_COOKIE_NAME } from '../../constants/cookies' diff --git a/apps/services/bff/src/app/modules/enhancedFetch/enhanced-fetch.provider.ts b/apps/services/bff/src/app/modules/enhancedFetch/enhanced-fetch.provider.ts index 3bb439fa2712..d935844ff506 100644 --- a/apps/services/bff/src/app/modules/enhancedFetch/enhanced-fetch.provider.ts +++ b/apps/services/bff/src/app/modules/enhancedFetch/enhanced-fetch.provider.ts @@ -12,6 +12,6 @@ export const EnhancedFetchProvider: Provider = { provide: ENHANCED_FETCH_PROVIDER_KEY, useFactory: () => createEnhancedFetch({ - name: `bff-${environment.name}-serivce`, + name: `bff-${environment.name}-service`, }), } diff --git a/apps/services/bff/src/app/services/pkce.service.ts b/apps/services/bff/src/app/services/pkce.service.ts index 78de1d73f8c8..a126ddc01945 100644 --- a/apps/services/bff/src/app/services/pkce.service.ts +++ b/apps/services/bff/src/app/services/pkce.service.ts @@ -6,6 +6,16 @@ const randomBytesAsync = promisify(crypto.randomBytes) @Injectable() export class PKCEService { + /** + * Creates an array of length "size" of random bytes + * @returns Array of random ints (0 to 255) + */ + private async getRandomValues(size: number): Promise { + const randomBytes = await randomBytesAsync(size) + + return new Uint8Array(randomBytes) + } + /** * Generate a PKCE code verifier * Generates a 50-character long verifier by default @@ -30,16 +40,6 @@ export class PKCEService { return this.base64UrlEncode(Buffer.from(hashBuffer)) } - /** - * Creates an array of length "size" of random bytes - * @returns Array of random ints (0 to 255) - */ - async getRandomValues(size: number): Promise { - const randomBytes = await randomBytesAsync(size) - - return new Uint8Array(randomBytes) - } - /** * Generate a PKCE challenge verifier * Generates cryptographically strong random string @@ -63,14 +63,8 @@ export class PKCEService { /** * Base64 URL encode the buffer input * This utility function converts a Buffer to a Base64 URL-safe string, - * replacing + with -, / with _, and removing any padding = characters. - * This is necessary because the standard Base64 encoding includes characters (+, /, and padding =) that are not URL-safe. */ private base64UrlEncode(buffer: Buffer): string { - return buffer - .toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, '') + return buffer.toString('base64url') } } diff --git a/apps/services/bff/src/app/utils/remove-trailing-slash.ts b/apps/services/bff/src/app/utils/remove-trailing-slash.ts new file mode 100644 index 000000000000..3a835b60cf3c --- /dev/null +++ b/apps/services/bff/src/app/utils/remove-trailing-slash.ts @@ -0,0 +1,13 @@ +/** + * Remove trailing slash from a string + * + * @example + * removeTrailingSlash('https://example.com/') // 'https://example.com' + */ +export const removeTrailingSlash = (str: string) => { + if (!str || str === '/') { + return '' + } + + return str.endsWith('/') ? str.slice(0, -1) : str +} diff --git a/apps/services/bff/src/app/utils/removeTrailingSlash.ts b/apps/services/bff/src/app/utils/removeTrailingSlash.ts deleted file mode 100644 index c12435ab918f..000000000000 --- a/apps/services/bff/src/app/utils/removeTrailingSlash.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Remove trailing slash from a string - * - * @example - * removeTrailingSlash('https://example.com/') // 'https://example.com' - */ -export const removeTrailingSlash = (str: string) => str.replace(/\/$/, '') diff --git a/libs/react-spa/bff/src/lib/BffProvider.tsx b/libs/react-spa/bff/src/lib/BffProvider.tsx index a36efc02cc41..1511e745874d 100644 --- a/libs/react-spa/bff/src/lib/BffProvider.tsx +++ b/libs/react-spa/bff/src/lib/BffProvider.tsx @@ -22,6 +22,14 @@ export const BffProvider = ({ const bffUrlGenerator = createBffUrlGenerator(applicationBasePath) const [state, dispatch] = useReducer(reducer, initialState) + const { authState } = state + const showErrorScreen = authState === 'error' + const showLoadingScreen = + authState === 'loading' || + authState === 'switching' || + authState === 'logging-out' + const isLoggedIn = authState === 'logged-in' + const checkLogin = async () => { dispatch({ type: ActionType.SIGNIN_START, @@ -127,13 +135,21 @@ export const BffProvider = ({ window.location.href = applicationBasePath } - const { authState } = state - const showErrorScreen = authState === 'error' - const showLoadingScreen = - authState === 'loading' || - authState === 'switching' || - authState === 'logging-out' - const isLoggedIn = authState === 'logged-in' + const renderContent = () => { + if (showErrorScreen) { + return + } + + if (showLoadingScreen) { + return + } + + if (isLoggedIn) { + return children + } + + return null + } return ( - {showErrorScreen ? ( - - ) : showLoadingScreen ? ( - - ) : isLoggedIn ? ( - children - ) : null} + {renderContent()} ) } diff --git a/libs/react-spa/bff/src/lib/bff.state.ts b/libs/react-spa/bff/src/lib/bff.state.ts index 41d46fe480bc..a7f16b0bee8a 100644 --- a/libs/react-spa/bff/src/lib/bff.state.ts +++ b/libs/react-spa/bff/src/lib/bff.state.ts @@ -12,7 +12,6 @@ export type BffState = export enum ActionType { SIGNIN_START = 'SIGNIN_START', SIGNIN_SUCCESS = 'SIGNIN_SUCCESS', - SIGNIN_FAILURE = 'SIGNIN_FAILURE', LOGGING_OUT = 'LOGGING_OUT', LOGGED_OUT = 'LOGGED_OUT', USER_LOADED = 'USER_LOADED', @@ -37,7 +36,6 @@ export type Action = | { type: | ActionType.SIGNIN_START - | ActionType.SIGNIN_FAILURE | ActionType.LOGGING_OUT | ActionType.LOGGED_OUT | ActionType.SWITCH_USER @@ -77,11 +75,6 @@ export const reducer = ( }) : state - case ActionType.SIGNIN_FAILURE: - return withState({ - authState: 'failed', - }) - case ActionType.LOGGING_OUT: return withState({ authState: 'logging-out', From 7f0d1323bb75f00dadc0bfabbdd92ec738971b5e Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Thu, 26 Sep 2024 13:51:36 +0000 Subject: [PATCH 071/248] remove private from method for test --- apps/services/bff/src/app/services/pkce.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/services/bff/src/app/services/pkce.service.ts b/apps/services/bff/src/app/services/pkce.service.ts index a126ddc01945..179f44617e1a 100644 --- a/apps/services/bff/src/app/services/pkce.service.ts +++ b/apps/services/bff/src/app/services/pkce.service.ts @@ -10,7 +10,7 @@ export class PKCEService { * Creates an array of length "size" of random bytes * @returns Array of random ints (0 to 255) */ - private async getRandomValues(size: number): Promise { + async getRandomValues(size: number): Promise { const randomBytes = await randomBytesAsync(size) return new Uint8Array(randomBytes) From 3e6c02b9bada2384ec153e11e44a203d8250bef3 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Thu, 26 Sep 2024 14:00:06 +0000 Subject: [PATCH 072/248] Move portal scopes to shareable location. --- apps/services/bff/infra/admin-portal.infra.ts | 25 +--------- .../bff/infra/utils/createPortalEnv.ts | 16 +++++++ libs/auth/scopes/src/index.ts | 2 + .../src/lib/clients/admin-portal-scopes.ts | 23 +++++++++ .../src/lib/clients/service-portal-scopes.ts | 48 +++++++++++++++++++ 5 files changed, 90 insertions(+), 24 deletions(-) create mode 100644 libs/auth/scopes/src/lib/clients/admin-portal-scopes.ts create mode 100644 libs/auth/scopes/src/lib/clients/service-portal-scopes.ts diff --git a/apps/services/bff/infra/admin-portal.infra.ts b/apps/services/bff/infra/admin-portal.infra.ts index 33e529d8c34e..14f1690f6765 100644 --- a/apps/services/bff/infra/admin-portal.infra.ts +++ b/apps/services/bff/infra/admin-portal.infra.ts @@ -6,30 +6,7 @@ export const serviceSetup = (): ServiceBuilder<'services-bff-admin-portal'> => .namespace('services-bff') .image('services-bff') .redis() - .env({ - ...createPortalEnv('stjornbord'), - IDENTITY_SERVER_CLIENT_SCOPES: json([ - '@admin.island.is/delegation-system', - '@admin.island.is/delegation-system:admin', - '@admin.island.is/ads', - '@admin.island.is/ads:explicit', - '@admin.island.is/delegations', - '@admin.island.is/regulations', - '@admin.island.is/regulations:manage', - '@admin.island.is/icelandic-names-registry', - '@admin.island.is/document-provider', - '@admin.island.is/application-system:admin', - '@admin.island.is/application-system:institution', - '@admin.island.is/auth', - '@admin.island.is/auth:admin', - '@admin.island.is/petitions', - '@admin.island.is/service-desk', - '@admin.island.is/signature-collection:process', - '@admin.island.is/signature-collection:manage', - '@admin.island.is/form-system', - '@admin.island.is/form-system:admin', - ]), - }) + .env(createPortalEnv('stjornbord')) .secrets({ // The secret should be a valid 32-byte base64 key. // Generate key example: `openssl rand -base64 32` diff --git a/apps/services/bff/infra/utils/createPortalEnv.ts b/apps/services/bff/infra/utils/createPortalEnv.ts index 8e766b399b9b..72fcc9c0b825 100644 --- a/apps/services/bff/infra/utils/createPortalEnv.ts +++ b/apps/services/bff/infra/utils/createPortalEnv.ts @@ -1,3 +1,5 @@ +import { adminPortalScopes, servicePortalScopes } from '@island.is/auth/scopes' +import { json } from '../../../../../infra/src/dsl/dsl' import { DEFAULT_CACHE_USER_PROFILE_TTL_MS } from '../../src/app/constants/time' type PortalKeys = 'stjornbord' | 'minarsidur' @@ -8,9 +10,23 @@ const defaultEnvUrls = { prod: 'https://island.is', } +const getScopes = (key: PortalKeys) => { + switch (key) { + case 'minarsidur': + return servicePortalScopes + + case 'stjornbord': + return adminPortalScopes + + default: + throw new Error('Invalid BFF client') + } +} + export const createPortalEnv = (key: PortalKeys) => { return { // Idenity server + IDENTITY_SERVER_CLIENT_SCOPES: json(getScopes(key)), IDENTITY_SERVER_CLIENT_ID: `@admin.island.is/bff-${key}`, IDENTITY_SERVER_ISSUER_URL: { dev: 'https://identity-server.dev01.devland.is', diff --git a/libs/auth/scopes/src/index.ts b/libs/auth/scopes/src/index.ts index acd201a8186e..ddb61ffe1938 100644 --- a/libs/auth/scopes/src/index.ts +++ b/libs/auth/scopes/src/index.ts @@ -24,3 +24,5 @@ export * from './lib/recycling-fund.scope' export * from './lib/universityGateway.scope' export * from './lib/mms.scope' export * from './lib/judicial-system.scope' +export * from './lib/clients/admin-portal-scopes' +export * from './lib/clients/service-portal-scopes' diff --git a/libs/auth/scopes/src/lib/clients/admin-portal-scopes.ts b/libs/auth/scopes/src/lib/clients/admin-portal-scopes.ts new file mode 100644 index 000000000000..252f4457ef38 --- /dev/null +++ b/libs/auth/scopes/src/lib/clients/admin-portal-scopes.ts @@ -0,0 +1,23 @@ +import { AdminPortalScope } from '../admin-portal.scope' + +export const adminPortalScopes = [ + AdminPortalScope.delegations, + AdminPortalScope.airDiscountScheme, + AdminPortalScope.regulationAdmin, + AdminPortalScope.regulationAdminManage, + AdminPortalScope.icelandicNamesRegistry, + AdminPortalScope.applicationSystemAdmin, + AdminPortalScope.applicationSystemInstitution, + AdminPortalScope.documentProvider, + AdminPortalScope.idsAdmin, + AdminPortalScope.idsAdminSuperUser, + AdminPortalScope.petitionsAdmin, + AdminPortalScope.serviceDesk, + AdminPortalScope.explicitAirDiscountScheme, + AdminPortalScope.signatureCollectionManage, + AdminPortalScope.signatureCollectionProcess, + AdminPortalScope.formSystem, + AdminPortalScope.formSystemSuperUser, + AdminPortalScope.delegationSystem, + AdminPortalScope.delegationSystemAdmin, +] diff --git a/libs/auth/scopes/src/lib/clients/service-portal-scopes.ts b/libs/auth/scopes/src/lib/clients/service-portal-scopes.ts new file mode 100644 index 000000000000..a92a6a77914e --- /dev/null +++ b/libs/auth/scopes/src/lib/clients/service-portal-scopes.ts @@ -0,0 +1,48 @@ +import { ApiScope } from '../api.scope' +import { ApplicationScope } from '../application.scope' +import { AuthScope } from '../auth.scope' +import { DocumentsScope } from '../documents.scope' +import { EndorsementsScope } from '../endorsements.scope' +import { NationalRegistryScope } from '../nationalRegistry.scope' +import { UserProfileScope } from '../userProfile.scope' + +export const servicePortalScopes = [ + 'api_resource.scope', + ApplicationScope.read, + ApplicationScope.write, + UserProfileScope.read, + UserProfileScope.write, + AuthScope.actorDelegations, + AuthScope.delegations, + AuthScope.consents, + NationalRegistryScope.individuals, + DocumentsScope.main, + EndorsementsScope.main, + EndorsementsScope.admin, + ApiScope.intellectualProperties, + ApiScope.assets, + ApiScope.education, + ApiScope.educationLicense, + ApiScope.financeOverview, + ApiScope.financeSalary, + ApiScope.financeSchedule, + ApiScope.financeLoans, + ApiScope.internal, + ApiScope.internalProcuring, + ApiScope.meDetails, + ApiScope.licenses, + ApiScope.licensesVerify, + ApiScope.company, + ApiScope.vehicles, + ApiScope.workMachines, + ApiScope.healthPayments, + ApiScope.healthMedicines, + ApiScope.healthAssistiveAndNutrition, + ApiScope.healthTherapies, + ApiScope.healthHealthcare, + ApiScope.healthRightsStatus, + ApiScope.healthDentists, + ApiScope.healthOrganDonation, + ApiScope.healthVaccinations, + ApiScope.signatureCollection, +] From 6af5e1966647364426e30f5cee8e0a56f92ff53b Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Thu, 26 Sep 2024 14:03:02 +0000 Subject: [PATCH 073/248] Remove unused import --- apps/services/bff/infra/admin-portal.infra.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/services/bff/infra/admin-portal.infra.ts b/apps/services/bff/infra/admin-portal.infra.ts index 14f1690f6765..60f9111c4401 100644 --- a/apps/services/bff/infra/admin-portal.infra.ts +++ b/apps/services/bff/infra/admin-portal.infra.ts @@ -1,4 +1,4 @@ -import { ServiceBuilder, json, service } from '../../../../infra/src/dsl/dsl' +import { ServiceBuilder, service } from '../../../../infra/src/dsl/dsl' import { createPortalEnv } from './utils/createPortalEnv' export const serviceSetup = (): ServiceBuilder<'services-bff-admin-portal'> => From fe5242cbb1d4e16824721b0a65882c67027c6cfd Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Thu, 26 Sep 2024 14:16:06 +0000 Subject: [PATCH 074/248] Add no_refresh query to user endpoint in backend --- .../src/app/modules/user/queries/get-user.query.ts | 7 +++++++ .../bff/src/app/modules/user/user.controller.ts | 12 +++++++++--- .../bff/src/app/modules/user/user.service.ts | 6 +++++- 3 files changed, 21 insertions(+), 4 deletions(-) create mode 100644 apps/services/bff/src/app/modules/user/queries/get-user.query.ts diff --git a/apps/services/bff/src/app/modules/user/queries/get-user.query.ts b/apps/services/bff/src/app/modules/user/queries/get-user.query.ts new file mode 100644 index 000000000000..cc106de0cbef --- /dev/null +++ b/apps/services/bff/src/app/modules/user/queries/get-user.query.ts @@ -0,0 +1,7 @@ +import { IsOptional, IsString } from 'class-validator' + +export class GetUserQuery { + @IsOptional() + @IsString() + no_refresh?: string +} diff --git a/apps/services/bff/src/app/modules/user/user.controller.ts b/apps/services/bff/src/app/modules/user/user.controller.ts index 1b002291fe1d..b94c54364f9e 100644 --- a/apps/services/bff/src/app/modules/user/user.controller.ts +++ b/apps/services/bff/src/app/modules/user/user.controller.ts @@ -1,8 +1,10 @@ import type { Request } from 'express' import type { BffUser } from '@island.is/shared/types' -import { Controller, Get, Req, VERSION_NEUTRAL } from '@nestjs/common' +import { Controller, Get, Query, Req, VERSION_NEUTRAL } from '@nestjs/common' +import { qsValidationPipe } from '../../utils/qs-validation-pipe' +import { GetUserQuery } from './queries/get-user.query' import { UserService } from './user.service' @Controller({ @@ -13,7 +15,11 @@ export class UserController { constructor(private readonly userService: UserService) {} @Get() - async getUser(@Req() req: Request): Promise { - return this.userService.getUser(req) + async getUser( + @Req() req: Request, + @Query(qsValidationPipe) + query: GetUserQuery, + ): Promise { + return this.userService.getUser(req, query.no_refresh === 'true') } } diff --git a/apps/services/bff/src/app/modules/user/user.service.ts b/apps/services/bff/src/app/modules/user/user.service.ts index 80b499c3aef9..e9ac1fcd4adb 100644 --- a/apps/services/bff/src/app/modules/user/user.service.ts +++ b/apps/services/bff/src/app/modules/user/user.service.ts @@ -29,7 +29,7 @@ export class UserService { } } - public async getUser(req: Request): Promise { + public async getUser(req: Request, noRefresh = false): Promise { const sid = req.cookies[SESSION_COOKIE_NAME] if (!sid) { @@ -50,6 +50,10 @@ export class UserService { // Check if the access token is expired if (isExpired(cachedTokenResponse.accessTokenExp)) { + if (noRefresh) { + throw new UnauthorizedException() + } + // Get new token data with refresh token const tokenResponse = await this.idsService.refreshToken( cachedTokenResponse.refresh_token, From fce508868c2182fb679635ec60c0385fde00bd0b Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Thu, 26 Sep 2024 16:04:58 +0000 Subject: [PATCH 075/248] Polling and broadcaster added to react spa bff library --- .../src/components/DownloadDraftButton.tsx | 17 ++- libs/react-spa/bff/src/lib/BffContext.tsx | 7 +- libs/react-spa/bff/src/lib/BffPoller.tsx | 97 ++++++++++++++ libs/react-spa/bff/src/lib/BffProvider.tsx | 107 +++++++++++---- .../bff/src/lib/BffSessionExpiredModal.tsx | 40 ++++++ libs/react-spa/bff/src/lib/bff.hooks.ts | 31 ++++- libs/react-spa/bff/src/lib/bff.state.ts | 90 +++++++------ libs/react-spa/bff/src/lib/bff.utils.ts | 30 ++++- .../shared/src/hooks/useBroadcaster.ts | 123 ++++++++++++++++++ .../react-spa/shared/src/hooks/usePolling.tsx | 111 ++++++++++++++++ libs/react-spa/shared/src/index.ts | 2 + 11 files changed, 576 insertions(+), 79 deletions(-) create mode 100644 libs/react-spa/bff/src/lib/BffPoller.tsx create mode 100644 libs/react-spa/bff/src/lib/BffSessionExpiredModal.tsx create mode 100644 libs/react-spa/shared/src/hooks/useBroadcaster.ts create mode 100644 libs/react-spa/shared/src/hooks/usePolling.tsx diff --git a/libs/portals/admin/regulations-admin/src/components/DownloadDraftButton.tsx b/libs/portals/admin/regulations-admin/src/components/DownloadDraftButton.tsx index 0810678b5176..6dea797f7a93 100644 --- a/libs/portals/admin/regulations-admin/src/components/DownloadDraftButton.tsx +++ b/libs/portals/admin/regulations-admin/src/components/DownloadDraftButton.tsx @@ -51,13 +51,18 @@ export const DownloadDraftButton = ({ draftId, reviewButton }: Props) => { if (url && !isFetchingFile) { setIsFetchingFile(true) - fetch(bffUrlGenerator(`/api?url=${url}`), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', + fetch( + bffUrlGenerator('/api', { + url, + }), + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', }, - credentials: 'include', - }) + ) .then((response) => { if (response.ok) { // Convert response to blob for download diff --git a/libs/react-spa/bff/src/lib/BffContext.tsx b/libs/react-spa/bff/src/lib/BffContext.tsx index 613672ae56c9..dc8657b37e92 100644 --- a/libs/react-spa/bff/src/lib/BffContext.tsx +++ b/libs/react-spa/bff/src/lib/BffContext.tsx @@ -2,11 +2,14 @@ import { createContext } from 'react' import { BffReducerState } from './bff.state' -export interface BffContextType extends BffReducerState { +export type BffContextType = BffReducerState & { signIn(): void signOut(): void switchUser(nationalId?: string): void - bffUrlGenerator(relativePath?: string): string + bffUrlGenerator( + relativePath?: string, + params?: Record, + ): string } export const BffContext = createContext(undefined) diff --git a/libs/react-spa/bff/src/lib/BffPoller.tsx b/libs/react-spa/bff/src/lib/BffPoller.tsx new file mode 100644 index 000000000000..dfe0d951df0d --- /dev/null +++ b/libs/react-spa/bff/src/lib/BffPoller.tsx @@ -0,0 +1,97 @@ +import { usePolling } from '@island.is/react-spa/shared' +import { BffUser } from '@island.is/shared/types' +import { ReactNode, useCallback, useEffect, useMemo } from 'react' +import { + BffBroadcastEvents, + useBff, + useBffBroadcaster, + useUserInfo, +} from './bff.hooks' +import { isNewSession } from './bff.utils' + +type BffPollerProps = { + children: ReactNode + newSessionCb(): void + pollIntervalMS?: number +} + +/** + * BffPoller component continuously polls the user's session + * information from the backend and broadcasts session changes across tabs + * or windows using the BroadcastChannel API. It checks for changes in the + * user's session data and triggers appropriate actions like displaying a + * session expired modal when necessary. + * + * Features: + * - Polls the backend at a specified interval to fetch user session data. + * - If the user's session expires or the backend returns an error, it + * automatically triggers a sign-in process. + * - If a change in user session (e.g., a new session ID) is detected, it + * broadcasts a message to all open tabs/windows and triggers the provided + * `newSessionCb` callback to handle the current tab/window. + * + * @param newSessionCb - Callback function to be called when a new session is detected. + * @param pollIntervalMS - Polling interval in milliseconds. Default is 10000ms. + * + * @usage: + * Wrap your application's root component with BffPoller to continuously + * monitor the user's session and keep session state synchronized across + * multiple tabs/windows. + */ +export const BffPoller = ({ + children, + newSessionCb, + pollIntervalMS = 10000, +}: BffPollerProps) => { + const { signIn, bffUrlGenerator } = useBff() + const userInfo = useUserInfo() + const { postMessage } = useBffBroadcaster() + + const url = useMemo( + () => bffUrlGenerator('/user', { no_refresh: 'true' }), + [bffUrlGenerator], + ) + + const fetchUser = useCallback(async () => { + const res = await fetch(url, { + credentials: 'include', + }) + + if (!res.ok) { + signIn() + + return + } + + return res.json() as Promise + }, [url, signIn]) + + // Poll user data every 10 seconds + const { data: newUser, error } = usePolling({ + fetcher: fetchUser, + intervalMs: pollIntervalMS, + waitToStartMS: 5000, + }) + + useEffect(() => { + if (error) { + // If user polling fails, likely due to 401, then sign in. + signIn() + } else if (newUser) { + // If session has changed (e.g. delegation switch), then notifiy tabs/windows/iframes and execute the callback. + if (isNewSession(newUser, userInfo)) { + // Note! The tab, window, or iframe that sends this message will not receive it. + // This is because the BroadcastChannel API does not broadcast messages to the sender. + // Therefore we need to manually handle the new session in the current tab/window, by calling the newSessionCb(). + postMessage({ + type: BffBroadcastEvents.NEW_SESSION, + userInfo: newUser, + }) + + newSessionCb() + } + } + }, [newUser, error, userInfo, signIn, postMessage, newSessionCb]) + + return children +} diff --git a/libs/react-spa/bff/src/lib/BffProvider.tsx b/libs/react-spa/bff/src/lib/BffProvider.tsx index 1511e745874d..96a88bb6173f 100644 --- a/libs/react-spa/bff/src/lib/BffProvider.tsx +++ b/libs/react-spa/bff/src/lib/BffProvider.tsx @@ -1,11 +1,16 @@ import { useEffectOnce } from '@island.is/react-spa/shared' -import { ReactNode, useCallback, useReducer } from 'react' +import { ReactNode, useCallback, useEffect, useReducer, useState } from 'react' import { LoadingScreen } from '@island.is/react/components' -import { createBffUrlGenerator, createQueryStr } from './bff.utils' import { BffContext } from './BffContext' +import { BffPoller } from './BffPoller' +import { BffSessionExpiredModal } from './BffSessionExpiredModal' import { ErrorScreen } from './ErrorScreen' -import { reducer, initialState, ActionType } from './bff.state' +import { BffBroadcastEvents, useBffBroadcaster } from './bff.hooks' +import { ActionType, initialState, reducer } from './bff.state' +import { createBffUrlGenerator, isNewSession } from './bff.utils' + +const ONE_HOUR_MS = 1000 * 60 * 60 type BffProviderProps = { children: ReactNode @@ -19,6 +24,7 @@ export const BffProvider = ({ children, applicationBasePath, }: BffProviderProps) => { + const [showSessionExpiredScreen, setSessionExpiredScreen] = useState(false) const bffUrlGenerator = createBffUrlGenerator(applicationBasePath) const [state, dispatch] = useReducer(reducer, initialState) @@ -30,13 +36,43 @@ export const BffProvider = ({ authState === 'logging-out' const isLoggedIn = authState === 'logged-in' - const checkLogin = async () => { + const { postMessage } = useBffBroadcaster((event) => { + if ( + isLoggedIn && + event.data.type === BffBroadcastEvents.NEW_SESSION && + isNewSession(state.userInfo, event.data.userInfo) + ) { + setSessionExpiredScreen(true) + } else if (event.data.type === BffBroadcastEvents.LOGOUT) { + dispatch({ + type: ActionType.LOGGED_OUT, + }) + + signIn() + } + }) + + useEffect(() => { + if (isLoggedIn) { + // Broadcast to all tabs/windows/iframes that a new session has started + postMessage({ + type: BffBroadcastEvents.NEW_SESSION, + userInfo: state.userInfo, + }) + } + }, [postMessage, state.userInfo, isLoggedIn]) + + const checkLogin = async (noRefresh = false) => { dispatch({ type: ActionType.SIGNIN_START, }) try { - const res = await fetch(bffUrlGenerator('/user'), { + const url = bffUrlGenerator('/user', { + no_refresh: noRefresh.toString(), + }) + + const res = await fetch(url, { credentials: 'include', }) @@ -61,11 +97,13 @@ export const BffProvider = ({ } const signIn = useCallback(() => { - const qs = createQueryStr({ - target_link_uri: window.location.href, + dispatch({ + type: ActionType.SIGNIN_START, }) - window.location.href = bffUrlGenerator(`/login?${qs}`) + window.location.href = bffUrlGenerator('/login', { + target_link_uri: window.location.href, + }) }, [bffUrlGenerator]) const signOut = useCallback(() => { @@ -77,34 +115,34 @@ export const BffProvider = ({ type: ActionType.LOGGING_OUT, }) - window.location.href = bffUrlGenerator( - `/logout?sid=${state.userInfo.profile.sid}`, - ) - }, [bffUrlGenerator, state.userInfo]) + window.location.href = bffUrlGenerator('/logout', { + sid: state.userInfo.profile.sid, + }) + + setTimeout(() => { + // We will wait 5 seconds before we post the logout message to other tabs/windows/iframes. + // The reason is that IDS will not log the user out immediately. + postMessage({ + type: BffBroadcastEvents.LOGOUT, + }) + }, 5000) + }, [bffUrlGenerator, postMessage, state.userInfo]) const switchUser = (nationalId?: string) => { dispatch({ type: ActionType.SWITCH_USER, }) - const qs = createQueryStr( - nationalId !== undefined + window.location.href = bffUrlGenerator( + '/login', + nationalId ? { login_hint: nationalId, - /** - * TODO: remove this. - * It is currently required to switch delegations, but we'd like - * the IDS to handle login_required and other potential road - * blocks. Now OidcSignIn is handling login_required. - */ - prompt: 'none', } : { prompt: 'select_account', }, ) - - window.location.href = bffUrlGenerator(`/login?${qs}`) } const checkQueryStringError = () => { @@ -131,6 +169,23 @@ export const BffProvider = ({ } }) + useEffectOnce(() => { + const timeout = setTimeout(() => { + // After one hour we check if the user is still logged in + // and we tell the /user endpoint not to refresh the tokens, + // since we are checking for timeout expiration. + checkLogin(true) + }, ONE_HOUR_MS) + + return () => { + clearTimeout(timeout) + } + }) + + const newSessionCb = useCallback(() => { + setSessionExpiredScreen(true) + }, []) + const onRetry = () => { window.location.href = applicationBasePath } @@ -144,8 +199,12 @@ export const BffProvider = ({ return } + if (showSessionExpiredScreen) { + return + } + if (isLoggedIn) { - return children + return {children} } return null diff --git a/libs/react-spa/bff/src/lib/BffSessionExpiredModal.tsx b/libs/react-spa/bff/src/lib/BffSessionExpiredModal.tsx new file mode 100644 index 000000000000..0e7e5545568c --- /dev/null +++ b/libs/react-spa/bff/src/lib/BffSessionExpiredModal.tsx @@ -0,0 +1,40 @@ +import { Box, Button, ProblemTemplate } from '@island.is/island-ui/core' +import { fullScreen } from './ErrorScreen.css' + +type BffSessionExpiredModalProps = { + /** + * Login callback + */ + onLogin(): void +} + +/** + * This screen is unfortunately not translated because at this point we don't have a user locale. + */ +export const BffSessionExpiredModal = ({ + onLogin, +}: BffSessionExpiredModalProps) => ( + + + Þú hefur skráð þig inn í öðru umboði. Viltu{' '} + {' '} + aftur inn? + + } + /> + +) diff --git a/libs/react-spa/bff/src/lib/bff.hooks.ts b/libs/react-spa/bff/src/lib/bff.hooks.ts index ef5487a8201a..b1cf35fa4281 100644 --- a/libs/react-spa/bff/src/lib/bff.hooks.ts +++ b/libs/react-spa/bff/src/lib/bff.hooks.ts @@ -1,7 +1,8 @@ -import { useContext } from 'react' -import { BffContext, BffContextType } from './BffContext' import { AuthContext } from '@island.is/auth/react' import { BffUser, User } from '@island.is/shared/types' +import { useContext } from 'react' +import { BffContext, BffContextType } from './BffContext' +import { createBroadcasterHook } from '@island.is/react-spa/shared' /** * Maps an object to a BffUser type. @@ -42,7 +43,7 @@ export const mapToBffUser = (input: User): BffUser => { /** * Dynamic hook to get the bff context. */ -export const useBffContext = (hookName: string): BffContextType => { +export const useDynamicBffHook = (hookName: string): BffContextType => { const bffContext = useContext(BffContext) if (!bffContext) { @@ -101,8 +102,26 @@ export const useUserInfo = (): BffUser => { * This hook is used to get the bff url generator. * The bff url generator is used to generate urls for the Bff in a conveinent way. */ -export const useBffUrlGenerator = () => { - const { bffUrlGenerator } = useBffContext('useBffUrlGenerator') +export const useBffUrlGenerator = () => + useDynamicBffHook(useBffUrlGenerator.name).bffUrlGenerator + +export const useBff = () => useDynamicBffHook(useBff.name) - return bffUrlGenerator +export enum BffBroadcastEvents { + NEW_SESSION = 'NEW_SESSION', + LOGOUT = 'LOGOUT', } + +type NewSessionEvent = { + type: BffBroadcastEvents.NEW_SESSION + userInfo: BffUser +} + +type LogoutEvent = { + type: BffBroadcastEvents.LOGOUT +} + +export type BffBroadcastEvent = NewSessionEvent | LogoutEvent + +export const useBffBroadcaster = + createBroadcasterHook('bff_auth_channel') diff --git a/libs/react-spa/bff/src/lib/bff.state.ts b/libs/react-spa/bff/src/lib/bff.state.ts index a7f16b0bee8a..d23dfd4b4f30 100644 --- a/libs/react-spa/bff/src/lib/bff.state.ts +++ b/libs/react-spa/bff/src/lib/bff.state.ts @@ -1,10 +1,10 @@ import { BffUser } from '@island.is/shared/types' +// Defining the possible states for authentication export type BffState = | 'logged-out' | 'loading' | 'logged-in' - | 'failed' | 'switching' | 'logging-out' | 'error' @@ -14,22 +14,38 @@ export enum ActionType { SIGNIN_SUCCESS = 'SIGNIN_SUCCESS', LOGGING_OUT = 'LOGGING_OUT', LOGGED_OUT = 'LOGGED_OUT', - USER_LOADED = 'USER_LOADED', SWITCH_USER = 'SWITCH_USER', ERROR = 'ERROR', } -export interface BffReducerState { - userInfo: BffUser | null +type NonLoggedInAuthState = Exclude + +export interface BffReducerStateBase { authState: BffState isAuthenticated: boolean - error?: Error + error?: Error | null +} + +// State when the user is not logged in +export interface NonLoggedInState extends BffReducerStateBase { + authState: NonLoggedInAuthState + userInfo: null } -export const initialState: BffReducerState = { +// State when the user is logged in +export interface LoggedInState extends BffReducerStateBase { + authState: 'logged-in' + userInfo: BffUser + isAuthenticated: true +} + +export type BffReducerState = NonLoggedInState | LoggedInState + +export const initialState: NonLoggedInState = { userInfo: null, authState: 'logged-out', isAuthenticated: false, + error: null, } export type Action = @@ -40,61 +56,59 @@ export type Action = | ActionType.LOGGED_OUT | ActionType.SWITCH_USER } - | { - type: ActionType.SIGNIN_SUCCESS | ActionType.USER_LOADED - payload: BffUser - } + | { type: ActionType.SIGNIN_SUCCESS; payload: BffUser } | { type: ActionType.ERROR; payload: Error } +/** + * Helper function to reset user-related state when switching users or logging out + */ +const resetState = (authState: NonLoggedInAuthState): NonLoggedInState => ({ + userInfo: null, + authState, + isAuthenticated: false, + error: null, +}) + +/** + * Reducer function to handle state transitions based on actions + */ export const reducer = ( state: BffReducerState, action: Action, ): BffReducerState => { - const withState = (newState: Partial) => ({ - ...state, - ...newState, - }) - switch (action.type) { case ActionType.SIGNIN_START: - return withState({ + return { + ...state, authState: 'loading', - }) + userInfo: null, + } case ActionType.SIGNIN_SUCCESS: - return withState({ + return { + ...state, userInfo: action.payload, authState: 'logged-in', isAuthenticated: true, - }) - - case ActionType.USER_LOADED: - return state.isAuthenticated - ? withState({ - userInfo: action.payload, - }) - : state + error: null, + } case ActionType.LOGGING_OUT: - return withState({ - authState: 'logging-out', - }) + return resetState('logging-out') case ActionType.SWITCH_USER: - return withState({ - authState: 'switching', - }) + return resetState('switching') case ActionType.ERROR: - return withState({ - authState: 'error', + return { + ...state, error: action.payload, - }) + authState: 'error', + userInfo: null, + } case ActionType.LOGGED_OUT: - return { - ...initialState, - } + return initialState default: return state diff --git a/libs/react-spa/bff/src/lib/bff.utils.ts b/libs/react-spa/bff/src/lib/bff.utils.ts index 5520da04d6c3..1c7ce0f0e1d9 100644 --- a/libs/react-spa/bff/src/lib/bff.utils.ts +++ b/libs/react-spa/bff/src/lib/bff.utils.ts @@ -1,14 +1,28 @@ +import { BffUser } from '@island.is/shared/types' + /** * Creates a function that can generate a BFF URLs. + * * @usage - * const bffBaseUrl = createBffUrlGenerator('/stjornbord) - * const userUrl = bffBaseUrl('/user') // http://localhost:3010/stjornbord/bff/user + * const bffBaseUrl = createBffUrlGenerator('/myapplication') + * const userUrl = bffBaseUrl('/user') // http://localhost:3010/myapplication/bff/user + * const userUrlWithParams = bffBaseUrl('/user', { id: '123' }) // http://localhost:3010/myapplication/bff/user?id=123 */ export const createBffUrlGenerator = (basePath: string) => { const sanitizedBasePath = sanitizePath(basePath) const baseUrl = `${window.location.origin}/${sanitizedBasePath}/bff` - return (relativePath = '') => `${baseUrl}${relativePath}` + return (relativePath = '', params?: Record) => { + const url = `${baseUrl}${relativePath}` + + if (params) { + const qs = createQueryStr(params) + + return `${url}${qs ? `?${qs}` : ''}` + } + + return url + } } /** @@ -22,3 +36,13 @@ const sanitizePath = (path: string) => path.replace(/^\/+|\/+$/g, '') export const createQueryStr = (params: Record) => { return new URLSearchParams(params).toString() } + +/** + * This method checks if the user has a new session + */ +export const isNewSession = (oldUser: BffUser, newUser: BffUser) => { + const oldSid = oldUser.profile.sid + const newSid = newUser.profile.sid + + return oldSid && newSid && oldSid !== newSid +} diff --git a/libs/react-spa/shared/src/hooks/useBroadcaster.ts b/libs/react-spa/shared/src/hooks/useBroadcaster.ts new file mode 100644 index 000000000000..fea8f0dc5d30 --- /dev/null +++ b/libs/react-spa/shared/src/hooks/useBroadcaster.ts @@ -0,0 +1,123 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +type UseBroadcasterArgs = { + channel: BroadcastChannel + onMessage?: (event: MessageEvent) => void +} + +type UseBroadcasterReturn = { + channel: BroadcastChannel + postMessage: (message: T) => void + error: Error | null +} + +/** + * Custom hook to manage communication via a BroadcastChannel. + * + * This hook: + * - Sets up a listener for incoming messages on the provided BroadcastChannel. + * - Provides a `postMessage` function to send messages through the BroadcastChannel. + * - Handles errors encountered while sending messages. + * + * @param channel - The BroadcastChannel instance to use for messaging. + * @param onMessage - Optional callback function to handle incoming messages. + * + * @returns An object containing the BroadcastChannel instance, the `postMessage` function, and any errors encountered. + */ +export const useBroadcaster = ({ + channel, + onMessage, +}: UseBroadcasterArgs): UseBroadcasterReturn => { + const [error, setError] = useState(null) + const onMessageRef = useRef(onMessage) + + useEffect(() => { + onMessageRef.current = onMessage + }, [onMessage]) + + const handleBroadcastMessage = useCallback( + (event: MessageEvent) => { + try { + onMessageRef.current?.(event) + } catch (err) { + setError(err as Error) + } + }, + [onMessageRef], + ) + + useEffect(() => { + channel.addEventListener('message', handleBroadcastMessage) + + return () => { + channel.removeEventListener('message', handleBroadcastMessage) + } + }, [channel, handleBroadcastMessage]) + + const postMessage = useCallback( + (message: T) => { + try { + channel.postMessage(message) + } catch (err) { + console.error('Error posting message to BroadcastChannel:', err) + setError(err as Error) + } + }, + [channel], + ) + + return { + channel, + postMessage, + error, + } +} + +/** + * Factory function to create a custom hook for managing a BroadcastChannel. + * + * This factory function: + * - Creates a new BroadcastChannel instance with the specified channel name. + * - Passes the instance to `useBroadcaster` along with the `onMessage` handler if provided. + * + * @param channelName - The name of the BroadcastChannel to listen to. + * + * @returns A hook that can be used to manage the BroadcastChannel and handle messages. + * + * @example + * export enum AuthBroadcastEvents { + * NEW_SESSION = 'NEW_SESSION', + * LOGOUT = 'LOGOUT', + * } + * + * type NewSessionEvent = { + * type: AuthBroadcastEvents.NEW_SESSION + * userInfo: User + * } + * + * type LogoutEvent = { + * type: AuthBroadcastEvents.LOGOUT + * } + * + * export type AuthBroadcastEvent = NewSessionEvent | LogoutEvent + * + * export const useAuthBroadcaster = createBroadcasterHook('auth_channel') + * + * const MyComponent = () => { + * const { postMessage, error } = useAuthBroadcaster((event) => { + * if (event.data.type === AuthBroadcastEvents.NEW_SESSION) { + * console.log('New session started:', event.data.userInfo) + * } + * }) + * + * useEffect(() => { + * postMessage({ type: AuthBroadcastEvents.LOGOUT }) + * }, [postMessage]) + */ +export const createBroadcasterHook = (channelName: string) => { + const channel = new BroadcastChannel(channelName) + + return (onMessage?: (event: MessageEvent) => void) => { + return useBroadcaster({ channel, onMessage }) + } +} diff --git a/libs/react-spa/shared/src/hooks/usePolling.tsx b/libs/react-spa/shared/src/hooks/usePolling.tsx new file mode 100644 index 000000000000..cb199e1fd1c0 --- /dev/null +++ b/libs/react-spa/shared/src/hooks/usePolling.tsx @@ -0,0 +1,111 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +interface PollingResult { + data?: T + loading: boolean + error: Error | null +} + +type UsePollingProps = { + /** + * A function that fetches data (returns a promise). + */ + fetcher(): Promise + /** + * The interval in milliseconds for how often to poll. + */ + intervalMs?: number + /** + * Optional prop for controlling polling externally. + */ + isCancelledProp?: boolean + /** + * The time in milliseconds to wait before starting the polling. + */ + waitToStartMS?: number +} + +/** + * usePolling is a custom hook for polling data at a specified interval. + */ +export const usePolling = ({ + fetcher, + intervalMs = 10000, + isCancelledProp, + waitToStartMS, +}: UsePollingProps): PollingResult => { + const [data, setData] = useState(undefined) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [shouldPoll, setShouldPoll] = useState(false) + + const intervalIdRef = useRef(null) + const isCancelledRef = useRef(false) + + // Sync the external isCancelledProp with the ref to ensure real-time updates + useEffect(() => { + isCancelledRef.current = !!isCancelledProp + }, [isCancelledProp]) + + const poll = useCallback(async () => { + setLoading(true) + try { + const result = await fetcher() + + if (!isCancelledRef.current) { + setData(result) + setError(null) + } + } catch (err) { + if (!isCancelledRef.current) { + setError(err as Error) + } + } finally { + if (!isCancelledRef.current) { + setLoading(false) + } + } + }, [fetcher]) + + useEffect(() => { + if (!shouldPoll) { + return + } + + // Initial poll + poll() + + // Set up the interval for polling + intervalIdRef.current = setInterval(poll, intervalMs) + + // Cleanup on unmount or when polling should stop + return () => { + isCancelledRef.current = true + + if (intervalIdRef.current) { + clearInterval(intervalIdRef.current) + } + } + }, [fetcher, intervalMs, shouldPoll, poll]) + + useEffect(() => { + let timeoutId: NodeJS.Timeout | null = null + + if (waitToStartMS) { + timeoutId = setTimeout(() => { + setShouldPoll(true) + }, waitToStartMS) + } else { + setShouldPoll(true) + } + + return () => { + // Clear the timeout if the component unmounts before the timeout is reached + if (timeoutId) { + clearTimeout(timeoutId) + } + } + }, [waitToStartMS]) + + return { data, loading, error } +} diff --git a/libs/react-spa/shared/src/index.ts b/libs/react-spa/shared/src/index.ts index 07a912e43525..d5e46c014376 100644 --- a/libs/react-spa/shared/src/index.ts +++ b/libs/react-spa/shared/src/index.ts @@ -10,6 +10,8 @@ export * from './lib/messages' // hooks export * from './hooks/useSubmitting' export * from './hooks/useEffectOnce' +export * from './hooks/usePolling' +export * from './hooks/useBroadcaster' // utils export * from './utils/getOrganizationSlugFromError' From c03478b28a8b430faae364718b49011da55e43f8 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Fri, 27 Sep 2024 09:24:25 +0000 Subject: [PATCH 076/248] Enhanced security in pkce service.and improve error handling to be more secure --- .../bff/src/app/modules/cache/cache.service.ts | 6 +++++- .../bff/src/app/services/crypto.service.ts | 4 ++-- .../bff/src/app/services/pkce.service.spec.ts | 14 ++++++++++++++ apps/services/bff/src/app/services/pkce.service.ts | 11 +++++++++++ 4 files changed, 32 insertions(+), 3 deletions(-) diff --git a/apps/services/bff/src/app/modules/cache/cache.service.ts b/apps/services/bff/src/app/modules/cache/cache.service.ts index 8b45e7541b6a..9e2f1b246c3b 100644 --- a/apps/services/bff/src/app/modules/cache/cache.service.ts +++ b/apps/services/bff/src/app/modules/cache/cache.service.ts @@ -56,6 +56,10 @@ export class CacheService { } public async delete(key: string) { - await this.cacheManager.del(key) + try { + await this.cacheManager.del(key) + } catch (error) { + throw new Error(`Failed to delete key "${key}" from cache.`) + } } } diff --git a/apps/services/bff/src/app/services/crypto.service.ts b/apps/services/bff/src/app/services/crypto.service.ts index 8ca73afaec51..c338da4d48ec 100644 --- a/apps/services/bff/src/app/services/crypto.service.ts +++ b/apps/services/bff/src/app/services/crypto.service.ts @@ -61,7 +61,7 @@ export class CryptoService { // The IV is used in decryption. return `${this.algorithm}:${iv.toString('base64')}:${encrypted}` } catch (error) { - this.logger.error('Error encrypting text:', error) + this.logger.error('Error encrypting text:', { message: error.message }) throw new Error('Failed to encrypt the text.') } @@ -103,7 +103,7 @@ export class CryptoService { // Return the decrypted plaintext return decrypted } catch (error) { - this.logger.error('Error decrypting text:', error) + this.logger.error('Error decrypting text:', { message: error.message }) throw new Error('Failed to decrypt the text.') } diff --git a/apps/services/bff/src/app/services/pkce.service.spec.ts b/apps/services/bff/src/app/services/pkce.service.spec.ts index b469fba3eb8e..6b0431f45a7c 100644 --- a/apps/services/bff/src/app/services/pkce.service.spec.ts +++ b/apps/services/bff/src/app/services/pkce.service.spec.ts @@ -18,6 +18,8 @@ describe('PKCEService', () => { const verifierTestCases = [ { length: 50, description: 'default length 50' }, { length: 64, description: 'specified length 64' }, + { length: 43, description: 'minimum length 43' }, + { length: 128, description: 'maximum length 128' }, ] verifierTestCases.forEach(({ length, description }) => { @@ -28,6 +30,18 @@ describe('PKCEService', () => { expect(verifier).toMatch(ALLOWED_VERIFIER_CHARACTERS_REGEX) }) }) + + it('should throw an error if the length is less than 43', async () => { + await expect(service.generateVerifier(42)).rejects.toThrow( + 'Length must be a positive integer between 43 and 128', + ) + }) + + it('should throw an error if the length is greater than 128', async () => { + await expect(service.generateVerifier(129)).rejects.toThrow( + 'Length must be a positive integer between 43 and 128', + ) + }) }) describe('generateCodeVerifier', () => { diff --git a/apps/services/bff/src/app/services/pkce.service.ts b/apps/services/bff/src/app/services/pkce.service.ts index 179f44617e1a..96c2375b5b2c 100644 --- a/apps/services/bff/src/app/services/pkce.service.ts +++ b/apps/services/bff/src/app/services/pkce.service.ts @@ -43,8 +43,19 @@ export class PKCEService { /** * Generate a PKCE challenge verifier * Generates cryptographically strong random string + * + * @param length The length of the verifier to generate + * + * Note! According to the RFC from OAuth 2.0 PKCE RFC 7636, the PKCE code verifier must have a length between 43 and 128 characters. */ async generateVerifier(length = 50): Promise { + // Enforce PKCE length requirements: 43 <= length <= 128 + if (!Number.isInteger(length) || length < 43 || length > 128) { + throw new Error( + 'Length must be a positive integer between 43 and 128, inclusive', + ) + } + const mask = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~' From 7a61d8a9fb4b58de4bd2324f08bb0d5ff1bfad78 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Fri, 27 Sep 2024 09:39:10 +0000 Subject: [PATCH 077/248] Update usePolling to have better types and secure resumabiltiy. --- .../src/components/DownloadDraftButton.tsx | 2 ++ libs/react-spa/shared/src/hooks/usePolling.tsx | 12 +++++++----- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/libs/portals/admin/regulations-admin/src/components/DownloadDraftButton.tsx b/libs/portals/admin/regulations-admin/src/components/DownloadDraftButton.tsx index 6dea797f7a93..ecb74181bf88 100644 --- a/libs/portals/admin/regulations-admin/src/components/DownloadDraftButton.tsx +++ b/libs/portals/admin/regulations-admin/src/components/DownloadDraftButton.tsx @@ -70,6 +70,8 @@ export const DownloadDraftButton = ({ draftId, reviewButton }: Props) => { const downloadUrl = URL.createObjectURL(blob) // Open the download URL in a new tab window.open(downloadUrl, '_newtab') + // Release the object URL to free up memory + URL.revokeObjectURL(downloadUrl) }) } else { toast.error(t(editorMsgs.signedDocumentDownloadFreshError)) diff --git a/libs/react-spa/shared/src/hooks/usePolling.tsx b/libs/react-spa/shared/src/hooks/usePolling.tsx index cb199e1fd1c0..72d821a62eb9 100644 --- a/libs/react-spa/shared/src/hooks/usePolling.tsx +++ b/libs/react-spa/shared/src/hooks/usePolling.tsx @@ -39,7 +39,7 @@ export const usePolling = ({ const [error, setError] = useState(null) const [shouldPoll, setShouldPoll] = useState(false) - const intervalIdRef = useRef(null) + const intervalIdRef = useRef | null>(null) const isCancelledRef = useRef(false) // Sync the external isCancelledProp with the ref to ensure real-time updates @@ -61,13 +61,15 @@ export const usePolling = ({ setError(err as Error) } } finally { - if (!isCancelledRef.current) { - setLoading(false) - } + setLoading(false) } }, [fetcher]) useEffect(() => { + // The cleanup function sets isCancelled to true to stop polling + // If the polling resumes then we set it back to false + isCancelledRef.current = false + if (!shouldPoll) { return } @@ -89,7 +91,7 @@ export const usePolling = ({ }, [fetcher, intervalMs, shouldPoll, poll]) useEffect(() => { - let timeoutId: NodeJS.Timeout | null = null + let timeoutId: ReturnType | null = null if (waitToStartMS) { timeoutId = setTimeout(() => { From 1c01c41406cb171de815d338d926516f2725c9ef Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Fri, 27 Sep 2024 09:41:48 +0000 Subject: [PATCH 078/248] Refactor useBroadcaster. --- .../shared/src/hooks/useBroadcaster.ts | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/libs/react-spa/shared/src/hooks/useBroadcaster.ts b/libs/react-spa/shared/src/hooks/useBroadcaster.ts index fea8f0dc5d30..fbb55f7c046a 100644 --- a/libs/react-spa/shared/src/hooks/useBroadcaster.ts +++ b/libs/react-spa/shared/src/hooks/useBroadcaster.ts @@ -6,7 +6,6 @@ type UseBroadcasterArgs = { } type UseBroadcasterReturn = { - channel: BroadcastChannel postMessage: (message: T) => void error: Error | null } @@ -35,16 +34,13 @@ export const useBroadcaster = ({ onMessageRef.current = onMessage }, [onMessage]) - const handleBroadcastMessage = useCallback( - (event: MessageEvent) => { - try { - onMessageRef.current?.(event) - } catch (err) { - setError(err as Error) - } - }, - [onMessageRef], - ) + const handleBroadcastMessage = useCallback((event: MessageEvent) => { + try { + onMessageRef.current?.(event) + } catch (err) { + setError(err as Error) + } + }, []) useEffect(() => { channel.addEventListener('message', handleBroadcastMessage) @@ -52,7 +48,8 @@ export const useBroadcaster = ({ return () => { channel.removeEventListener('message', handleBroadcastMessage) } - }, [channel, handleBroadcastMessage]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [channel]) const postMessage = useCallback( (message: T) => { @@ -67,7 +64,6 @@ export const useBroadcaster = ({ ) return { - channel, postMessage, error, } From 361f60a7bb04c641c21d7fc72ca6eb9ed428d7c0 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Fri, 27 Sep 2024 12:54:58 +0000 Subject: [PATCH 079/248] Add client logic to handle the case if bff server goes down --- libs/react-spa/bff/src/lib/BffProvider.tsx | 16 +++++++++++++++- libs/react-spa/bff/src/lib/ErrorScreen.tsx | 8 ++++++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/libs/react-spa/bff/src/lib/BffProvider.tsx b/libs/react-spa/bff/src/lib/BffProvider.tsx index 96a88bb6173f..cee6650fa549 100644 --- a/libs/react-spa/bff/src/lib/BffProvider.tsx +++ b/libs/react-spa/bff/src/lib/BffProvider.tsx @@ -11,6 +11,7 @@ import { ActionType, initialState, reducer } from './bff.state' import { createBffUrlGenerator, isNewSession } from './bff.utils' const ONE_HOUR_MS = 1000 * 60 * 60 +const BFF_SERVER_UNAVAILABLE = 'BFF_SERVER_UNAVAILABLE' type BffProviderProps = { children: ReactNode @@ -77,6 +78,12 @@ export const BffProvider = ({ }) if (!res.ok) { + // Bff server is down + if (res.status >= 500) { + throw new Error(BFF_SERVER_UNAVAILABLE) + } + + // For other none ok responses, like 401/403, proceed with sign-in redirect. signIn() return @@ -192,7 +199,14 @@ export const BffProvider = ({ const renderContent = () => { if (showErrorScreen) { - return + return ( + + ) } if (showLoadingScreen) { diff --git a/libs/react-spa/bff/src/lib/ErrorScreen.tsx b/libs/react-spa/bff/src/lib/ErrorScreen.tsx index 9d73d075e781..dbb6b48f1ed4 100644 --- a/libs/react-spa/bff/src/lib/ErrorScreen.tsx +++ b/libs/react-spa/bff/src/lib/ErrorScreen.tsx @@ -2,6 +2,7 @@ import { Box, Button, ProblemTemplate } from '@island.is/island-ui/core' import { fullScreen } from './ErrorScreen.css' type ErrorScreenProps = { + title?: string /** * Retry callback */ @@ -11,7 +12,10 @@ type ErrorScreenProps = { /** * This screen is unfortunately not translated because at this point we don't have a user locale. */ -export const ErrorScreen = ({ onRetry }: ErrorScreenProps) => ( +export const ErrorScreen = ({ + title = 'Innskráning mistókst', + onRetry, +}: ErrorScreenProps) => ( ( variant="error" expand tag="Villa" - title="Innskráning mistókst" + title={title} message={ <> Vinsamlegast reyndu aftur síðar.{' '} From 332c7e36b5704b81709c3a4304c443e6d6e3e100 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Mon, 30 Sep 2024 10:43:56 +0000 Subject: [PATCH 080/248] Fix tests and builds --- apps/services/bff/jest.config.ts | 1 + .../bff/src/app/modules/ids/ids.service.ts | 2 +- apps/services/bff/test/setup.ts | 3 +++ .../templates/estate/jest.config.ts | 1 + .../templates/estate/jest.setup.ts | 3 +++ libs/application/ui-shell/jest.setup.ts | 3 +++ libs/portals/core/test/setup.ts | 3 +++ libs/react-spa/shared/mocks/index.ts | 1 + .../shared/mocks/mockBroadcastChannel.ts | 23 +++++++++++++++++++ libs/shared/components/jest.setup.ts | 3 +++ tsconfig.base.json | 3 +++ 11 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 apps/services/bff/test/setup.ts create mode 100644 libs/application/templates/estate/jest.setup.ts create mode 100644 libs/react-spa/shared/mocks/index.ts create mode 100644 libs/react-spa/shared/mocks/mockBroadcastChannel.ts diff --git a/apps/services/bff/jest.config.ts b/apps/services/bff/jest.config.ts index 07386b89f4f5..3564c051f849 100644 --- a/apps/services/bff/jest.config.ts +++ b/apps/services/bff/jest.config.ts @@ -14,4 +14,5 @@ export default { coverageDirectory: '/coverage/apps/services/bff', displayName: 'bff', collectCoverageFrom: ['src/**/*.ts'], + setupFilesAfterEnv: [`${__dirname}/test/setup.ts`], } diff --git a/apps/services/bff/src/app/modules/ids/ids.service.ts b/apps/services/bff/src/app/modules/ids/ids.service.ts index c1c044faf487..2078604918df 100644 --- a/apps/services/bff/src/app/modules/ids/ids.service.ts +++ b/apps/services/bff/src/app/modules/ids/ids.service.ts @@ -3,7 +3,7 @@ import { LOGGER_PROVIDER } from '@island.is/logging' import { Inject, Injectable } from '@nestjs/common' import { ConfigType } from '@nestjs/config' -import { EnhancedFetchAPI } from '@island.is/clients/middlewares' +import type { EnhancedFetchAPI } from '@island.is/clients/middlewares' import { BffConfig } from '../../bff.config' import { CryptoService } from '../../services/crypto.service' import { ENHANCED_FETCH_PROVIDER_KEY } from '../enhancedFetch/enhanced-fetch.provider' diff --git a/apps/services/bff/test/setup.ts b/apps/services/bff/test/setup.ts new file mode 100644 index 000000000000..4a9c7f4d0cca --- /dev/null +++ b/apps/services/bff/test/setup.ts @@ -0,0 +1,3 @@ +process.env.PORT = '3010' +process.env.BFF_CLIENT_KEY_PATH = '/my-client-key-path' +process.env.BFF_NAME = 'bff-some-placeholder-name' diff --git a/libs/application/templates/estate/jest.config.ts b/libs/application/templates/estate/jest.config.ts index 6866304f6899..cf69af640735 100644 --- a/libs/application/templates/estate/jest.config.ts +++ b/libs/application/templates/estate/jest.config.ts @@ -12,4 +12,5 @@ export default { moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], coverageDirectory: '/coverage/libs/application/templates/estate', displayName: 'application-templates-estate', + setupFilesAfterEnv: [`${__dirname}/jest.setup.ts`], } diff --git a/libs/application/templates/estate/jest.setup.ts b/libs/application/templates/estate/jest.setup.ts new file mode 100644 index 000000000000..4bd52a20dcaa --- /dev/null +++ b/libs/application/templates/estate/jest.setup.ts @@ -0,0 +1,3 @@ +import { MockBroadcastChannel } from '@island.is/react-spa/shared/mocks' + +global.BroadcastChannel = MockBroadcastChannel diff --git a/libs/application/ui-shell/jest.setup.ts b/libs/application/ui-shell/jest.setup.ts index ddce481f3b36..09897a369edf 100644 --- a/libs/application/ui-shell/jest.setup.ts +++ b/libs/application/ui-shell/jest.setup.ts @@ -1,4 +1,7 @@ import '@testing-library/jest-dom/extend-expect' import '@vanilla-extract/css/disableRuntimeStyles' +import { MockBroadcastChannel } from '@island.is/react-spa/shared/mocks' + +global.BroadcastChannel = MockBroadcastChannel window.scrollTo = () => undefined diff --git a/libs/portals/core/test/setup.ts b/libs/portals/core/test/setup.ts index 7986237687fd..8d00d3048b23 100644 --- a/libs/portals/core/test/setup.ts +++ b/libs/portals/core/test/setup.ts @@ -1,2 +1,5 @@ // Prevent all styles creation when running tests as they don´t depend on styling import '@vanilla-extract/css/disableRuntimeStyles' +import { MockBroadcastChannel } from '@island.is/react-spa/shared/mocks' + +global.BroadcastChannel = MockBroadcastChannel diff --git a/libs/react-spa/shared/mocks/index.ts b/libs/react-spa/shared/mocks/index.ts new file mode 100644 index 000000000000..5010f8d04076 --- /dev/null +++ b/libs/react-spa/shared/mocks/index.ts @@ -0,0 +1 @@ +export * from './mockBroadcastChannel' diff --git a/libs/react-spa/shared/mocks/mockBroadcastChannel.ts b/libs/react-spa/shared/mocks/mockBroadcastChannel.ts new file mode 100644 index 000000000000..141fcb5c0d60 --- /dev/null +++ b/libs/react-spa/shared/mocks/mockBroadcastChannel.ts @@ -0,0 +1,23 @@ +export class MockBroadcastChannel { + constructor() { + this.onmessage = null + } + + postMessage() { + // No-op + } + + close() { + // No-op + } + + addEventListener(type, listener) { + if (type === 'message') { + this.onmessage = listener + } + } + + removeEventListener() { + // No-op + } +} diff --git a/libs/shared/components/jest.setup.ts b/libs/shared/components/jest.setup.ts index cc8fccdb84f7..2ba6b2fab9d9 100644 --- a/libs/shared/components/jest.setup.ts +++ b/libs/shared/components/jest.setup.ts @@ -1,2 +1,5 @@ import '@testing-library/jest-dom/extend-expect' import '@vanilla-extract/css/disableRuntimeStyles' +import { MockBroadcastChannel } from '@island.is/react-spa/shared/mocks' + +global.BroadcastChannel = MockBroadcastChannel diff --git a/tsconfig.base.json b/tsconfig.base.json index 919e09c02f3b..092d9d14d822 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -974,6 +974,9 @@ ], "@island.is/react-spa/bff": ["libs/react-spa/bff/src/index.ts"], "@island.is/react-spa/shared": ["libs/react-spa/shared/src/index.ts"], + "@island.is/react-spa/shared/mocks": [ + "libs/react-spa/shared/mocks/index.ts" + ], "@island.is/react/components": ["libs/react/components/src/index.ts"], "@island.is/react/feature-flags": [ "libs/react/feature-flags/src/index.ts" From 009ddcc33d237696883ce06ef6baac12ed50a378 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Thu, 3 Oct 2024 10:54:59 +0000 Subject: [PATCH 081/248] Fix portal infra local vars --- .../bff/infra/utils/createPortalEnv.ts | 26 ++++++++++++++----- .../bff/src/app/services/crypto.service.ts | 6 +---- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/apps/services/bff/infra/utils/createPortalEnv.ts b/apps/services/bff/infra/utils/createPortalEnv.ts index 72fcc9c0b825..4b15b8b84073 100644 --- a/apps/services/bff/infra/utils/createPortalEnv.ts +++ b/apps/services/bff/infra/utils/createPortalEnv.ts @@ -5,9 +5,10 @@ import { DEFAULT_CACHE_USER_PROFILE_TTL_MS } from '../../src/app/constants/time' type PortalKeys = 'stjornbord' | 'minarsidur' const defaultEnvUrls = { - dev: 'https://beta.dev01.devland.is', - staging: 'https://beta.staging01.devland.is', - prod: 'https://island.is', + local: json(['http://localhost:4200/stjornbord']), + dev: json(['https://beta.dev01.devland.is']), + staging: json(['https://beta.staging01.devland.is']), + prod: json(['https://island.is']), } const getScopes = (key: PortalKeys) => { @@ -29,29 +30,40 @@ export const createPortalEnv = (key: PortalKeys) => { IDENTITY_SERVER_CLIENT_SCOPES: json(getScopes(key)), IDENTITY_SERVER_CLIENT_ID: `@admin.island.is/bff-${key}`, IDENTITY_SERVER_ISSUER_URL: { + local: 'https://identity-server.dev01.devland.is', dev: 'https://identity-server.dev01.devland.is', staging: 'https://identity-server.staging01.devland.is', prod: 'https://innskra.island.is', }, // BFF + BFF_NAME: { + local: key, + dev: key, + staging: key, + prod: key, + }, + BFF_CLIENT_KEY_PATH: `/${key}`, + BFF_PAR_SUPPORT_ENABLED: 'false', BFF_ALLOWED_REDIRECT_URIS: defaultEnvUrls, BFF_CLIENT_BASE_URL: defaultEnvUrls, BFF_LOGOUT_REDIRECT_URI: defaultEnvUrls, - BFF_CLIENT_KEY_PATH: `/${key}`, BFF_CALLBACKS_BASE_PATH: { + local: `http://localhost:3010/${key}/bff/callbacks`, dev: `https://beta.dev01.devland.is/${key}/bff/callbacks`, staging: `https://beta.staging01.devland.is/${key}/bff/callbacks`, prod: `https://island.is/${key}/bff/callbacks`, }, BFF_PROXY_API_ENDPOINT: { + local: 'http://localhost:4444/api/graphql', dev: 'https://beta.dev01.devland.is/api/graphql', staging: 'https://beta.staging01.devland.is/api/graphql', prod: 'https://island.is/api/graphql', }, BFF_ALLOWED_EXTERNAL_API_URLS: { - dev: 'https://api.dev01.devland.is', - staging: 'https://api.staging01.devland.is', - prod: 'https://api.island.is', + local: json(['https://api.dev01.devland.is']), + dev: json(['https://api.dev01.devland.is']), + staging: json(['https://api.staging01.devland.is']), + prod: json(['https://api.island.is']), }, BFF_CACHE_USER_PROFILE_TTL_MS: DEFAULT_CACHE_USER_PROFILE_TTL_MS.toString(), } diff --git a/apps/services/bff/src/app/services/crypto.service.ts b/apps/services/bff/src/app/services/crypto.service.ts index c338da4d48ec..f5b39526b7c1 100644 --- a/apps/services/bff/src/app/services/crypto.service.ts +++ b/apps/services/bff/src/app/services/crypto.service.ts @@ -1,10 +1,6 @@ import type { Logger } from '@island.is/logging' import { LOGGER_PROVIDER } from '@island.is/logging' -import { - Inject, - Injectable, - InternalServerErrorException, -} from '@nestjs/common' +import { Inject, Injectable } from '@nestjs/common' import { ConfigType } from '@nestjs/config' import * as crypto from 'crypto' import { BffConfig } from '../bff.config' From c7f4a3c9a2fca3aa350e30b3522f158d3cfe38f3 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Thu, 3 Oct 2024 14:59:18 +0000 Subject: [PATCH 082/248] DX infra setup for services-bff --- .gitignore | 1 + apps/portals/admin/project.json | 39 ++++++++-- .../bff/infra/utils/createPortalEnv.ts | 6 +- charts/islandis/values.dev.yaml | 75 ++++++++++++++++++ charts/islandis/values.prod.yaml | 78 +++++++++++++++++++ charts/islandis/values.staging.yaml | 76 ++++++++++++++++++ .../dsl/value-files-generators/local-setup.ts | 9 +++ infra/src/uber-charts/islandis.ts | 11 ++- 8 files changed, 285 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index e5cc4dfbeb64..a83266ecb971 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ tmp/ out-tsc/ **/tsconfig.tsbuildinfo infra/mountebank-imposter-config.json +mountebank-imposter-config.json infra/helm/**/*.tgz # dependencies diff --git a/apps/portals/admin/project.json b/apps/portals/admin/project.json index dddd11104b36..bcf34eb1072f 100644 --- a/apps/portals/admin/project.json +++ b/apps/portals/admin/project.json @@ -3,11 +3,15 @@ "$schema": "../../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "apps/portals/admin/src", "projectType": "application", - "tags": ["scope:portals-admin"], + "tags": [ + "scope:portals-admin" + ], "targets": { "build": { "executor": "@nx/webpack:webpack", - "outputs": ["{options.outputPath}"], + "outputs": [ + "{options.outputPath}" + ], "defaultConfiguration": "production", "options": { "compiler": "babel", @@ -22,7 +26,9 @@ "apps/portals/admin/src/mockServiceWorker.js", "apps/portals/admin/src/assets" ], - "styles": ["apps/portals/admin/src/styles.css"], + "styles": [ + "apps/portals/admin/src/styles.css" + ], "scripts": [], "webpackConfig": "apps/portals/admin/webpack.config.js" }, @@ -49,7 +55,9 @@ "node scripts/dockerfile-assets/bash/extract-environment.js apps/portals/admin/src" ] }, - "outputs": ["{workspaceRoot}/apps/portals/admin/src/index.html"] + "outputs": [ + "{workspaceRoot}/apps/portals/admin/src/index.html" + ] }, "serve": { "executor": "@nx/webpack:dev-server", @@ -75,7 +83,9 @@ }, "test": { "executor": "@nx/jest:jest", - "outputs": ["{workspaceRoot}/coverage/apps/portals/admin"], + "outputs": [ + "{workspaceRoot}/coverage/apps/portals/admin" + ], "options": { "jestConfig": "apps/portals/admin/jest.config.ts" } @@ -89,15 +99,28 @@ "dev-init": { "executor": "nx:run-commands", "options": { - "commands": ["yarn get-secrets portals-admin"], + "commands": [ + "yarn get-secrets portals-admin" + ], "parallel": false } }, + "start-bff": { + "executor": "nx:run-commands", + "options": { + "commands": [ + "node -r esbuild-register src/cli/cli.ts run-local-env --service services-bff-admin-portal" + ], + "cwd": "infra" + } + }, "dev": { "executor": "nx:run-commands", "options": { - "commands": ["yarn start portals-admin"], - "parallel": true + "commands": [ + "yarn nx run portals-admin:start-bff", + "yarn start portals-admin" + ] } }, "docker-static": { diff --git a/apps/services/bff/infra/utils/createPortalEnv.ts b/apps/services/bff/infra/utils/createPortalEnv.ts index 4b15b8b84073..bbcb12fd1b04 100644 --- a/apps/services/bff/infra/utils/createPortalEnv.ts +++ b/apps/services/bff/infra/utils/createPortalEnv.ts @@ -1,4 +1,8 @@ -import { adminPortalScopes, servicePortalScopes } from '@island.is/auth/scopes' +// eslint-disable-next-line @nx/enforce-module-boundaries +import { + adminPortalScopes, + servicePortalScopes, +} from '../../../../../libs/auth/scopes/src/index' import { json } from '../../../../../infra/src/dsl/dsl' import { DEFAULT_CACHE_USER_PROFILE_TTL_MS } from '../../src/app/constants/time' diff --git a/charts/islandis/values.dev.yaml b/charts/islandis/values.dev.yaml index 54fe3e284c71..21d88139a393 100644 --- a/charts/islandis/values.dev.yaml +++ b/charts/islandis/values.dev.yaml @@ -1791,6 +1791,7 @@ namespaces: - 'services-sessions' - 'contentful-apps' - 'services-university-gateway' + - 'services-bff' portals-admin: enabled: true env: @@ -2278,6 +2279,80 @@ service-portal-api: eks.amazonaws.com/role-arn: 'arn:aws:iam::013313053092:role/service-portal-api' create: true name: 'service-portal-api' +services-bff-admin-portal: + enabled: true + env: + BFF_ALLOWED_EXTERNAL_API_URLS: '["https://api.dev01.devland.is"]' + BFF_ALLOWED_REDIRECT_URIS: '["https://beta.dev01.devland.is"]' + BFF_CACHE_USER_PROFILE_TTL_MS: '3595000' + BFF_CALLBACKS_BASE_PATH: 'https://beta.dev01.devland.is/stjornbord/bff/callbacks' + BFF_CLIENT_BASE_URL: '["https://beta.dev01.devland.is"]' + BFF_CLIENT_KEY_PATH: '/stjornbord' + BFF_LOGOUT_REDIRECT_URI: '["https://beta.dev01.devland.is"]' + BFF_NAME: 'stjornbord' + BFF_PAR_SUPPORT_ENABLED: 'false' + BFF_PROXY_API_ENDPOINT: 'https://beta.dev01.devland.is/api/graphql' + IDENTITY_SERVER_CLIENT_ID: '@admin.island.is/bff-stjornbord' + IDENTITY_SERVER_CLIENT_SCOPES: '["@admin.island.is/delegations","@admin.island.is/ads","@admin.island.is/regulations","@admin.island.is/regulations:manage","@admin.island.is/icelandic-names-registry","@admin.island.is/application-system:admin","@admin.island.is/application-system:institution","@admin.island.is/document-provider","@admin.island.is/auth","@admin.island.is/auth:admin","@admin.island.is/petitions","@admin.island.is/service-desk","@admin.island.is/ads:explicit","@admin.island.is/signature-collection:manage","@admin.island.is/signature-collection:process","@admin.island.is/form-system","@admin.island.is/form-system:admin","@admin.island.is/delegation-system","@admin.island.is/delegation-system:admin"]' + IDENTITY_SERVER_ISSUER_URL: 'https://identity-server.dev01.devland.is' + LOG_LEVEL: 'info' + NODE_OPTIONS: '--max-old-space-size=460 -r dd-trace/init' + REDIS_URL_NODE_01: '["clustercfg.general-redis-cluster-group.5fzau3.euw1.cache.amazonaws.com:6379"]' + SERVERSIDE_FEATURES_ON: '' + grantNamespaces: [] + grantNamespacesEnabled: false + healthCheck: + liveness: + initialDelaySeconds: 3 + path: '/liveness' + timeoutSeconds: 3 + readiness: + initialDelaySeconds: 3 + path: '/health/check' + timeoutSeconds: 3 + hpa: + scaling: + metric: + cpuAverageUtilization: 90 + nginxRequestsIrate: 5 + replicas: + max: 10 + min: 2 + image: + repository: '821090935708.dkr.ecr.eu-west-1.amazonaws.com/services-bff' + ingress: + primary-alb: + annotations: + kubernetes.io/ingress.class: 'nginx-external-alb' + nginx.ingress.kubernetes.io/proxy-buffer-size: '8k' + nginx.ingress.kubernetes.io/proxy-buffering: 'on' + nginx.ingress.kubernetes.io/service-upstream: 'true' + hosts: + - host: 'beta.dev01.devland.is' + paths: + - '/stjornbord/bff' + namespace: 'services-bff' + podDisruptionBudget: + maxUnavailable: 1 + pvcs: [] + replicaCount: + default: 2 + max: 10 + min: 2 + resources: + limits: + cpu: '400m' + memory: '512Mi' + requests: + cpu: '100m' + memory: '256Mi' + secrets: + BFF_TOKEN_SECRET_BASE64: '/k8s/services-bff/admin-portal/BFF_TOKEN_SECRET_BASE64' + CONFIGCAT_SDK_KEY: '/k8s/configcat/CONFIGCAT_SDK_KEY' + IDENTITY_SERVER_CLIENT_SECRET: '/k8s/services-bff/admin-portal/IDENTITY_SERVER_CLIENT_SECRET' + securityContext: + allowPrivilegeEscalation: false + privileged: false services-documents: enabled: true env: diff --git a/charts/islandis/values.prod.yaml b/charts/islandis/values.prod.yaml index 92b9250b3f09..390de1a2dca2 100644 --- a/charts/islandis/values.prod.yaml +++ b/charts/islandis/values.prod.yaml @@ -1656,6 +1656,7 @@ namespaces: - 'services-university-gateway' - 'contentful-apps' - 'contentful-entry-tagger' + - 'services-bff' portals-admin: enabled: true env: @@ -2149,6 +2150,83 @@ service-portal-api: eks.amazonaws.com/role-arn: 'arn:aws:iam::251502586493:role/service-portal-api' create: true name: 'service-portal-api' +services-bff-admin-portal: + enabled: true + env: + BFF_ALLOWED_EXTERNAL_API_URLS: '["https://api.island.is"]' + BFF_ALLOWED_REDIRECT_URIS: '["https://island.is"]' + BFF_CACHE_USER_PROFILE_TTL_MS: '3595000' + BFF_CALLBACKS_BASE_PATH: 'https://island.is/stjornbord/bff/callbacks' + BFF_CLIENT_BASE_URL: '["https://island.is"]' + BFF_CLIENT_KEY_PATH: '/stjornbord' + BFF_LOGOUT_REDIRECT_URI: '["https://island.is"]' + BFF_NAME: 'stjornbord' + BFF_PAR_SUPPORT_ENABLED: 'false' + BFF_PROXY_API_ENDPOINT: 'https://island.is/api/graphql' + IDENTITY_SERVER_CLIENT_ID: '@admin.island.is/bff-stjornbord' + IDENTITY_SERVER_CLIENT_SCOPES: '["@admin.island.is/delegations","@admin.island.is/ads","@admin.island.is/regulations","@admin.island.is/regulations:manage","@admin.island.is/icelandic-names-registry","@admin.island.is/application-system:admin","@admin.island.is/application-system:institution","@admin.island.is/document-provider","@admin.island.is/auth","@admin.island.is/auth:admin","@admin.island.is/petitions","@admin.island.is/service-desk","@admin.island.is/ads:explicit","@admin.island.is/signature-collection:manage","@admin.island.is/signature-collection:process","@admin.island.is/form-system","@admin.island.is/form-system:admin","@admin.island.is/delegation-system","@admin.island.is/delegation-system:admin"]' + IDENTITY_SERVER_ISSUER_URL: 'https://innskra.island.is' + LOG_LEVEL: 'info' + NODE_OPTIONS: '--max-old-space-size=460 -r dd-trace/init' + REDIS_URL_NODE_01: '["clustercfg.general-redis-cluster-group.whakos.euw1.cache.amazonaws.com:6379"]' + SERVERSIDE_FEATURES_ON: 'driving-license-use-v1-endpoint-for-v2-comms' + grantNamespaces: [] + grantNamespacesEnabled: false + healthCheck: + liveness: + initialDelaySeconds: 3 + path: '/liveness' + timeoutSeconds: 3 + readiness: + initialDelaySeconds: 3 + path: '/health/check' + timeoutSeconds: 3 + hpa: + scaling: + metric: + cpuAverageUtilization: 90 + nginxRequestsIrate: 5 + replicas: + max: 10 + min: 2 + image: + repository: '821090935708.dkr.ecr.eu-west-1.amazonaws.com/services-bff' + ingress: + primary-alb: + annotations: + kubernetes.io/ingress.class: 'nginx-external-alb' + nginx.ingress.kubernetes.io/proxy-buffer-size: '8k' + nginx.ingress.kubernetes.io/proxy-buffering: 'on' + nginx.ingress.kubernetes.io/service-upstream: 'true' + hosts: + - host: 'island.is' + paths: + - '/stjornbord/bff' + - host: 'www.island.is' + paths: + - '/stjornbord/bff' + namespace: 'services-bff' + podDisruptionBudget: + maxUnavailable: 1 + pvcs: [] + replicaCount: + default: 2 + max: 10 + min: 2 + resources: + limits: + cpu: '400m' + memory: '512Mi' + requests: + cpu: '100m' + memory: '256Mi' + secrets: + BFF_TOKEN_SECRET_BASE64: '/k8s/services-bff/admin-portal/BFF_TOKEN_SECRET_BASE64' + CONFIGCAT_SDK_KEY: '/k8s/configcat/CONFIGCAT_SDK_KEY' + IDENTITY_SERVER_CLIENT_SECRET: '/k8s/services-bff/admin-portal/IDENTITY_SERVER_CLIENT_SECRET' + securityContext: + allowPrivilegeEscalation: false + privileged: false services-documents: enabled: true env: diff --git a/charts/islandis/values.staging.yaml b/charts/islandis/values.staging.yaml index 5fbb15a89a49..ed87e1e1f88e 100644 --- a/charts/islandis/values.staging.yaml +++ b/charts/islandis/values.staging.yaml @@ -1532,6 +1532,7 @@ namespaces: - 'license-api' - 'services-sessions' - 'services-university-gateway' + - 'services-bff' portals-admin: enabled: true env: @@ -2021,6 +2022,81 @@ service-portal-api: eks.amazonaws.com/role-arn: 'arn:aws:iam::261174024191:role/service-portal-api' create: true name: 'service-portal-api' +services-bff-admin-portal: + enabled: true + env: + BFF_ALLOWED_EXTERNAL_API_URLS: '["https://api.staging01.devland.is"]' + BFF_ALLOWED_REDIRECT_URIS: '["https://beta.staging01.devland.is"]' + BFF_CACHE_USER_PROFILE_TTL_MS: '3595000' + BFF_CALLBACKS_BASE_PATH: 'https://beta.staging01.devland.is/stjornbord/bff/callbacks' + BFF_CLIENT_BASE_URL: '["https://beta.staging01.devland.is"]' + BFF_CLIENT_KEY_PATH: '/stjornbord' + BFF_LOGOUT_REDIRECT_URI: '["https://beta.staging01.devland.is"]' + BFF_NAME: 'stjornbord' + BFF_PAR_SUPPORT_ENABLED: 'false' + BFF_PROXY_API_ENDPOINT: 'https://beta.staging01.devland.is/api/graphql' + IDENTITY_SERVER_CLIENT_ID: '@admin.island.is/bff-stjornbord' + IDENTITY_SERVER_CLIENT_SCOPES: '["@admin.island.is/delegations","@admin.island.is/ads","@admin.island.is/regulations","@admin.island.is/regulations:manage","@admin.island.is/icelandic-names-registry","@admin.island.is/application-system:admin","@admin.island.is/application-system:institution","@admin.island.is/document-provider","@admin.island.is/auth","@admin.island.is/auth:admin","@admin.island.is/petitions","@admin.island.is/service-desk","@admin.island.is/ads:explicit","@admin.island.is/signature-collection:manage","@admin.island.is/signature-collection:process","@admin.island.is/form-system","@admin.island.is/form-system:admin","@admin.island.is/delegation-system","@admin.island.is/delegation-system:admin"]' + IDENTITY_SERVER_ISSUER_URL: 'https://identity-server.staging01.devland.is' + LOG_LEVEL: 'info' + NODE_OPTIONS: '--max-old-space-size=460 -r dd-trace/init' + REDIS_URL_NODE_01: '["clustercfg.general-redis-cluster-group.ab9ckb.euw1.cache.amazonaws.com:6379"]' + SERVERSIDE_FEATURES_ON: '' + grantNamespaces: [] + grantNamespacesEnabled: false + healthCheck: + liveness: + initialDelaySeconds: 3 + path: '/liveness' + timeoutSeconds: 3 + readiness: + initialDelaySeconds: 3 + path: '/health/check' + timeoutSeconds: 3 + hpa: + scaling: + metric: + cpuAverageUtilization: 90 + nginxRequestsIrate: 5 + replicas: + max: 10 + min: 2 + image: + repository: '821090935708.dkr.ecr.eu-west-1.amazonaws.com/services-bff' + ingress: + primary-alb: + annotations: + kubernetes.io/ingress.class: 'nginx-external-alb' + nginx.ingress.kubernetes.io/enable-global-auth: 'false' + nginx.ingress.kubernetes.io/proxy-buffer-size: '8k' + nginx.ingress.kubernetes.io/proxy-buffering: 'on' + nginx.ingress.kubernetes.io/service-upstream: 'true' + hosts: + - host: 'beta.staging01.devland.is' + paths: + - '/stjornbord/bff' + namespace: 'services-bff' + podDisruptionBudget: + maxUnavailable: 1 + pvcs: [] + replicaCount: + default: 2 + max: 10 + min: 2 + resources: + limits: + cpu: '400m' + memory: '512Mi' + requests: + cpu: '100m' + memory: '256Mi' + secrets: + BFF_TOKEN_SECRET_BASE64: '/k8s/services-bff/admin-portal/BFF_TOKEN_SECRET_BASE64' + CONFIGCAT_SDK_KEY: '/k8s/configcat/CONFIGCAT_SDK_KEY' + IDENTITY_SERVER_CLIENT_SECRET: '/k8s/services-bff/admin-portal/IDENTITY_SERVER_CLIENT_SECRET' + securityContext: + allowPrivilegeEscalation: false + privileged: false services-documents: enabled: true env: diff --git a/infra/src/dsl/value-files-generators/local-setup.ts b/infra/src/dsl/value-files-generators/local-setup.ts index 2770615a561e..4073157cebe8 100644 --- a/infra/src/dsl/value-files-generators/local-setup.ts +++ b/infra/src/dsl/value-files-generators/local-setup.ts @@ -16,6 +16,14 @@ const mapServiceToNXname = async (serviceName: string) => { const projects = globSync(['apps/*/project.json', 'apps/*/*/project.json'], { cwd: projectRootPath, }) + + // This is a hack to make sure we are running `services-bff` project with the desired infra config. + // We have multiple infra files under the `services-bff` project, e.g. `services-bff-admin-portal`, `services-bff-my-pages-portal`, etc. + // For the project to run correctly, we need to run the `services-bff` project. + if (serviceName.startsWith('services-bff-')) { + serviceName = 'services-bff' + } + const nxName = ( await Promise.all( projects.map(async (path) => { @@ -69,6 +77,7 @@ export const getLocalrunValueFile = async ( ? { PORT: runtime.ports[name].toString() } : {} const serviceNXName = await mapServiceToNXname(name) + logger.debug('Process service', { name, service, serviceNXName }) dockerComposeServices[name] = { env: Object.assign( diff --git a/infra/src/uber-charts/islandis.ts b/infra/src/uber-charts/islandis.ts index 7c2f09f796c0..c2d8b1f21b33 100644 --- a/infra/src/uber-charts/islandis.ts +++ b/infra/src/uber-charts/islandis.ts @@ -10,10 +10,14 @@ import { } from '../../../apps/application-system/api/infra/application-system-api' import { serviceSetup as appSystemFormSetup } from '../../../apps/application-system/form/infra/application-system-form' +// Portals import { serviceSetup as servicePortalApiSetup } from '../../../apps/services/user-profile/infra/service-portal-api' import { serviceSetup as servicePortalSetup } from '../../../apps/service-portal/infra/service-portal' - import { serviceSetup as adminPortalSetup } from '../../../apps/portals/admin/infra/portals-admin' + +// Bff's +import { serviceSetup as bffAdminPortalServiceSetup } from '../../../apps/services/bff/infra/admin-portal.infra' + import { serviceSetup as consultationPortalSetup } from '../../../apps/consultation-portal/infra/samradsgatt' import { serviceSetup as xroadCollectorSetup } from '../../../apps/services/xroad-collector/infra/xroad-collector' @@ -77,6 +81,8 @@ const appSystemApi = appSystemApiSetup({ }) const appSystemApiWorker = appSystemApiWorkerSetup() +const bffAdminPortalService = bffAdminPortalServiceSetup() + const adminPortal = adminPortalSetup() const nameRegistryBackend = serviceNameRegistryBackendSetup() @@ -173,6 +179,7 @@ export const Services: EnvironmentServices = { universityGatewayWorker, contentfulApps, contentfulEntryTagger, + bffAdminPortalService, ], staging: [ appSystemApi, @@ -206,6 +213,7 @@ export const Services: EnvironmentServices = { sessionsCleanupWorker, universityGatewayService, universityGatewayWorker, + bffAdminPortalService, ], dev: [ appSystemApi, @@ -243,6 +251,7 @@ export const Services: EnvironmentServices = { contentfulApps, universityGatewayService, universityGatewayWorker, + bffAdminPortalService, ], } From 006d2ab2b625775d86a073f5889d9b5f9da418f8 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Mon, 7 Oct 2024 09:18:52 +0000 Subject: [PATCH 083/248] Remove error log from revokeRefreshToken since it is handled by enhancedFetch and update download service local url --- apps/services/bff/infra/utils/createPortalEnv.ts | 2 +- apps/services/bff/src/app/modules/auth/auth.service.ts | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/services/bff/infra/utils/createPortalEnv.ts b/apps/services/bff/infra/utils/createPortalEnv.ts index bbcb12fd1b04..23c7b2138611 100644 --- a/apps/services/bff/infra/utils/createPortalEnv.ts +++ b/apps/services/bff/infra/utils/createPortalEnv.ts @@ -64,7 +64,7 @@ export const createPortalEnv = (key: PortalKeys) => { prod: 'https://island.is/api/graphql', }, BFF_ALLOWED_EXTERNAL_API_URLS: { - local: json(['https://api.dev01.devland.is']), + local: json(['http://localhost:3377/download/v1']), dev: json(['https://api.dev01.devland.is']), staging: json(['https://api.staging01.devland.is']), prod: json(['https://api.island.is']), diff --git a/apps/services/bff/src/app/modules/auth/auth.service.ts b/apps/services/bff/src/app/modules/auth/auth.service.ts index ff0c156c1ff8..35835aa2410e 100644 --- a/apps/services/bff/src/app/modules/auth/auth.service.ts +++ b/apps/services/bff/src/app/modules/auth/auth.service.ts @@ -121,9 +121,7 @@ export class AuthService { * If the operation fails, we log the error. */ private revokeRefreshToken(token: string) { - this.idsService.revokeToken(token, 'refresh_token').catch((error) => { - this.logger.error('Failed to revoke refresh token:', error) - }) + this.idsService.revokeToken(token, 'refresh_token') } /** From 600abfaf36eef3b04d6fb1cf7498d04765613731 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Mon, 7 Oct 2024 09:37:41 +0000 Subject: [PATCH 084/248] Rename cached toke fields to be prefixed with encrypted and fix where encryption was missing. Also fix for revoking wrong token --- .../bff/src/app/modules/auth/auth.service.ts | 37 ++++++++++++++----- .../bff/src/app/modules/auth/auth.types.ts | 8 +++- .../src/app/modules/proxy/proxy.service.ts | 6 ++- .../bff/src/app/modules/user/user.service.ts | 2 +- 4 files changed, 39 insertions(+), 14 deletions(-) diff --git a/apps/services/bff/src/app/modules/auth/auth.service.ts b/apps/services/bff/src/app/modules/auth/auth.service.ts index 35835aa2410e..8faa228364f0 100644 --- a/apps/services/bff/src/app/modules/auth/auth.service.ts +++ b/apps/services/bff/src/app/modules/auth/auth.service.ts @@ -95,8 +95,12 @@ export class AuthService { ...tokenResponse, // Encrypt the access and refresh tokens before saving them to the cache // to prevent unauthorized access to the tokens if cached service is compromised. - access_token: this.cryptoService.encrypt(tokenResponse.access_token), - refresh_token: this.cryptoService.encrypt(tokenResponse.refresh_token), + encryptedAccessToken: this.cryptoService.encrypt( + tokenResponse.access_token, + ), + encryptedRefreshToken: this.cryptoService.encrypt( + tokenResponse.refresh_token, + ), scopes: tokenResponse.scope.split(' '), userProfile, // Subtract 5 seconds from the token expiration time to account for latency. @@ -118,10 +122,15 @@ export class AuthService { * Revoke the refresh token on the identity server, since we have a new session * We deliberately do not await this operation to make the login flow faster, * since this operation is not critical part to await. - * If the operation fails, we log the error. + * + * @param encryptedRefreshToken The encrypted refresh token to revoke */ - private revokeRefreshToken(token: string) { - this.idsService.revokeToken(token, 'refresh_token') + private revokeRefreshToken(encryptedRefreshToken: string) { + const decryptedRefreshToken = this.cryptoService.decrypt( + encryptedRefreshToken, + ) + + this.idsService.revokeToken(decryptedRefreshToken, 'refresh_token') } /** @@ -285,12 +294,20 @@ export class AuthService { oldSessionCookie && oldSessionCookie !== updatedTokenResponse.userProfile.sid ) { - // Clean up the old session key from the cache - await this.cacheService.delete( - this.cacheService.createSessionKeyType('current', oldSessionCookie), + const oldSessionCacheKey = this.cacheService.createSessionKeyType( + 'current', + oldSessionCookie, ) - this.revokeRefreshToken(updatedTokenResponse.refresh_token) + const oldSessionData = await this.cacheService.get( + oldSessionCacheKey, + ) + + // Clean up the old session key from the cache + await this.cacheService.delete(oldSessionCacheKey) + + // Revoke the old session refresh token + this.revokeRefreshToken(oldSessionData.encryptedRefreshToken) } return res.redirect( @@ -372,7 +389,7 @@ export class AuthService { */ res.clearCookie(SESSION_COOKIE_NAME, this.getCookieOptions()) this.cacheService.delete(currentLoginCacheKey) - this.revokeRefreshToken(cachedTokenResponse.refresh_token) + this.revokeRefreshToken(cachedTokenResponse.encryptedRefreshToken) const searchParams = new URLSearchParams({ id_token_hint: cachedTokenResponse.id_token, diff --git a/apps/services/bff/src/app/modules/auth/auth.types.ts b/apps/services/bff/src/app/modules/auth/auth.types.ts index 544c713458c5..e57e52298603 100644 --- a/apps/services/bff/src/app/modules/auth/auth.types.ts +++ b/apps/services/bff/src/app/modules/auth/auth.types.ts @@ -2,7 +2,10 @@ import { IdTokenClaims } from '@island.is/shared/types' import { TokenResponse } from '../ids/ids.types' -export type CachedTokenResponse = TokenResponse & { +export type CachedTokenResponse = Omit< + TokenResponse, + 'refresh_token' | 'access_token' +> & { scopes: string[] /** @@ -14,4 +17,7 @@ export type CachedTokenResponse = TokenResponse & { * Expiration time of the access token */ accessTokenExp: number + + encryptedAccessToken: string + encryptedRefreshToken: string } diff --git a/apps/services/bff/src/app/modules/proxy/proxy.service.ts b/apps/services/bff/src/app/modules/proxy/proxy.service.ts index 4bf3f7daf620..ccc01a85b064 100644 --- a/apps/services/bff/src/app/modules/proxy/proxy.service.ts +++ b/apps/services/bff/src/app/modules/proxy/proxy.service.ts @@ -56,7 +56,7 @@ export class ProxyService { if (isExpired(cachedTokenResponse.accessTokenExp)) { const tokenResponse = await this.idsService.refreshToken( - cachedTokenResponse.refresh_token, + cachedTokenResponse.encryptedRefreshToken, ) cachedTokenResponse = await this.authService.updateTokenCache( @@ -64,7 +64,9 @@ export class ProxyService { ) } - return this.cryptoService.decrypt(cachedTokenResponse.access_token) + return this.cryptoService.decrypt( + cachedTokenResponse.encryptedAccessToken, + ) } catch (error) { this.logger.error('Error getting access token:', error) diff --git a/apps/services/bff/src/app/modules/user/user.service.ts b/apps/services/bff/src/app/modules/user/user.service.ts index e9ac1fcd4adb..02780905b1b6 100644 --- a/apps/services/bff/src/app/modules/user/user.service.ts +++ b/apps/services/bff/src/app/modules/user/user.service.ts @@ -56,7 +56,7 @@ export class UserService { // Get new token data with refresh token const tokenResponse = await this.idsService.refreshToken( - cachedTokenResponse.refresh_token, + cachedTokenResponse.encryptedRefreshToken, ) // Update cache with new token data From de47d6ae8a30be37266f38e196a7d51a3d90b618 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Mon, 7 Oct 2024 10:09:38 +0000 Subject: [PATCH 085/248] Better handling on errors in auth service --- .../bff/src/app/modules/auth/auth.service.ts | 29 ++++++++++++++----- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/apps/services/bff/src/app/modules/auth/auth.service.ts b/apps/services/bff/src/app/modules/auth/auth.service.ts index 8faa228364f0..a0d391a6bf87 100644 --- a/apps/services/bff/src/app/modules/auth/auth.service.ts +++ b/apps/services/bff/src/app/modules/auth/auth.service.ts @@ -119,18 +119,26 @@ export class AuthService { } /** - * Revoke the refresh token on the identity server, since we have a new session + * Revoke the refresh token on the identity server, since we have a new session. * We deliberately do not await this operation to make the login flow faster, * since this operation is not critical part to await. * * @param encryptedRefreshToken The encrypted refresh token to revoke */ private revokeRefreshToken(encryptedRefreshToken: string) { - const decryptedRefreshToken = this.cryptoService.decrypt( - encryptedRefreshToken, - ) + try { + const decryptedToken = this.cryptoService.decrypt(encryptedRefreshToken) - this.idsService.revokeToken(decryptedRefreshToken, 'refresh_token') + // Call revokeToken without awaiting and handle potential errors with .catch() to handle unhandled promise rejections. + this.idsService + .revokeToken(decryptedToken, 'refresh_token') + .catch((error) => { + this.logger.warn('Failed to revoke refresh token:', error) + }) + } catch (error) { + // Catch synchronous decryption errors + this.logger.warn('Failed to decrypt refresh token:', error) + } } /** @@ -384,11 +392,16 @@ export class AuthService { * - Revoke the refresh token on the identity server * - Delete the current login from the cache * - Clear the session cookie - * - * Note! We deliberately do not await this operation to make the logout flow faster. */ res.clearCookie(SESSION_COOKIE_NAME, this.getCookieOptions()) - this.cacheService.delete(currentLoginCacheKey) + + this.cacheService + .delete(currentLoginCacheKey) + // catch() to handle unhandled promise rejections + .catch((err) => { + this.logger.warn(err) + }) + // Note! We deliberately do not await this operation to make the logout flow faster. this.revokeRefreshToken(cachedTokenResponse.encryptedRefreshToken) const searchParams = new URLSearchParams({ From eae6fa4a31fe97cb0159613c1f69dc078c6fdedf Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Mon, 7 Oct 2024 20:19:51 +0000 Subject: [PATCH 086/248] Update api requests formatting and handling to handle exceptions and errors better. --- .../bff/infra/utils/createPortalEnv.ts | 41 ++++++++---- apps/services/bff/src/app/bff.config.ts | 17 +++-- apps/services/bff/src/app/constants/time.ts | 7 --- .../bff/src/app/modules/auth/auth.service.ts | 62 +++++++++++-------- .../bff/src/app/modules/ids/ids.service.ts | 39 +++++++++--- .../bff/src/app/modules/ids/ids.types.ts | 22 ++++++- .../src/app/modules/proxy/proxy.service.ts | 11 +++- .../bff/src/app/modules/user/user.service.ts | 22 ++++--- .../app/utils/has-timestamp-expired-in-ms.ts | 6 ++ apps/services/bff/src/app/utils/is-expired.ts | 6 -- 10 files changed, 153 insertions(+), 80 deletions(-) create mode 100644 apps/services/bff/src/app/utils/has-timestamp-expired-in-ms.ts delete mode 100644 apps/services/bff/src/app/utils/is-expired.ts diff --git a/apps/services/bff/infra/utils/createPortalEnv.ts b/apps/services/bff/infra/utils/createPortalEnv.ts index 23c7b2138611..0a62c9582dca 100644 --- a/apps/services/bff/infra/utils/createPortalEnv.ts +++ b/apps/services/bff/infra/utils/createPortalEnv.ts @@ -1,18 +1,28 @@ -// eslint-disable-next-line @nx/enforce-module-boundaries +/* eslint-disable @nx/enforce-module-boundaries */ +import { json } from '../../../../../infra/src/dsl/dsl' import { adminPortalScopes, servicePortalScopes, } from '../../../../../libs/auth/scopes/src/index' -import { json } from '../../../../../infra/src/dsl/dsl' -import { DEFAULT_CACHE_USER_PROFILE_TTL_MS } from '../../src/app/constants/time' +import { FIVE_SECONDS_IN_MS } from '../../src/app/constants/time' + +const ONE_HOUR_IN_MS = 60 * 60 * 1000 +const ONE_WEEK_IN_MS = ONE_HOUR_IN_MS * 24 * 7 type PortalKeys = 'stjornbord' | 'minarsidur' -const defaultEnvUrls = { - local: json(['http://localhost:4200/stjornbord']), - dev: json(['https://beta.dev01.devland.is']), - staging: json(['https://beta.staging01.devland.is']), - prod: json(['https://island.is']), +const getDefaultEnvUrls = (asArray = false) => { + const local = 'http://localhost:4200/stjornbord' + const dev = 'https://beta.dev01.devland.is' + const staging = 'https://beta.staging01.devland.is' + const prod = 'https://island.is' + + return { + local: asArray ? json([local]) : local, + dev: asArray ? json([dev]) : dev, + staging: asArray ? json([staging]) : staging, + prod: asArray ? json([prod]) : prod, + } } const getScopes = (key: PortalKeys) => { @@ -48,9 +58,9 @@ export const createPortalEnv = (key: PortalKeys) => { }, BFF_CLIENT_KEY_PATH: `/${key}`, BFF_PAR_SUPPORT_ENABLED: 'false', - BFF_ALLOWED_REDIRECT_URIS: defaultEnvUrls, - BFF_CLIENT_BASE_URL: defaultEnvUrls, - BFF_LOGOUT_REDIRECT_URI: defaultEnvUrls, + BFF_ALLOWED_REDIRECT_URIS: getDefaultEnvUrls(true), + BFF_CLIENT_BASE_URL: getDefaultEnvUrls(), + BFF_LOGOUT_REDIRECT_URI: getDefaultEnvUrls(), BFF_CALLBACKS_BASE_PATH: { local: `http://localhost:3010/${key}/bff/callbacks`, dev: `https://beta.dev01.devland.is/${key}/bff/callbacks`, @@ -69,6 +79,13 @@ export const createPortalEnv = (key: PortalKeys) => { staging: json(['https://api.staging01.devland.is']), prod: json(['https://api.island.is']), }, - BFF_CACHE_USER_PROFILE_TTL_MS: DEFAULT_CACHE_USER_PROFILE_TTL_MS.toString(), + /** + * The TTL should be aligned with the lifespan of the Ids client refresh token. + * We also subtract 5 seconds from the TTL to handle latency and clock drift. + */ + BFF_CACHE_USER_PROFILE_TTL_MS: ( + ONE_HOUR_IN_MS - FIVE_SECONDS_IN_MS + ).toString(), + BFF_LOGIN_ATTEMPT_TTL_MS: ONE_WEEK_IN_MS.toString(), } } diff --git a/apps/services/bff/src/app/bff.config.ts b/apps/services/bff/src/app/bff.config.ts index 83839b02a111..820f2ad2351b 100644 --- a/apps/services/bff/src/app/bff.config.ts +++ b/apps/services/bff/src/app/bff.config.ts @@ -3,7 +3,6 @@ import { defineConfig } from '@island.is/nest/config' import { z } from 'zod' import { isProduction } from '../environment' import { removeTrailingSlash } from './utils/remove-trailing-slash' -import { DEFAULT_CACHE_USER_PROFILE_TTL_MS } from './constants/time' export const idsSchema = z.strictObject({ issuer: z.string(), @@ -54,10 +53,12 @@ const BffConfigSchema = z.object({ * * Note: The TTL should be aligned with the lifespan of the Ids client refresh token. * We also subtract 5 seconds from the TTL to handle latency and clock drift. - * - * @default 1 hour - 5 seconds = 359995000ms */ - cacheUserProfileTTLms: z.number().default(DEFAULT_CACHE_USER_PROFILE_TTL_MS), + cacheUserProfileTTLms: z.number(), + /** + * Time-to-live (TTL) for caching the login attempts, in milliseconds. + */ + cacheLoginAttemptTTLms: z.number(), }) export const BffConfig = defineConfig({ @@ -119,12 +120,10 @@ export const BffConfig = defineConfig({ ], ), /** - * Time-to-live (TTL) for caching the user profile, in milliseconds. + * Time-to-live (TTL) in milliseconds for caching. */ - cacheUserProfileTTLms: env.requiredJSON( - 'BFF_CACHE_USER_PROFILE_TTL_MS', - DEFAULT_CACHE_USER_PROFILE_TTL_MS, - ), + cacheUserProfileTTLms: env.requiredJSON('BFF_CACHE_USER_PROFILE_TTL_MS'), + cacheLoginAttemptTTLms: env.requiredJSON('BFF_LOGIN_ATTEMPT_TTL_MS'), } }, }) diff --git a/apps/services/bff/src/app/constants/time.ts b/apps/services/bff/src/app/constants/time.ts index 78c7e347e1dc..6ed98a5e9b64 100644 --- a/apps/services/bff/src/app/constants/time.ts +++ b/apps/services/bff/src/app/constants/time.ts @@ -1,8 +1 @@ export const FIVE_SECONDS_IN_MS = 5 * 1000 -export const ONE_HOUR_IN_MS = 60 * 60 * 1000 -export const ONE_WEEK_IN_MS = ONE_HOUR_IN_MS * 24 * 7 - -// Time-to-live (TTL) for caching the user profile, in milliseconds. -// We subtract 5 seconds from the TTL to handle latency and clock drift. -export const DEFAULT_CACHE_USER_PROFILE_TTL_MS = - ONE_HOUR_IN_MS - FIVE_SECONDS_IN_MS diff --git a/apps/services/bff/src/app/modules/auth/auth.service.ts b/apps/services/bff/src/app/modules/auth/auth.service.ts index a0d391a6bf87..77b6b0021d1a 100644 --- a/apps/services/bff/src/app/modules/auth/auth.service.ts +++ b/apps/services/bff/src/app/modules/auth/auth.service.ts @@ -10,7 +10,7 @@ import { v4 as uuid } from 'uuid' import { environment } from '../../../environment' import { BffConfig } from '../../bff.config' import { SESSION_COOKIE_NAME } from '../../constants/cookies' -import { FIVE_SECONDS_IN_MS, ONE_WEEK_IN_MS } from '../../constants/time' +import { FIVE_SECONDS_IN_MS } from '../../constants/time' import { CryptoService } from '../../services/crypto.service' import { PKCEService } from '../../services/pkce.service' import { @@ -122,23 +122,16 @@ export class AuthService { * Revoke the refresh token on the identity server, since we have a new session. * We deliberately do not await this operation to make the login flow faster, * since this operation is not critical part to await. + * We use .catch() to handle unhandled promise rejections. * * @param encryptedRefreshToken The encrypted refresh token to revoke */ private revokeRefreshToken(encryptedRefreshToken: string) { - try { - const decryptedToken = this.cryptoService.decrypt(encryptedRefreshToken) - - // Call revokeToken without awaiting and handle potential errors with .catch() to handle unhandled promise rejections. - this.idsService - .revokeToken(decryptedToken, 'refresh_token') - .catch((error) => { - this.logger.warn('Failed to revoke refresh token:', error) - }) - } catch (error) { - // Catch synchronous decryption errors - this.logger.warn('Failed to decrypt refresh token:', error) - } + this.idsService + .revokeToken(encryptedRefreshToken, 'refresh_token') + .catch((error) => { + this.logger.warn('Failed to revoke refresh token:', error) + }) } /** @@ -187,7 +180,7 @@ export class AuthService { codeVerifier, targetLinkUri, }, - ttl: ONE_WEEK_IN_MS, // 1 week + ttl: this.config.cacheLoginAttemptTTLms, }) let searchParams: URLSearchParams @@ -200,8 +193,12 @@ export class AuthService { prompt, }) + if (parResponse.type === 'error') { + throw parResponse.data + } + searchParams = new URLSearchParams({ - request_uri: parResponse.request_uri, + request_uri: parResponse.data.request_uri, client_id: this.config.ids.clientId, }) } else { @@ -281,13 +278,21 @@ export class AuthService { codeVerifier: loginAttemptData.codeVerifier, }) - const updatedTokenResponse = await this.updateTokenCache(tokenResponse) + if (tokenResponse.type === 'error') { + throw tokenResponse.data + } - // Clean up the login attempt from the cache since we have a successful login. - await this.cacheService.delete( - this.cacheService.createSessionKeyType('attempt', query.state), + const updatedTokenResponse = await this.updateTokenCache( + tokenResponse.data, ) + // Clean up the login attempt from the cache since we have a successful login. + this.cacheService + .delete(this.cacheService.createSessionKeyType('attempt', query.state)) + .catch((err) => { + this.logger.warn(err) + }) + // Create session cookie with successful login session id res.cookie( SESSION_COOKIE_NAME, @@ -309,13 +314,20 @@ export class AuthService { const oldSessionData = await this.cacheService.get( oldSessionCacheKey, + // Do not throw an error if the key is not found + false, ) - // Clean up the old session key from the cache - await this.cacheService.delete(oldSessionCacheKey) + if (oldSessionData) { + // Revoke the old session refresh token + this.revokeRefreshToken(oldSessionData.encryptedRefreshToken) - // Revoke the old session refresh token - this.revokeRefreshToken(oldSessionData.encryptedRefreshToken) + // Clean up the old session key from the cache. + // Use catch() to handle unhandled promise rejections + this.cacheService.delete(oldSessionCacheKey).catch((err) => { + this.logger.warn(err) + }) + } } return res.redirect( @@ -397,7 +409,7 @@ export class AuthService { this.cacheService .delete(currentLoginCacheKey) - // catch() to handle unhandled promise rejections + // handle unhandled promise rejections .catch((err) => { this.logger.warn(err) }) diff --git a/apps/services/bff/src/app/modules/ids/ids.service.ts b/apps/services/bff/src/app/modules/ids/ids.service.ts index 2078604918df..fff55993e6e7 100644 --- a/apps/services/bff/src/app/modules/ids/ids.service.ts +++ b/apps/services/bff/src/app/modules/ids/ids.service.ts @@ -1,5 +1,3 @@ -import type { Logger } from '@island.is/logging' -import { LOGGER_PROVIDER } from '@island.is/logging' import { Inject, Injectable } from '@nestjs/common' import { ConfigType } from '@nestjs/config' @@ -7,16 +5,13 @@ import type { EnhancedFetchAPI } from '@island.is/clients/middlewares' import { BffConfig } from '../../bff.config' import { CryptoService } from '../../services/crypto.service' import { ENHANCED_FETCH_PROVIDER_KEY } from '../enhancedFetch/enhanced-fetch.provider' -import { ParResponse, TokenResponse } from './ids.types' +import { ApiResponse, ErrorRes, ParResponse, TokenResponse } from './ids.types' @Injectable() export class IdsService { private readonly issuerUrl constructor( - @Inject(LOGGER_PROVIDER) - private readonly logger: Logger, - @Inject(BffConfig.KEY) private readonly config: ConfigType, @@ -34,7 +29,7 @@ export class IdsService { private async postRequest( endpoint: string, body: Record, - ): Promise { + ): Promise> { try { const response = await this.enhancedFetch( `${this.issuerUrl}${endpoint}`, @@ -50,10 +45,36 @@ export class IdsService { const contentType = response.headers.get('content-type') || '' if (contentType.includes('application/json')) { - return response.json() as Promise + const data = await response.json() + + if (!response.ok) { + // If error response from Ids is not in the expected format, throw the data as is + if (!data.error || !data.error_description) { + throw data + } + + return { + type: 'error', + data: { + error: data.error, + error_description: data.error_description, + }, + } as ErrorRes + } + + return { + type: 'success', + data: data as T, + } } - return response.text() as Promise + // Handle plain text responses + const textResponse = await response.text() + + return { + type: 'success', + data: textResponse, + } as ApiResponse } catch (error) { throw new Error(error) } diff --git a/apps/services/bff/src/app/modules/ids/ids.types.ts b/apps/services/bff/src/app/modules/ids/ids.types.ts index f7466e1916c9..83798d29ef51 100644 --- a/apps/services/bff/src/app/modules/ids/ids.types.ts +++ b/apps/services/bff/src/app/modules/ids/ids.types.ts @@ -1,3 +1,11 @@ +export interface ErrorResponse { + // Error code, e.g. invalid_grant, invalid_request, ... + error: string + + // Human-readable error description, + error_description: string +} + export type ParResponse = { // An identifier for the authorization request, instead of sending the parameters that were just pushed request_uri: string @@ -6,7 +14,7 @@ export type ParResponse = { expires_in: number } -export interface TokenResponse { +export type TokenResponse = { // ID token issued by the authorization server id_token: string @@ -25,3 +33,15 @@ export interface TokenResponse { // Scopes associated with the access token scope: string } + +export interface SuccessResponse { + type: 'success' + data: T +} + +export interface ErrorRes { + type: 'error' + data: ErrorResponse +} + +export type ApiResponse = SuccessResponse | ErrorRes diff --git a/apps/services/bff/src/app/modules/proxy/proxy.service.ts b/apps/services/bff/src/app/modules/proxy/proxy.service.ts index ccc01a85b064..c4f17cfa6deb 100644 --- a/apps/services/bff/src/app/modules/proxy/proxy.service.ts +++ b/apps/services/bff/src/app/modules/proxy/proxy.service.ts @@ -11,7 +11,8 @@ import { Request, Response } from 'express' import fetch from 'node-fetch' import { BffConfig } from '../../bff.config' import { CryptoService } from '../../services/crypto.service' -import { isExpired } from '../../utils/is-expired' + +import { hasTimestampExpiredInMS } from '../../utils/has-timestamp-expired-in-ms' import { validateUri } from '../../utils/validate-uri' import { AuthService } from '../auth/auth.service' import { CachedTokenResponse } from '../auth/auth.types' @@ -54,13 +55,17 @@ export class ProxyService { this.cacheService.createSessionKeyType('current', sid), ) - if (isExpired(cachedTokenResponse.accessTokenExp)) { + if (hasTimestampExpiredInMS(cachedTokenResponse.accessTokenExp)) { const tokenResponse = await this.idsService.refreshToken( cachedTokenResponse.encryptedRefreshToken, ) + if (tokenResponse.type === 'error') { + throw tokenResponse.data + } + cachedTokenResponse = await this.authService.updateTokenCache( - tokenResponse, + tokenResponse.data, ) } diff --git a/apps/services/bff/src/app/modules/user/user.service.ts b/apps/services/bff/src/app/modules/user/user.service.ts index 02780905b1b6..e63c8d8b68bb 100644 --- a/apps/services/bff/src/app/modules/user/user.service.ts +++ b/apps/services/bff/src/app/modules/user/user.service.ts @@ -4,12 +4,14 @@ import { Inject, Injectable, UnauthorizedException } from '@nestjs/common' import { Request } from 'express' import { BffUser } from '@island.is/shared/types' -import { isExpired } from '../../utils/is-expired' +import { SESSION_COOKIE_NAME } from '../../constants/cookies' +import { CryptoService } from '../../services/crypto.service' + +import { hasTimestampExpiredInMS } from '../../utils/has-timestamp-expired-in-ms' import { AuthService } from '../auth/auth.service' import { CachedTokenResponse } from '../auth/auth.types' import { CacheService } from '../cache/cache.service' import { IdsService } from '../ids/ids.service' -import { SESSION_COOKIE_NAME } from '../../constants/cookies' @Injectable() export class UserService { @@ -17,6 +19,7 @@ export class UserService { @Inject(LOGGER_PROVIDER) private logger: Logger, + private readonly cryptoService: CryptoService, private readonly cacheService: CacheService, private readonly idsService: IdsService, private readonly authService: AuthService, @@ -48,20 +51,23 @@ export class UserService { throw new UnauthorizedException() } - // Check if the access token is expired - if (isExpired(cachedTokenResponse.accessTokenExp)) { - if (noRefresh) { - throw new UnauthorizedException() - } + const accessTokenHasExpired = hasTimestampExpiredInMS( + cachedTokenResponse.accessTokenExp, + ) + if (accessTokenHasExpired && !noRefresh) { // Get new token data with refresh token const tokenResponse = await this.idsService.refreshToken( cachedTokenResponse.encryptedRefreshToken, ) + if (tokenResponse.type === 'error') { + throw tokenResponse.data + } + // Update cache with new token data const value: CachedTokenResponse = - await this.authService.updateTokenCache(tokenResponse) + await this.authService.updateTokenCache(tokenResponse.data) return this.mapToBffUser(value) } diff --git a/apps/services/bff/src/app/utils/has-timestamp-expired-in-ms.ts b/apps/services/bff/src/app/utils/has-timestamp-expired-in-ms.ts new file mode 100644 index 000000000000..87762126c8c6 --- /dev/null +++ b/apps/services/bff/src/app/utils/has-timestamp-expired-in-ms.ts @@ -0,0 +1,6 @@ +/** + * Check if a Unix timestamp (in milliseconds) has expired + */ +export const hasTimestampExpiredInMS = (unixTimestampMs: number): boolean => { + return unixTimestampMs < Date.now() +} diff --git a/apps/services/bff/src/app/utils/is-expired.ts b/apps/services/bff/src/app/utils/is-expired.ts deleted file mode 100644 index 42605f482907..000000000000 --- a/apps/services/bff/src/app/utils/is-expired.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Check if unix timestamp is expired - */ -export const isExpired = (unixTimestamp: number) => { - return unixTimestamp < Date.now() / 1000 -} From 1558e41a515363fd6402c4ce09e2a92c465df6ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sn=C3=A6r=20Seljan=20=C3=9E=C3=B3roddsson?= <112904566+snaerseljan@users.noreply.github.com> Date: Mon, 7 Oct 2024 20:22:29 +0000 Subject: [PATCH 087/248] Update apps/services/bff/src/app/bff.config.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit simpler redis config Co-authored-by: Eiríkur Heiðar Nilsson --- apps/services/bff/src/app/bff.config.ts | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/apps/services/bff/src/app/bff.config.ts b/apps/services/bff/src/app/bff.config.ts index 820f2ad2351b..971f4bcf2cb4 100644 --- a/apps/services/bff/src/app/bff.config.ts +++ b/apps/services/bff/src/app/bff.config.ts @@ -81,19 +81,11 @@ export const BffConfig = defineConfig({ * Our main GraphQL API endpoint */ graphqlApiEndpoint: env.required('BFF_PROXY_API_ENDPOINT'), - redis: isProduction - ? { - name: env.required('BFF_REDIS_NAME'), - nodes: env.requiredJSON('BFF_REDIS_URL_NODES'), - ssl: true, - } - : redisNodes - ? { - name: env.optional('BFF_REDIS_NAME') ?? 'unnamed-bff', - nodes: redisNodes, - ssl: false, - } - : undefined, + redis: { + name: env.required('BFF_REDIS_NAME', 'unnamed-bff'), + nodes: env.requiredJSON('BFF_REDIS_URL_NODES', []), + ssl: env.optionalJSON('BFF_REDIS_SSL', false) ?? true, + }, ids: { issuer: env.required('IDENTITY_SERVER_ISSUER_URL'), clientId: env.required('IDENTITY_SERVER_CLIENT_ID'), From 3ea37b9c007acf6c5246717cdfd9395a4d345542 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Mon, 7 Oct 2024 20:23:30 +0000 Subject: [PATCH 088/248] cleanup after commit from github --- apps/services/bff/src/app/bff.config.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/apps/services/bff/src/app/bff.config.ts b/apps/services/bff/src/app/bff.config.ts index 971f4bcf2cb4..a00fefe572ee 100644 --- a/apps/services/bff/src/app/bff.config.ts +++ b/apps/services/bff/src/app/bff.config.ts @@ -1,7 +1,6 @@ import { defineConfig } from '@island.is/nest/config' import { z } from 'zod' -import { isProduction } from '../environment' import { removeTrailingSlash } from './utils/remove-trailing-slash' export const idsSchema = z.strictObject({ @@ -68,10 +67,6 @@ export const BffConfig = defineConfig({ const callbacksBaseRedirectPath = removeTrailingSlash( env.required('BFF_CALLBACKS_BASE_PATH'), ) - // Redis nodes are only required in production - // In development, we can use a local Redis server or - // rely on the default in-memory cache provided by CacheModule - const redisNodes = env.optionalJSON('BFF_REDIS_URL_NODES') return { parSupportEnabled: env.optionalJSON('BFF_PAR_SUPPORT_ENABLED') ?? false, @@ -82,10 +77,13 @@ export const BffConfig = defineConfig({ */ graphqlApiEndpoint: env.required('BFF_PROXY_API_ENDPOINT'), redis: { - name: env.required('BFF_REDIS_NAME', 'unnamed-bff'), - nodes: env.requiredJSON('BFF_REDIS_URL_NODES', []), - ssl: env.optionalJSON('BFF_REDIS_SSL', false) ?? true, - }, + name: env.required('BFF_REDIS_NAME', 'unnamed-bff'), + // Redis nodes are only required in production + // In development, we can use a local Redis server or + // rely on the default in-memory cache provided by CacheModule + nodes: env.requiredJSON('BFF_REDIS_URL_NODES', []), + ssl: env.optionalJSON('BFF_REDIS_SSL', false) ?? true, + }, ids: { issuer: env.required('IDENTITY_SERVER_ISSUER_URL'), clientId: env.required('IDENTITY_SERVER_CLIENT_ID'), From 9093668f362a8621c302bcbf09e990eb3982e2ff Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Mon, 7 Oct 2024 20:30:52 +0000 Subject: [PATCH 089/248] Update after our pull request AI suggested the change --- apps/services/bff/src/app/modules/auth/auth.service.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/services/bff/src/app/modules/auth/auth.service.ts b/apps/services/bff/src/app/modules/auth/auth.service.ts index 77b6b0021d1a..269ea487abf1 100644 --- a/apps/services/bff/src/app/modules/auth/auth.service.ts +++ b/apps/services/bff/src/app/modules/auth/auth.service.ts @@ -60,7 +60,12 @@ export class AuthService { * Creates the client base URL with the path appended. */ private createClientBaseUrl() { - return `${this.config.clientBaseUrl}${environment.keyPath}` + const baseUrl = new URL(this.config.clientBaseUrl) + baseUrl.pathname = `${baseUrl.pathname}${environment.keyPath}` + // Prevent potential issues with malformed URLs. + .replace('//', '/') + + return baseUrl.toString() } /** From 8f38c927803896a62e29af09834f558ea1870c29 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Tue, 8 Oct 2024 09:30:17 +0000 Subject: [PATCH 090/248] Remove broadcaster mocks --- .../templates/estate/jest.config.ts | 1 - .../templates/estate/jest.setup.ts | 3 --- libs/application/ui-shell/jest.setup.ts | 3 --- libs/portals/core/test/setup.ts | 3 --- libs/react-spa/shared/mocks/index.ts | 1 - .../shared/mocks/mockBroadcastChannel.ts | 23 ------------------- .../shared/src/hooks/useBroadcaster.ts | 19 +++++++++++++-- libs/shared/components/jest.setup.ts | 3 --- tsconfig.base.json | 3 --- 9 files changed, 17 insertions(+), 42 deletions(-) delete mode 100644 libs/application/templates/estate/jest.setup.ts delete mode 100644 libs/react-spa/shared/mocks/index.ts delete mode 100644 libs/react-spa/shared/mocks/mockBroadcastChannel.ts diff --git a/libs/application/templates/estate/jest.config.ts b/libs/application/templates/estate/jest.config.ts index cf69af640735..6866304f6899 100644 --- a/libs/application/templates/estate/jest.config.ts +++ b/libs/application/templates/estate/jest.config.ts @@ -12,5 +12,4 @@ export default { moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], coverageDirectory: '/coverage/libs/application/templates/estate', displayName: 'application-templates-estate', - setupFilesAfterEnv: [`${__dirname}/jest.setup.ts`], } diff --git a/libs/application/templates/estate/jest.setup.ts b/libs/application/templates/estate/jest.setup.ts deleted file mode 100644 index 4bd52a20dcaa..000000000000 --- a/libs/application/templates/estate/jest.setup.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { MockBroadcastChannel } from '@island.is/react-spa/shared/mocks' - -global.BroadcastChannel = MockBroadcastChannel diff --git a/libs/application/ui-shell/jest.setup.ts b/libs/application/ui-shell/jest.setup.ts index 09897a369edf..ddce481f3b36 100644 --- a/libs/application/ui-shell/jest.setup.ts +++ b/libs/application/ui-shell/jest.setup.ts @@ -1,7 +1,4 @@ import '@testing-library/jest-dom/extend-expect' import '@vanilla-extract/css/disableRuntimeStyles' -import { MockBroadcastChannel } from '@island.is/react-spa/shared/mocks' - -global.BroadcastChannel = MockBroadcastChannel window.scrollTo = () => undefined diff --git a/libs/portals/core/test/setup.ts b/libs/portals/core/test/setup.ts index 8d00d3048b23..7986237687fd 100644 --- a/libs/portals/core/test/setup.ts +++ b/libs/portals/core/test/setup.ts @@ -1,5 +1,2 @@ // Prevent all styles creation when running tests as they don´t depend on styling import '@vanilla-extract/css/disableRuntimeStyles' -import { MockBroadcastChannel } from '@island.is/react-spa/shared/mocks' - -global.BroadcastChannel = MockBroadcastChannel diff --git a/libs/react-spa/shared/mocks/index.ts b/libs/react-spa/shared/mocks/index.ts deleted file mode 100644 index 5010f8d04076..000000000000 --- a/libs/react-spa/shared/mocks/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './mockBroadcastChannel' diff --git a/libs/react-spa/shared/mocks/mockBroadcastChannel.ts b/libs/react-spa/shared/mocks/mockBroadcastChannel.ts deleted file mode 100644 index 141fcb5c0d60..000000000000 --- a/libs/react-spa/shared/mocks/mockBroadcastChannel.ts +++ /dev/null @@ -1,23 +0,0 @@ -export class MockBroadcastChannel { - constructor() { - this.onmessage = null - } - - postMessage() { - // No-op - } - - close() { - // No-op - } - - addEventListener(type, listener) { - if (type === 'message') { - this.onmessage = listener - } - } - - removeEventListener() { - // No-op - } -} diff --git a/libs/react-spa/shared/src/hooks/useBroadcaster.ts b/libs/react-spa/shared/src/hooks/useBroadcaster.ts index fbb55f7c046a..22e066d0a91f 100644 --- a/libs/react-spa/shared/src/hooks/useBroadcaster.ts +++ b/libs/react-spa/shared/src/hooks/useBroadcaster.ts @@ -69,6 +69,9 @@ export const useBroadcaster = ({ } } +const isTestEnv = process.env.NODE_ENV === 'test' + + /** * Factory function to create a custom hook for managing a BroadcastChannel. * @@ -111,9 +114,21 @@ export const useBroadcaster = ({ * }, [postMessage]) */ export const createBroadcasterHook = (channelName: string) => { - const channel = new BroadcastChannel(channelName) + let broadcastChannelInstance: BroadcastChannel | null = null + + // Skip BroadcastChannel initialization in test environment since it is not supported by Jest. + if (!isTestEnv) { + broadcastChannelInstance = new BroadcastChannel(channelName) + } return (onMessage?: (event: MessageEvent) => void) => { - return useBroadcaster({ channel, onMessage }) + if (isTestEnv || !broadcastChannelInstance) { + return null as unknown as ReturnType> + } + + return useBroadcaster({ + channel: broadcastChannelInstance, + onMessage, + }) } } diff --git a/libs/shared/components/jest.setup.ts b/libs/shared/components/jest.setup.ts index 2ba6b2fab9d9..cc8fccdb84f7 100644 --- a/libs/shared/components/jest.setup.ts +++ b/libs/shared/components/jest.setup.ts @@ -1,5 +1,2 @@ import '@testing-library/jest-dom/extend-expect' import '@vanilla-extract/css/disableRuntimeStyles' -import { MockBroadcastChannel } from '@island.is/react-spa/shared/mocks' - -global.BroadcastChannel = MockBroadcastChannel diff --git a/tsconfig.base.json b/tsconfig.base.json index 3b36190db9d8..489d1d31b04f 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -974,9 +974,6 @@ ], "@island.is/react-spa/bff": ["libs/react-spa/bff/src/index.ts"], "@island.is/react-spa/shared": ["libs/react-spa/shared/src/index.ts"], - "@island.is/react-spa/shared/mocks": [ - "libs/react-spa/shared/mocks/index.ts" - ], "@island.is/react/components": ["libs/react/components/src/index.ts"], "@island.is/react/feature-flags": [ "libs/react/feature-flags/src/index.ts" From 4e871bf47f53733b686b4be474694563ca61eb73 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Tue, 8 Oct 2024 09:32:41 +0000 Subject: [PATCH 091/248] Remove redundant timeout in favour of poller --- libs/react-spa/bff/src/lib/BffProvider.tsx | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/libs/react-spa/bff/src/lib/BffProvider.tsx b/libs/react-spa/bff/src/lib/BffProvider.tsx index cee6650fa549..0d81ce16bd1d 100644 --- a/libs/react-spa/bff/src/lib/BffProvider.tsx +++ b/libs/react-spa/bff/src/lib/BffProvider.tsx @@ -10,7 +10,6 @@ import { BffBroadcastEvents, useBffBroadcaster } from './bff.hooks' import { ActionType, initialState, reducer } from './bff.state' import { createBffUrlGenerator, isNewSession } from './bff.utils' -const ONE_HOUR_MS = 1000 * 60 * 60 const BFF_SERVER_UNAVAILABLE = 'BFF_SERVER_UNAVAILABLE' type BffProviderProps = { @@ -176,19 +175,6 @@ export const BffProvider = ({ } }) - useEffectOnce(() => { - const timeout = setTimeout(() => { - // After one hour we check if the user is still logged in - // and we tell the /user endpoint not to refresh the tokens, - // since we are checking for timeout expiration. - checkLogin(true) - }, ONE_HOUR_MS) - - return () => { - clearTimeout(timeout) - } - }) - const newSessionCb = useCallback(() => { setSessionExpiredScreen(true) }, []) From f1ff85fe089ae61f62bf68005044e341bf800c50 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Tue, 8 Oct 2024 11:04:59 +0000 Subject: [PATCH 092/248] Fix portal config, fix redis cache module init, update bff provider to handle logout in before redirect --- .../bff/infra/utils/createPortalEnv.ts | 35 ++++++++++--------- apps/services/bff/src/app/bff.config.ts | 13 +++---- .../src/app/modules/auth/auth.controller.ts | 1 - .../bff/src/app/modules/cache/cache.module.ts | 14 +++++--- libs/react-spa/bff/src/lib/BffProvider.tsx | 26 +++++++------- 5 files changed, 46 insertions(+), 43 deletions(-) diff --git a/apps/services/bff/infra/utils/createPortalEnv.ts b/apps/services/bff/infra/utils/createPortalEnv.ts index 0a62c9582dca..5b450c88ad8a 100644 --- a/apps/services/bff/infra/utils/createPortalEnv.ts +++ b/apps/services/bff/infra/utils/createPortalEnv.ts @@ -11,20 +11,6 @@ const ONE_WEEK_IN_MS = ONE_HOUR_IN_MS * 24 * 7 type PortalKeys = 'stjornbord' | 'minarsidur' -const getDefaultEnvUrls = (asArray = false) => { - const local = 'http://localhost:4200/stjornbord' - const dev = 'https://beta.dev01.devland.is' - const staging = 'https://beta.staging01.devland.is' - const prod = 'https://island.is' - - return { - local: asArray ? json([local]) : local, - dev: asArray ? json([dev]) : dev, - staging: asArray ? json([staging]) : staging, - prod: asArray ? json([prod]) : prod, - } -} - const getScopes = (key: PortalKeys) => { switch (key) { case 'minarsidur': @@ -58,9 +44,24 @@ export const createPortalEnv = (key: PortalKeys) => { }, BFF_CLIENT_KEY_PATH: `/${key}`, BFF_PAR_SUPPORT_ENABLED: 'false', - BFF_ALLOWED_REDIRECT_URIS: getDefaultEnvUrls(true), - BFF_CLIENT_BASE_URL: getDefaultEnvUrls(), - BFF_LOGOUT_REDIRECT_URI: getDefaultEnvUrls(), + BFF_ALLOWED_REDIRECT_URIS: { + local: json(['http://localhost:4200/stjornbord']), + dev: json(['https://beta.dev01.devland.is']), + staging: json(['https://beta.staging01.devland.is']), + prod: json(['https://island.is']), + }, + BFF_CLIENT_BASE_URL: { + local: 'http://localhost:4200', + dev: 'https://beta.dev01.devland.is', + staging: 'https://beta.staging01.devland.is', + prod: 'https://island.is', + }, + BFF_LOGOUT_REDIRECT_URI: { + local: 'http://localhost:4200/stjornbord', + dev: 'https://beta.dev01.devland.is', + staging: 'https://beta.staging01.devland.is', + prod: 'https://island.is', + }, BFF_CALLBACKS_BASE_PATH: { local: `http://localhost:3010/${key}/bff/callbacks`, dev: `https://beta.dev01.devland.is/${key}/bff/callbacks`, diff --git a/apps/services/bff/src/app/bff.config.ts b/apps/services/bff/src/app/bff.config.ts index a00fefe572ee..116ab115bbe4 100644 --- a/apps/services/bff/src/app/bff.config.ts +++ b/apps/services/bff/src/app/bff.config.ts @@ -11,14 +11,11 @@ export const idsSchema = z.strictObject({ }) const BffConfigSchema = z.object({ - redis: z - .object({ - name: z.string(), - nodes: z.array(z.string()), - ssl: z.boolean(), - }) - // Only required in production - .optional(), + redis: z.object({ + name: z.string(), + nodes: z.array(z.string()), + ssl: z.boolean(), + }), graphqlApiEndpoint: z.string(), /** * The URL to redirect to after logging out diff --git a/apps/services/bff/src/app/modules/auth/auth.controller.ts b/apps/services/bff/src/app/modules/auth/auth.controller.ts index d0eca27838db..145fe38c4b94 100644 --- a/apps/services/bff/src/app/modules/auth/auth.controller.ts +++ b/apps/services/bff/src/app/modules/auth/auth.controller.ts @@ -10,7 +10,6 @@ import { Request, Response } from 'express' import { qsValidationPipe } from '../../utils/qs-validation-pipe' import { AuthService } from './auth.service' import { CallbackLoginQuery } from './queries/callback-login.query' -import { CallbackLogoutQuery } from './queries/callback-logout.query' import { LoginQuery } from './queries/login.query' import { LogoutQuery } from './queries/logout.query' diff --git a/apps/services/bff/src/app/modules/cache/cache.module.ts b/apps/services/bff/src/app/modules/cache/cache.module.ts index dc3fe66967b9..379db4ba8f83 100644 --- a/apps/services/bff/src/app/modules/cache/cache.module.ts +++ b/apps/services/bff/src/app/modules/cache/cache.module.ts @@ -15,11 +15,15 @@ export class CacheModule { ? [NestCacheModule.register()] : [ NestCacheModule.registerAsync({ - useFactory: ({ redis }: ConfigType) => ({ - store: redis - ? redisInsStore(createRedisCluster(redis)) - : undefined, - }), + useFactory: ({ redis }: ConfigType) => { + const configHasRedis = redis.nodes.length > 0 && redis.name + + return { + store: configHasRedis + ? redisInsStore(createRedisCluster(redis)) + : undefined, + } + }, inject: [BffConfig.KEY], }), ] diff --git a/libs/react-spa/bff/src/lib/BffProvider.tsx b/libs/react-spa/bff/src/lib/BffProvider.tsx index 0d81ce16bd1d..84696de254cd 100644 --- a/libs/react-spa/bff/src/lib/BffProvider.tsx +++ b/libs/react-spa/bff/src/lib/BffProvider.tsx @@ -44,11 +44,16 @@ export const BffProvider = ({ ) { setSessionExpiredScreen(true) } else if (event.data.type === BffBroadcastEvents.LOGOUT) { - dispatch({ - type: ActionType.LOGGED_OUT, - }) + // We will wait 5 seconds before we dispatch logout action. + // The reason is that IDS will not log the user out immediately. + // Note! The bff poller may have triggered logout by that time anyways. + setTimeout(() => { + dispatch({ + type: ActionType.LOGGED_OUT, + }) - signIn() + signIn() + }, 5000) } }) @@ -121,17 +126,14 @@ export const BffProvider = ({ type: ActionType.LOGGING_OUT, }) + // Broadcast to all tabs/windows/iframes that the user is logging out + postMessage({ + type: BffBroadcastEvents.LOGOUT, + }) + window.location.href = bffUrlGenerator('/logout', { sid: state.userInfo.profile.sid, }) - - setTimeout(() => { - // We will wait 5 seconds before we post the logout message to other tabs/windows/iframes. - // The reason is that IDS will not log the user out immediately. - postMessage({ - type: BffBroadcastEvents.LOGOUT, - }) - }, 5000) }, [bffUrlGenerator, postMessage, state.userInfo]) const switchUser = (nationalId?: string) => { From b941f525ccc50083394ee94f8fbc0ada7278ac01 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Tue, 8 Oct 2024 11:25:52 +0000 Subject: [PATCH 093/248] Remove timeout in logout broadcasting and throw the error in postRequest if not successful plain text response --- .../services/bff/src/app/modules/ids/ids.service.ts | 6 +++++- libs/react-spa/bff/src/lib/BffProvider.tsx | 13 ++++--------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/apps/services/bff/src/app/modules/ids/ids.service.ts b/apps/services/bff/src/app/modules/ids/ids.service.ts index fff55993e6e7..192dec2db6c5 100644 --- a/apps/services/bff/src/app/modules/ids/ids.service.ts +++ b/apps/services/bff/src/app/modules/ids/ids.service.ts @@ -9,7 +9,7 @@ import { ApiResponse, ErrorRes, ParResponse, TokenResponse } from './ids.types' @Injectable() export class IdsService { - private readonly issuerUrl + private readonly issuerUrl: string constructor( @Inject(BffConfig.KEY) @@ -71,6 +71,10 @@ export class IdsService { // Handle plain text responses const textResponse = await response.text() + if (!response.ok) { + throw textResponse + } + return { type: 'success', data: textResponse, diff --git a/libs/react-spa/bff/src/lib/BffProvider.tsx b/libs/react-spa/bff/src/lib/BffProvider.tsx index 84696de254cd..0a6a2229139e 100644 --- a/libs/react-spa/bff/src/lib/BffProvider.tsx +++ b/libs/react-spa/bff/src/lib/BffProvider.tsx @@ -44,16 +44,11 @@ export const BffProvider = ({ ) { setSessionExpiredScreen(true) } else if (event.data.type === BffBroadcastEvents.LOGOUT) { - // We will wait 5 seconds before we dispatch logout action. - // The reason is that IDS will not log the user out immediately. - // Note! The bff poller may have triggered logout by that time anyways. - setTimeout(() => { - dispatch({ - type: ActionType.LOGGED_OUT, - }) + dispatch({ + type: ActionType.LOGGED_OUT, + }) - signIn() - }, 5000) + signIn() } }) From 684862f536b76790e7f96f3d24323a0a88432fa3 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Tue, 8 Oct 2024 11:31:42 +0000 Subject: [PATCH 094/248] Revert the timeout in the logout --- libs/react-spa/bff/src/lib/BffProvider.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/libs/react-spa/bff/src/lib/BffProvider.tsx b/libs/react-spa/bff/src/lib/BffProvider.tsx index 0a6a2229139e..463faf5925fe 100644 --- a/libs/react-spa/bff/src/lib/BffProvider.tsx +++ b/libs/react-spa/bff/src/lib/BffProvider.tsx @@ -44,11 +44,16 @@ export const BffProvider = ({ ) { setSessionExpiredScreen(true) } else if (event.data.type === BffBroadcastEvents.LOGOUT) { - dispatch({ - type: ActionType.LOGGED_OUT, - }) + // We will wait 1 seconds before we dispatch logout action. + // The reason is that IDS will not log the user out immediately. + // Note! The bff poller may have triggered logout by that time anyways. + setTimeout(() => { + dispatch({ + type: ActionType.LOGGED_OUT, + }) - signIn() + signIn() + }, 1000) } }) From bcc91ab750cc8c9210dcf46af274fe6796b722ea Mon Sep 17 00:00:00 2001 From: andes-it Date: Tue, 8 Oct 2024 11:40:49 +0000 Subject: [PATCH 095/248] chore: charts update dirty files --- charts/islandis/values.dev.yaml | 5 +++-- charts/islandis/values.prod.yaml | 5 +++-- charts/islandis/values.staging.yaml | 5 +++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/charts/islandis/values.dev.yaml b/charts/islandis/values.dev.yaml index ef2a9126e370..a59fe06d2792 100644 --- a/charts/islandis/values.dev.yaml +++ b/charts/islandis/values.dev.yaml @@ -2288,9 +2288,10 @@ services-bff-admin-portal: BFF_ALLOWED_REDIRECT_URIS: '["https://beta.dev01.devland.is"]' BFF_CACHE_USER_PROFILE_TTL_MS: '3595000' BFF_CALLBACKS_BASE_PATH: 'https://beta.dev01.devland.is/stjornbord/bff/callbacks' - BFF_CLIENT_BASE_URL: '["https://beta.dev01.devland.is"]' + BFF_CLIENT_BASE_URL: 'https://beta.dev01.devland.is' BFF_CLIENT_KEY_PATH: '/stjornbord' - BFF_LOGOUT_REDIRECT_URI: '["https://beta.dev01.devland.is"]' + BFF_LOGIN_ATTEMPT_TTL_MS: '604800000' + BFF_LOGOUT_REDIRECT_URI: 'https://beta.dev01.devland.is' BFF_NAME: 'stjornbord' BFF_PAR_SUPPORT_ENABLED: 'false' BFF_PROXY_API_ENDPOINT: 'https://beta.dev01.devland.is/api/graphql' diff --git a/charts/islandis/values.prod.yaml b/charts/islandis/values.prod.yaml index f36bcc936c22..67ed71f9414e 100644 --- a/charts/islandis/values.prod.yaml +++ b/charts/islandis/values.prod.yaml @@ -2159,9 +2159,10 @@ services-bff-admin-portal: BFF_ALLOWED_REDIRECT_URIS: '["https://island.is"]' BFF_CACHE_USER_PROFILE_TTL_MS: '3595000' BFF_CALLBACKS_BASE_PATH: 'https://island.is/stjornbord/bff/callbacks' - BFF_CLIENT_BASE_URL: '["https://island.is"]' + BFF_CLIENT_BASE_URL: 'https://island.is' BFF_CLIENT_KEY_PATH: '/stjornbord' - BFF_LOGOUT_REDIRECT_URI: '["https://island.is"]' + BFF_LOGIN_ATTEMPT_TTL_MS: '604800000' + BFF_LOGOUT_REDIRECT_URI: 'https://island.is' BFF_NAME: 'stjornbord' BFF_PAR_SUPPORT_ENABLED: 'false' BFF_PROXY_API_ENDPOINT: 'https://island.is/api/graphql' diff --git a/charts/islandis/values.staging.yaml b/charts/islandis/values.staging.yaml index f23a8402f7a0..18ff6188973f 100644 --- a/charts/islandis/values.staging.yaml +++ b/charts/islandis/values.staging.yaml @@ -2031,9 +2031,10 @@ services-bff-admin-portal: BFF_ALLOWED_REDIRECT_URIS: '["https://beta.staging01.devland.is"]' BFF_CACHE_USER_PROFILE_TTL_MS: '3595000' BFF_CALLBACKS_BASE_PATH: 'https://beta.staging01.devland.is/stjornbord/bff/callbacks' - BFF_CLIENT_BASE_URL: '["https://beta.staging01.devland.is"]' + BFF_CLIENT_BASE_URL: 'https://beta.staging01.devland.is' BFF_CLIENT_KEY_PATH: '/stjornbord' - BFF_LOGOUT_REDIRECT_URI: '["https://beta.staging01.devland.is"]' + BFF_LOGIN_ATTEMPT_TTL_MS: '604800000' + BFF_LOGOUT_REDIRECT_URI: 'https://beta.staging01.devland.is' BFF_NAME: 'stjornbord' BFF_PAR_SUPPORT_ENABLED: 'false' BFF_PROXY_API_ENDPOINT: 'https://beta.staging01.devland.is/api/graphql' From d3a75524f13d2028085720f799d857918969be2c Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Tue, 8 Oct 2024 12:48:40 +0000 Subject: [PATCH 096/248] Rename queries to dto for consistency in monorepo and add log for logout callback --- .../src/app/modules/auth/auth.controller.ts | 31 +++++++++++++++---- .../bff/src/app/modules/auth/auth.service.ts | 21 +++++++++---- .../callback-login.dto.ts} | 2 +- .../modules/auth/dto/callback-logout.dto.ts | 7 +++++ .../login.query.ts => dto/login.dto.ts} | 2 +- .../logout.query.ts => dto/logout.dto.ts} | 2 +- .../auth/queries/callback-logout.query.ts | 6 ---- .../api-proxy.dto.ts} | 2 +- .../src/app/modules/proxy/proxy.controller.ts | 4 +-- .../src/app/modules/proxy/proxy.service.ts | 4 +-- .../get-user.query.ts => dto/get-user.dto.ts} | 2 +- .../src/app/modules/user/user.controller.ts | 4 +-- 12 files changed, 58 insertions(+), 29 deletions(-) rename apps/services/bff/src/app/modules/auth/{queries/callback-login.query.ts => dto/callback-login.dto.ts} (92%) create mode 100644 apps/services/bff/src/app/modules/auth/dto/callback-logout.dto.ts rename apps/services/bff/src/app/modules/auth/{queries/login.query.ts => dto/login.dto.ts} (89%) rename apps/services/bff/src/app/modules/auth/{queries/logout.query.ts => dto/logout.dto.ts} (73%) delete mode 100644 apps/services/bff/src/app/modules/auth/queries/callback-logout.query.ts rename apps/services/bff/src/app/modules/proxy/{queries/api-proxy.query.ts => dto/api-proxy.dto.ts} (75%) rename apps/services/bff/src/app/modules/user/{queries/get-user.query.ts => dto/get-user.dto.ts} (79%) diff --git a/apps/services/bff/src/app/modules/auth/auth.controller.ts b/apps/services/bff/src/app/modules/auth/auth.controller.ts index 145fe38c4b94..7ed39143a2b0 100644 --- a/apps/services/bff/src/app/modules/auth/auth.controller.ts +++ b/apps/services/bff/src/app/modules/auth/auth.controller.ts @@ -1,6 +1,8 @@ import { + Body, Controller, Get, + Post, Query, Req, Res, @@ -9,9 +11,10 @@ import { import { Request, Response } from 'express' import { qsValidationPipe } from '../../utils/qs-validation-pipe' import { AuthService } from './auth.service' -import { CallbackLoginQuery } from './queries/callback-login.query' -import { LoginQuery } from './queries/login.query' -import { LogoutQuery } from './queries/logout.query' +import { CallbackLoginDto } from './dto/callback-login.dto' +import { CallbackLogoutDto } from './dto/callback-logout.dto' +import { LoginDto } from './dto/login.dto' +import { LogoutDto } from './dto/logout.dto' @Controller({ version: [VERSION_NEUTRAL, '1'], @@ -23,7 +26,7 @@ export class AuthController { async login( @Res() res: Response, @Query(qsValidationPipe) - query: LoginQuery, + query: LoginDto, ): Promise { return this.authService.login({ res, query }) } @@ -33,7 +36,7 @@ export class AuthController { @Req() req: Request, @Res() res: Response, @Query(qsValidationPipe) - query: CallbackLoginQuery, + query: CallbackLoginDto, ): Promise { return this.authService.callbackLogin({ req, res, query }) } @@ -43,8 +46,24 @@ export class AuthController { @Req() req: Request, @Res() res: Response, @Query(qsValidationPipe) - query: LogoutQuery, + query: LogoutDto, ): Promise { return this.authService.logout({ req, res, query }) } + + @Post('callbacks/logout') + async callbackBackchannelLogout( + @Req() req: Request, + @Body() body: CallbackLogoutDto, + ): Promise { + console.log('------------------------------------------------------------') + console.log('callbackBackchannelLogout', body) + console.log('------------------------------------------------------------') + + // TODO validate the token + //https://openid.net/specs/openid-connect-backchannel-1_0.html + // clear the cache + + return + } } diff --git a/apps/services/bff/src/app/modules/auth/auth.service.ts b/apps/services/bff/src/app/modules/auth/auth.service.ts index 269ea487abf1..176e32ad7d7a 100644 --- a/apps/services/bff/src/app/modules/auth/auth.service.ts +++ b/apps/services/bff/src/app/modules/auth/auth.service.ts @@ -22,9 +22,10 @@ import { CacheService } from '../cache/cache.service' import { IdsService } from '../ids/ids.service' import { TokenResponse } from '../ids/ids.types' import { CachedTokenResponse } from './auth.types' -import { CallbackLoginQuery } from './queries/callback-login.query' -import { LoginQuery } from './queries/login.query' -import { LogoutQuery } from './queries/logout.query' +import { CallbackLoginDto } from './dto/callback-login.dto' +import { CallbackLogoutDto } from './dto/callback-logout.dto' +import { LoginDto } from './dto/login.dto' +import { LogoutDto } from './dto/logout.dto' @Injectable() export class AuthService { @@ -151,7 +152,7 @@ export class AuthService { query: { target_link_uri: targetLinkUri, login_hint: loginHint, prompt }, }: { res: Response - query: LoginQuery + query: LoginDto }) { // Validate targetLinkUri if it is provided if ( @@ -243,7 +244,7 @@ export class AuthService { }: { req: Request res: Response - query: CallbackLoginQuery + query: CallbackLoginDto }) { const idsError = query.invalid_request @@ -360,7 +361,7 @@ export class AuthService { }: { req: Request res: Response - query: LogoutQuery + query: LogoutDto }) { const sidCookie = req.cookies[SESSION_COOKIE_NAME] @@ -430,4 +431,12 @@ export class AuthService { `${this.baseUrl}/connect/endsession?${searchParams.toString()}`, ) } + + async callbackLogout(req: Request, body: CallbackLogoutDto) { + this.logger.warn('callbackBackchannelLogout', JSON.stringify(body, null, 2)) + + // TODO validate the token + // clear the cache + // https://openid.net/specs/openid-connect-backchannel-1_0.html + } } diff --git a/apps/services/bff/src/app/modules/auth/queries/callback-login.query.ts b/apps/services/bff/src/app/modules/auth/dto/callback-login.dto.ts similarity index 92% rename from apps/services/bff/src/app/modules/auth/queries/callback-login.query.ts rename to apps/services/bff/src/app/modules/auth/dto/callback-login.dto.ts index 044e1268a793..f8a22df32ff2 100644 --- a/apps/services/bff/src/app/modules/auth/queries/callback-login.query.ts +++ b/apps/services/bff/src/app/modules/auth/dto/callback-login.dto.ts @@ -1,6 +1,6 @@ import { IsOptional, IsString } from 'class-validator' -export class CallbackLoginQuery { +export class CallbackLoginDto { @IsOptional() @IsString() code?: string diff --git a/apps/services/bff/src/app/modules/auth/dto/callback-logout.dto.ts b/apps/services/bff/src/app/modules/auth/dto/callback-logout.dto.ts new file mode 100644 index 000000000000..129b96ba83e3 --- /dev/null +++ b/apps/services/bff/src/app/modules/auth/dto/callback-logout.dto.ts @@ -0,0 +1,7 @@ +import { IsOptional, IsString } from 'class-validator' + +export class CallbackLogoutDto { + @IsOptional() + @IsString() + logout_token?: string +} diff --git a/apps/services/bff/src/app/modules/auth/queries/login.query.ts b/apps/services/bff/src/app/modules/auth/dto/login.dto.ts similarity index 89% rename from apps/services/bff/src/app/modules/auth/queries/login.query.ts rename to apps/services/bff/src/app/modules/auth/dto/login.dto.ts index 6e80be378ee6..8132325aa029 100644 --- a/apps/services/bff/src/app/modules/auth/queries/login.query.ts +++ b/apps/services/bff/src/app/modules/auth/dto/login.dto.ts @@ -1,6 +1,6 @@ import { IsOptional, IsString } from 'class-validator' -export class LoginQuery { +export class LoginDto { @IsOptional() @IsString() target_link_uri?: string diff --git a/apps/services/bff/src/app/modules/auth/queries/logout.query.ts b/apps/services/bff/src/app/modules/auth/dto/logout.dto.ts similarity index 73% rename from apps/services/bff/src/app/modules/auth/queries/logout.query.ts rename to apps/services/bff/src/app/modules/auth/dto/logout.dto.ts index 3acae6d8475a..0462aa69fe9a 100644 --- a/apps/services/bff/src/app/modules/auth/queries/logout.query.ts +++ b/apps/services/bff/src/app/modules/auth/dto/logout.dto.ts @@ -1,6 +1,6 @@ import { IsString } from 'class-validator' -export class LogoutQuery { +export class LogoutDto { @IsString() sid!: string } diff --git a/apps/services/bff/src/app/modules/auth/queries/callback-logout.query.ts b/apps/services/bff/src/app/modules/auth/queries/callback-logout.query.ts deleted file mode 100644 index be27cf6eeee1..000000000000 --- a/apps/services/bff/src/app/modules/auth/queries/callback-logout.query.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { IsString } from 'class-validator' - -export class CallbackLogoutQuery { - @IsString() - state!: string -} diff --git a/apps/services/bff/src/app/modules/proxy/queries/api-proxy.query.ts b/apps/services/bff/src/app/modules/proxy/dto/api-proxy.dto.ts similarity index 75% rename from apps/services/bff/src/app/modules/proxy/queries/api-proxy.query.ts rename to apps/services/bff/src/app/modules/proxy/dto/api-proxy.dto.ts index de4f79ca7910..51f965eb4cc0 100644 --- a/apps/services/bff/src/app/modules/proxy/queries/api-proxy.query.ts +++ b/apps/services/bff/src/app/modules/proxy/dto/api-proxy.dto.ts @@ -1,6 +1,6 @@ import { IsString } from 'class-validator' -export class ApiQuery { +export class ApiDto { @IsString() url!: string } diff --git a/apps/services/bff/src/app/modules/proxy/proxy.controller.ts b/apps/services/bff/src/app/modules/proxy/proxy.controller.ts index f24b7a19b8ea..7f01ecc8f8c6 100644 --- a/apps/services/bff/src/app/modules/proxy/proxy.controller.ts +++ b/apps/services/bff/src/app/modules/proxy/proxy.controller.ts @@ -9,7 +9,7 @@ import { import { Request, Response } from 'express' import { qsValidationPipe } from '../../utils/qs-validation-pipe' import { ProxyService } from './proxy.service' -import { ApiQuery } from './queries/api-proxy.query' +import { ApiDto } from './dto/api-proxy.dto' @Controller({ path: 'api', @@ -23,7 +23,7 @@ export class ProxyController { @Req() req: Request, @Res() res: Response, @Query(qsValidationPipe) - query: ApiQuery, + query: ApiDto, ): Promise { return this.proxyService.proxyApiUrlRequest({ req, res, query }) } diff --git a/apps/services/bff/src/app/modules/proxy/proxy.service.ts b/apps/services/bff/src/app/modules/proxy/proxy.service.ts index c4f17cfa6deb..c508941ed162 100644 --- a/apps/services/bff/src/app/modules/proxy/proxy.service.ts +++ b/apps/services/bff/src/app/modules/proxy/proxy.service.ts @@ -18,7 +18,7 @@ import { AuthService } from '../auth/auth.service' import { CachedTokenResponse } from '../auth/auth.types' import { CacheService } from '../cache/cache.service' import { IdsService } from '../ids/ids.service' -import { ApiQuery } from './queries/api-proxy.query' +import { ApiDto } from './dto/api-proxy.dto' const droppedResponseHeaders = ['access-control-allow-origin'] @@ -179,7 +179,7 @@ export class ProxyService { }: { req: Request res: Response - query: ApiQuery + query: ApiDto }) { const { url } = query diff --git a/apps/services/bff/src/app/modules/user/queries/get-user.query.ts b/apps/services/bff/src/app/modules/user/dto/get-user.dto.ts similarity index 79% rename from apps/services/bff/src/app/modules/user/queries/get-user.query.ts rename to apps/services/bff/src/app/modules/user/dto/get-user.dto.ts index cc106de0cbef..e0991a40e2e4 100644 --- a/apps/services/bff/src/app/modules/user/queries/get-user.query.ts +++ b/apps/services/bff/src/app/modules/user/dto/get-user.dto.ts @@ -1,6 +1,6 @@ import { IsOptional, IsString } from 'class-validator' -export class GetUserQuery { +export class GetUserDto { @IsOptional() @IsString() no_refresh?: string diff --git a/apps/services/bff/src/app/modules/user/user.controller.ts b/apps/services/bff/src/app/modules/user/user.controller.ts index b94c54364f9e..28e2b1383864 100644 --- a/apps/services/bff/src/app/modules/user/user.controller.ts +++ b/apps/services/bff/src/app/modules/user/user.controller.ts @@ -4,7 +4,7 @@ import type { BffUser } from '@island.is/shared/types' import { Controller, Get, Query, Req, VERSION_NEUTRAL } from '@nestjs/common' import { qsValidationPipe } from '../../utils/qs-validation-pipe' -import { GetUserQuery } from './queries/get-user.query' +import { GetUserDto } from './dto/get-user.dto' import { UserService } from './user.service' @Controller({ @@ -18,7 +18,7 @@ export class UserController { async getUser( @Req() req: Request, @Query(qsValidationPipe) - query: GetUserQuery, + query: GetUserDto, ): Promise { return this.userService.getUser(req, query.no_refresh === 'true') } From 16fce38538f619eb31ccb3c2ccd94d98bb487f3a Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Tue, 8 Oct 2024 12:50:33 +0000 Subject: [PATCH 097/248] Fix cli error that got merged from main --- apps/portals/admin/project.json | 2 +- infra/src/cli/cli.ts | 17 +++++++---------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/apps/portals/admin/project.json b/apps/portals/admin/project.json index bcf34eb1072f..12d4ddd42c8c 100644 --- a/apps/portals/admin/project.json +++ b/apps/portals/admin/project.json @@ -109,7 +109,7 @@ "executor": "nx:run-commands", "options": { "commands": [ - "node -r esbuild-register src/cli/cli.ts run-local-env --service services-bff-admin-portal" + "node -r esbuild-register src/cli/cli.ts run-local-env services-bff-admin-portal" ], "cwd": "infra" } diff --git a/infra/src/cli/cli.ts b/infra/src/cli/cli.ts index 78cf78f8d92a..60cc03a65e80 100644 --- a/infra/src/cli/cli.ts +++ b/infra/src/cli/cli.ts @@ -54,7 +54,7 @@ const cli = yargs(process.argv.slice(2)) }, ) .command( - 'render-local-env', + 'render-local-env [services..]', 'Render environment variables needed by service.\nThis is to be used when developing locally and loading of the environment variables for "dev" environment is needed.', (yargs) => { return yargs @@ -70,22 +70,19 @@ const cli = yargs(process.argv.slice(2)) default: false, alias: ['nosecrets', 'no-secrets'], }) - .demandCommand(1, 'You must pass at least one service to run!') }, async (argv) => { - const services = await renderLocalServices({ + await renderLocalServices({ services: argv.services, dryRun: argv.dry, json: argv.json, print: true, noUpdateSecrets: argv['no-update-secrets'], }) - - return }, ) .command( - 'run-local-env', + 'run-local-env [services..]', 'Render environment and run the local environment.\nThis is to be used when developing locally and loading of the environment variables for "dev" environment is needed.', (yargs) => { return yargs @@ -109,9 +106,8 @@ const cli = yargs(process.argv.slice(2)) type: 'boolean', default: false, }) - .demandCommand(1, 'You must pass at least one service to run!') }, - async (argv) => + async (argv) => { await runLocalServices(argv.services, argv.dependencies, { dryRun: argv.dry, json: argv.json, @@ -119,7 +115,8 @@ const cli = yargs(process.argv.slice(2)) noUpdateSecrets: argv['no-update-secrets'], print: argv.print, startProxies: argv.proxies, - }), + }) + }, ) - .demandCommand(1) + .demandCommand(1, 'You must pass at least one service to run!') .parse() From bc9ed7b743122ee0b6bd13836c036e51bd6b11b8 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Tue, 8 Oct 2024 19:34:42 +0000 Subject: [PATCH 098/248] Fix prettier formatting error --- .../shared/src/hooks/useBroadcaster.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/libs/react-spa/shared/src/hooks/useBroadcaster.ts b/libs/react-spa/shared/src/hooks/useBroadcaster.ts index 22e066d0a91f..4f409c6bb2cf 100644 --- a/libs/react-spa/shared/src/hooks/useBroadcaster.ts +++ b/libs/react-spa/shared/src/hooks/useBroadcaster.ts @@ -71,7 +71,6 @@ export const useBroadcaster = ({ const isTestEnv = process.env.NODE_ENV === 'test' - /** * Factory function to create a custom hook for managing a BroadcastChannel. * @@ -122,8 +121,20 @@ export const createBroadcasterHook = (channelName: string) => { } return (onMessage?: (event: MessageEvent) => void) => { - if (isTestEnv || !broadcastChannelInstance) { - return null as unknown as ReturnType> + if (isTestEnv) { + return { + postMessage: (message: Events) => { + console.warn( + 'postMessage called in test environment with message: ', + message, + ) + }, + error: null, + } as UseBroadcasterReturn + } else if (!broadcastChannelInstance) { + throw new Error( + 'BroadcastChannel is not supported in this environment. Ensure the environment supports BroadcastChannel before using this hook.', + ) } return useBroadcaster({ From 204839482b031bde9dfb3850fdf02c64e3b3c493 Mon Sep 17 00:00:00 2001 From: andes-it Date: Tue, 8 Oct 2024 20:02:14 +0000 Subject: [PATCH 099/248] chore: nx format:write update dirty files --- apps/portals/admin/project.json | 24 ++++++------------------ 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/apps/portals/admin/project.json b/apps/portals/admin/project.json index 12d4ddd42c8c..39cf66c437c7 100644 --- a/apps/portals/admin/project.json +++ b/apps/portals/admin/project.json @@ -3,15 +3,11 @@ "$schema": "../../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "apps/portals/admin/src", "projectType": "application", - "tags": [ - "scope:portals-admin" - ], + "tags": ["scope:portals-admin"], "targets": { "build": { "executor": "@nx/webpack:webpack", - "outputs": [ - "{options.outputPath}" - ], + "outputs": ["{options.outputPath}"], "defaultConfiguration": "production", "options": { "compiler": "babel", @@ -26,9 +22,7 @@ "apps/portals/admin/src/mockServiceWorker.js", "apps/portals/admin/src/assets" ], - "styles": [ - "apps/portals/admin/src/styles.css" - ], + "styles": ["apps/portals/admin/src/styles.css"], "scripts": [], "webpackConfig": "apps/portals/admin/webpack.config.js" }, @@ -55,9 +49,7 @@ "node scripts/dockerfile-assets/bash/extract-environment.js apps/portals/admin/src" ] }, - "outputs": [ - "{workspaceRoot}/apps/portals/admin/src/index.html" - ] + "outputs": ["{workspaceRoot}/apps/portals/admin/src/index.html"] }, "serve": { "executor": "@nx/webpack:dev-server", @@ -83,9 +75,7 @@ }, "test": { "executor": "@nx/jest:jest", - "outputs": [ - "{workspaceRoot}/coverage/apps/portals/admin" - ], + "outputs": ["{workspaceRoot}/coverage/apps/portals/admin"], "options": { "jestConfig": "apps/portals/admin/jest.config.ts" } @@ -99,9 +89,7 @@ "dev-init": { "executor": "nx:run-commands", "options": { - "commands": [ - "yarn get-secrets portals-admin" - ], + "commands": ["yarn get-secrets portals-admin"], "parallel": false } }, From 88a6e3764bb25a436436c26e0431b932a4c17cbc Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Tue, 8 Oct 2024 20:40:42 +0000 Subject: [PATCH 100/248] fix storybook build --- libs/island-ui/storybook/config/main.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/libs/island-ui/storybook/config/main.ts b/libs/island-ui/storybook/config/main.ts index 3cd410158982..3c9e5ccccacd 100644 --- a/libs/island-ui/storybook/config/main.ts +++ b/libs/island-ui/storybook/config/main.ts @@ -96,6 +96,7 @@ const config: StorybookConfig = { ), '@island.is/feature-flags': rootDir('../../../feature-flags/src'), '@island.is/react-spa/shared': rootDir('../../../react-spa/shared/src'), + '@island.is/react-spa/bff': rootDir('../../../react-spa/bff/src'), }, } return config From fba600616af6e784f9f888f119a2f0c275758d50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B3n=20Levy?= Date: Wed, 9 Oct 2024 10:42:16 +0000 Subject: [PATCH 101/248] ci: trigger from levy user --- apps/services/bff/infra/admin-portal.infra.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/services/bff/infra/admin-portal.infra.ts b/apps/services/bff/infra/admin-portal.infra.ts index 60f9111c4401..ba8853eb1b7e 100644 --- a/apps/services/bff/infra/admin-portal.infra.ts +++ b/apps/services/bff/infra/admin-portal.infra.ts @@ -1,3 +1,4 @@ +// DELETEME import { ServiceBuilder, service } from '../../../../infra/src/dsl/dsl' import { createPortalEnv } from './utils/createPortalEnv' From aac4e2dd5a9c2e2fba6b2572095518c7c8f1ac15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B3n=20Levy?= Date: Wed, 9 Oct 2024 15:47:58 +0000 Subject: [PATCH 102/248] fix: use portals-admin, added portal-env test --- apps/services/bff/infra/admin-portal.infra.ts | 17 +- infra/src/dsl/portal-env.spec.ts | 234 ++++++++++++++++++ infra/src/feature-env.ts | 10 +- 3 files changed, 248 insertions(+), 13 deletions(-) create mode 100644 infra/src/dsl/portal-env.spec.ts diff --git a/apps/services/bff/infra/admin-portal.infra.ts b/apps/services/bff/infra/admin-portal.infra.ts index ba8853eb1b7e..83e80af88861 100644 --- a/apps/services/bff/infra/admin-portal.infra.ts +++ b/apps/services/bff/infra/admin-portal.infra.ts @@ -1,20 +1,23 @@ -// DELETEME import { ServiceBuilder, service } from '../../../../infra/src/dsl/dsl' import { createPortalEnv } from './utils/createPortalEnv' +const bffName = "services-bff" +const clientName = "portals-admin" +const serviceName = `${bffName}-${clientName}` -export const serviceSetup = (): ServiceBuilder<'services-bff-admin-portal'> => - service('services-bff-admin-portal') - .namespace('services-bff') - .image('services-bff') +export const serviceSetup = (): ServiceBuilder => + service(serviceName) + .namespace(clientName) + .image(bffName) .redis() + .serviceAccount(bffName) .env(createPortalEnv('stjornbord')) .secrets({ // The secret should be a valid 32-byte base64 key. // Generate key example: `openssl rand -base64 32` BFF_TOKEN_SECRET_BASE64: - '/k8s/services-bff/admin-portal/BFF_TOKEN_SECRET_BASE64', + `/k8s/${bffName}/${clientName}/BFF_TOKEN_SECRET_BASE64`, IDENTITY_SERVER_CLIENT_SECRET: - '/k8s/services-bff/admin-portal/IDENTITY_SERVER_CLIENT_SECRET', + `/k8s/${bffName}/${clientName}/IDENTITY_SERVER_CLIENT_SECRET`, }) .readiness('/health/check') .liveness('/liveness') diff --git a/infra/src/dsl/portal-env.spec.ts b/infra/src/dsl/portal-env.spec.ts new file mode 100644 index 000000000000..aedb59ea136c --- /dev/null +++ b/infra/src/dsl/portal-env.spec.ts @@ -0,0 +1,234 @@ +import { service } from './dsl' +import { Kubernetes } from './kubernetes-runtime' +import { SerializeSuccess, HelmService } from './types/output-types' +import { EnvironmentConfig } from './types/charts' +import { renderers } from './upstream-dependencies' +import { generateOutputOne } from './processing/rendering-pipeline' +import { createPortalEnv } from '../../../apps/services/bff/infra/utils/createPortalEnv' +import { json } from './dsl' + +import { + adminPortalScopes, + servicePortalScopes, +} from '../../../libs/auth/scopes/src/index' + +import { FIVE_SECONDS_IN_MS } from '../../../apps/services/bff/src/app/constants/time' +const ONE_HOUR_IN_MS = 60 * 60 * 1000 +const ONE_WEEK_IN_MS = ONE_HOUR_IN_MS * 24 * 7 + +const bffName = "services-bff" +const bffType = "stjornbord" +const clientName = "portals-admin" +const serviceName = `${bffName}-${clientName}` + + +const Staging: EnvironmentConfig = { + auroraHost: 'a', + redisHost: 'b', + domain: 'dev01.devland.is', + type: 'dev', + featuresOn: [], + defaultMaxReplicas: 3, + defaultMinReplicas: 2, + releaseName: 'web', + awsAccountId: '111111', + awsAccountRegion: 'eu-west-1', + global: {}, +} + +describe('BFF PortalEnv serialization', () => { + const sut = + service(serviceName) + .namespace(clientName) + .image(bffName) + .redis() + .serviceAccount(bffName) + .env(createPortalEnv(bffType)) + .secrets({ + BFF_TOKEN_SECRET_BASE64: + `/k8s/${bffName}/${clientName}/BFF_TOKEN_SECRET_BASE64`, + IDENTITY_SERVER_CLIENT_SECRET: + `/k8s/${bffName}/${clientName}/IDENTITY_SERVER_CLIENT_SECRET`, + }) + .command('node') + .args('main.js') + .readiness('/health/check') + .liveness('/liveness') + .replicaCount({ + default: 2, + min: 2, + max: 3, + }) + .resources({ + limits: { + cpu: '400m', + memory: '512Mi', + }, + requests: { + cpu: '100m', + memory: '256Mi', + }, + }) + .ingress({ + primary: { + host: { + dev: ['beta'], + staging: ['beta'], + prod: ['', 'www.island.is'], + }, + extraAnnotations: { + dev: { + 'nginx.ingress.kubernetes.io/proxy-buffering': 'on', + 'nginx.ingress.kubernetes.io/proxy-buffer-size': '8k', + }, + staging: { + 'nginx.ingress.kubernetes.io/enable-global-auth': 'false', + 'nginx.ingress.kubernetes.io/proxy-buffering': 'on', + 'nginx.ingress.kubernetes.io/proxy-buffer-size': '8k', + }, + prod: { + 'nginx.ingress.kubernetes.io/proxy-buffering': 'on', + 'nginx.ingress.kubernetes.io/proxy-buffer-size': '8k', + }, + }, + paths: [`/${bffType}/bff`], + }, + }) + let result: SerializeSuccess + beforeEach(async () => { + result = (await generateOutputOne({ + outputFormat: renderers.helm, + service: sut, + runtime: new Kubernetes(Staging), + env: Staging, + })) as SerializeSuccess + }) + + it('basic props', () => { + expect(result.serviceDef[0].enabled).toBe(true) + expect(result.serviceDef[0].namespace).toBe(clientName) + }) + + it('image and repo', () => { + expect(result.serviceDef[0].image.repository).toBe( + `821090935708.dkr.ecr.eu-west-1.amazonaws.com/${bffName}`, + ) + }) + + it('command and args', () => { + expect(result.serviceDef[0].command).toStrictEqual(['node']) + expect(result.serviceDef[0].args).toStrictEqual(['main.js']) + }) + it('network policies', () => { + expect(result.serviceDef[0].grantNamespaces).toStrictEqual([]) + expect(result.serviceDef[0].grantNamespacesEnabled).toBe(false) + }) + + it('resources', () => { + expect(result.serviceDef[0].resources).toStrictEqual({ + limits: { + cpu: '400m', + memory: '512Mi', + }, + requests: { + cpu: '100m', + memory: '256Mi', + }, + }) + }) + it('replica count', () => { + expect(result.serviceDef[0].replicaCount).toStrictEqual({ + min: 2, + max: 3, + default: 2, + }) + }) + + it('environment variables', () => { + expect(result.serviceDef[0].env).toEqual({ + IDENTITY_SERVER_CLIENT_SCOPES: json(adminPortalScopes), + IDENTITY_SERVER_CLIENT_ID: `@admin.island.is/bff-${bffType}`, + IDENTITY_SERVER_ISSUER_URL: 'https://identity-server.dev01.devland.is', + // BFF + BFF_NAME: "stjornbord", + BFF_CLIENT_KEY_PATH: `/${bffType}`, + BFF_PAR_SUPPORT_ENABLED: 'false', + BFF_ALLOWED_REDIRECT_URIS: json(['https://beta.dev01.devland.is']), + BFF_CLIENT_BASE_URL: 'https://beta.dev01.devland.is', + BFF_LOGOUT_REDIRECT_URI: 'https://beta.dev01.devland.is', + BFF_CALLBACKS_BASE_PATH: `https://beta.dev01.devland.is/${bffType}/bff/callbacks`, + BFF_PROXY_API_ENDPOINT: 'https://beta.dev01.devland.is/api/graphql', + BFF_ALLOWED_EXTERNAL_API_URLS: json(['https://api.dev01.devland.is']), + BFF_CACHE_USER_PROFILE_TTL_MS: ( + ONE_HOUR_IN_MS - FIVE_SECONDS_IN_MS + ).toString(), + BFF_LOGIN_ATTEMPT_TTL_MS: ONE_WEEK_IN_MS.toString(), + NODE_OPTIONS: '--max-old-space-size=460 -r dd-trace/init', + SERVERSIDE_FEATURES_ON: '', + LOG_LEVEL: 'info', + REDIS_URL_NODE_01: "b" + }) + }) + + it('secrets', () => { + expect(result.serviceDef[0].secrets).toEqual({ + BFF_TOKEN_SECRET_BASE64: + `/k8s/${bffName}/${clientName}/BFF_TOKEN_SECRET_BASE64`, + IDENTITY_SERVER_CLIENT_SECRET: + `/k8s/${bffName}/${clientName}/IDENTITY_SERVER_CLIENT_SECRET`, + CONFIGCAT_SDK_KEY: '/k8s/configcat/CONFIGCAT_SDK_KEY', + }) + }) + + it('service account', () => { + expect(result.serviceDef[0].podSecurityContext).toEqual({ + fsGroup: 65534, + }) + expect(result.serviceDef[0].serviceAccount).toEqual({ + annotations: { + 'eks.amazonaws.com/role-arn': `arn:aws:iam::111111:role/${bffName}`, + }, + create: true, + name: bffName, + }) + }) + + it('ingress', () => { + expect(result.serviceDef[0].ingress).toEqual({ + 'primary-alb': { + annotations: { + 'nginx.ingress.kubernetes.io/proxy-buffering': 'on', + 'nginx.ingress.kubernetes.io/proxy-buffer-size': '8k', + 'kubernetes.io/ingress.class': 'nginx-external-alb', + 'nginx.ingress.kubernetes.io/service-upstream': 'true', + }, + hosts: [ + { + host: 'beta.dev01.devland.is', + paths: ['/stjornbord/bff'], + }, + ], + }, + }) + }) +}) + +describe('Env definition defaults', () => { + const sut = service('api').namespace('islandis').image(bffName) + let result: SerializeSuccess + beforeEach(async () => { + result = (await generateOutputOne({ + outputFormat: renderers.helm, + service: sut, + runtime: new Kubernetes(Staging), + env: Staging, + })) as SerializeSuccess + }) + it('replica max count', () => { + expect(result.serviceDef[0].replicaCount).toStrictEqual({ + min: 2, + max: 3, + default: 2, + }) + }) +}) diff --git a/infra/src/feature-env.ts b/infra/src/feature-env.ts index 8f5dfaff157a..70e14471df10 100644 --- a/infra/src/feature-env.ts +++ b/infra/src/feature-env.ts @@ -19,7 +19,6 @@ import { renderHelmValueFileContent, } from './dsl/exports/helm' import { ServiceBuilder } from './dsl/dsl' -import { logger } from './common' type ChartName = 'islandis' | 'identity-server' @@ -100,10 +99,9 @@ const buildIngressComment = (data: HelmService[]): string => .join('\n') const buildComment = (data: Services): string => { - return `Feature deployment of your services will begin shortly. Your feature will be accessible here:\n${ - buildIngressComment(Object.values(data)) ?? + return `Feature deployment of your services will begin shortly. Your feature will be accessible here:\n${buildIngressComment(Object.values(data)) ?? 'Feature deployment of your services will begin shortly. No web endpoints defined (no ingresses were defined)' - }` + }` } const deployedComment = ( data: ServiceBuilder[], @@ -118,7 +116,7 @@ yargs(process.argv.slice(2)) .command( 'values', 'get helm values file', - () => {}, + () => { }, async (argv: Arguments) => { const { habitat, affectedServices, env } = parseArguments(argv) const { included: featureYaml } = await getFeatureAffectedServices( @@ -142,7 +140,7 @@ yargs(process.argv.slice(2)) .command( 'ingress-comment', 'get helm values file', - () => {}, + () => { }, async (argv: Arguments) => { const { habitat, affectedServices, env } = parseArguments(argv) const { included: featureYaml, excluded } = From f8b7ff2c745bb84a0f54aac75f6023124d50250a Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Wed, 9 Oct 2024 17:48:21 +0000 Subject: [PATCH 103/248] Revert manual validation and use library --- .../bff/src/app/modules/auth/auth.service.ts | 118 ++++++++++++++++-- 1 file changed, 106 insertions(+), 12 deletions(-) diff --git a/apps/services/bff/src/app/modules/auth/auth.service.ts b/apps/services/bff/src/app/modules/auth/auth.service.ts index 176e32ad7d7a..8030a59cdb3f 100644 --- a/apps/services/bff/src/app/modules/auth/auth.service.ts +++ b/apps/services/bff/src/app/modules/auth/auth.service.ts @@ -1,11 +1,19 @@ import type { Logger } from '@island.is/logging' import { LOGGER_PROVIDER } from '@island.is/logging' -import { Inject, Injectable } from '@nestjs/common' +import { + BadRequestException, + Inject, + Injectable, + InternalServerErrorException, + UnauthorizedException, +} from '@nestjs/common' import { ConfigType } from '@nestjs/config' import { CookieOptions, Request, Response } from 'express' +import jwksClient from 'jwks-rsa' import { jwtDecode } from 'jwt-decode' import { IdTokenClaims } from '@island.is/shared/types' +import { decode, verify, Algorithm } from 'jsonwebtoken' import { v4 as uuid } from 'uuid' import { environment } from '../../../environment' import { BffConfig } from '../../bff.config' @@ -20,7 +28,7 @@ import { import { validateUri } from '../../utils/validate-uri' import { CacheService } from '../cache/cache.service' import { IdsService } from '../ids/ids.service' -import { TokenResponse } from '../ids/ids.types' +import { LogoutTokenPayload, TokenResponse } from '../ids/ids.types' import { CachedTokenResponse } from './auth.types' import { CallbackLoginDto } from './dto/callback-login.dto' import { CallbackLogoutDto } from './dto/callback-logout.dto' @@ -387,12 +395,11 @@ export class AuthService { query.sid, ) - const cachedTokenResponse = - await this.cacheService.get( - currentLoginCacheKey, - // Do not throw an error if the key is not found - false, - ) + const cachedTokenResponse = await this.cacheService.get( + currentLoginCacheKey, + // Do not throw an error if the key is not found + false, + ) if (!cachedTokenResponse) { this.logger.error( @@ -432,11 +439,98 @@ export class AuthService { ) } - async callbackLogout(req: Request, body: CallbackLogoutDto) { + async validateLogoutToken(logoutToken: string): Promise { + try { + const secretClient = jwksClient({ + cache: true, + rateLimit: true, + jwksUri: `${this.config.ids.issuer}/.well-known/openid-configuration/jwks`, + }) + + // Decode the token without verifying the signature + const decodedToken = decode(logoutToken, { complete: true }) + const kid = decodedToken?.header?.kid + + if (!kid) { + throw new Error('Invalid token header. No kid found in header.') + } + + const signingKeys = await secretClient.getSigningKeys() + + // Find the the correct signing key matching the headers kid + const signingKey = signingKeys.find((sk) => sk.kid === kid) + + if (!signingKey) { + throw new Error(`No matching key found for kid "${kid}"`) + } + + const publicKey = signingKey.getPublicKey() + + // Verify the signature + const payload = verify(logoutToken, publicKey, { + algorithms: [signingKey.alg as Algorithm], + issuer: this.config.ids.issuer, + }) as LogoutTokenPayload + + if (!payload.sid) { + throw new Error('No sid in the token') + } + + return payload + } catch (error) { + this.logger.error('Error validating logout token: ', error) + + throw new UnauthorizedException() + } + } + + async callbackLogout(body: CallbackLogoutDto) { + // TODO: Remove this log statement when done testing on feature deploy this.logger.warn('callbackBackchannelLogout', JSON.stringify(body, null, 2)) + const logoutToken = body.logout_token + + if (!logoutToken) { + const errorMessage = 'No param "logout_token" provided!' + this.logger.error(errorMessage) + + throw new BadRequestException(errorMessage) + } + + try { + // Validate the logout token and extract payload + const payload = await this.validateLogoutToken(logoutToken) + + // Create cache key and retrieve cached token response + const cacheKey = this.cacheService.createSessionKeyType( + 'current', + payload.sid, + ) + const cachedTokenResponse = await this.cacheService.get( + cacheKey, + false, // Do not throw an error if the key is not found + ) - // TODO validate the token - // clear the cache - // https://openid.net/specs/openid-connect-backchannel-1_0.html + // Revoke refresh token and delete cache entry + if (cachedTokenResponse) { + this.revokeRefreshToken(cachedTokenResponse.encryptedRefreshToken) + } + + await this.cacheService.delete(cacheKey) + + return { + status: 'success', + message: 'Logout successful and cache cleared.', + } + } catch (error) { + // Check if error is an UnauthorizedException and just throw it, + // since it is already being logged in the validateLogoutToken method. + if (error instanceof UnauthorizedException) { + throw error + } + + this.logger.error('Callback backchannel logout failed: ', error) + + throw new InternalServerErrorException() + } } } From 32e5ae2aa0420039a4b9f54d47642f38865b1852 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Thu, 10 Oct 2024 10:42:36 +0000 Subject: [PATCH 104/248] Use fetch instead of post in download url --- .../src/components/DownloadDraftButton.tsx | 34 ++----------------- 1 file changed, 2 insertions(+), 32 deletions(-) diff --git a/libs/portals/admin/regulations-admin/src/components/DownloadDraftButton.tsx b/libs/portals/admin/regulations-admin/src/components/DownloadDraftButton.tsx index ecb74181bf88..5e4054aad9ed 100644 --- a/libs/portals/admin/regulations-admin/src/components/DownloadDraftButton.tsx +++ b/libs/portals/admin/regulations-admin/src/components/DownloadDraftButton.tsx @@ -49,42 +49,12 @@ export const DownloadDraftButton = ({ draftId, reviewButton }: Props) => { const url = data?.getDraftRegulationPdfDownload?.url if (url && !isFetchingFile) { - setIsFetchingFile(true) - - fetch( + window.open( bffUrlGenerator('/api', { url, }), - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - credentials: 'include', - }, + '_blank', ) - .then((response) => { - if (response.ok) { - // Convert response to blob for download - response.blob().then((blob) => { - const downloadUrl = URL.createObjectURL(blob) - // Open the download URL in a new tab - window.open(downloadUrl, '_newtab') - // Release the object URL to free up memory - URL.revokeObjectURL(downloadUrl) - }) - } else { - toast.error(t(editorMsgs.signedDocumentDownloadFreshError)) - } - }) - .catch((error) => { - console.error('Error occurred:', error) - - toast.error(t(editorMsgs.signedDocumentDownloadFreshError)) - }) - .finally(() => { - setIsFetchingFile(false) - }) } else if (data && !url) { toast.error(t(editorMsgs.signedDocumentDownloadFreshError)) } From 95293b437ab5bf99e371a73fc6976fcfc01a7f05 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Thu, 10 Oct 2024 10:01:25 +0000 Subject: [PATCH 105/248] Fix type errors and add forward get proxy api request --- apps/portals/admin/project.json | 26 ++++++++++++++----- .../src/app/modules/auth/auth.controller.ts | 15 ++++------- .../bff/src/app/modules/ids/ids.types.ts | 23 ++++++++++++++++ .../app/modules/proxy/dto/api-proxy.dto.ts | 2 +- .../src/app/modules/proxy/proxy.controller.ts | 11 ++++---- .../src/app/modules/proxy/proxy.service.ts | 8 +++--- 6 files changed, 58 insertions(+), 27 deletions(-) diff --git a/apps/portals/admin/project.json b/apps/portals/admin/project.json index 39cf66c437c7..b28683236526 100644 --- a/apps/portals/admin/project.json +++ b/apps/portals/admin/project.json @@ -3,11 +3,15 @@ "$schema": "../../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "apps/portals/admin/src", "projectType": "application", - "tags": ["scope:portals-admin"], + "tags": [ + "scope:portals-admin" + ], "targets": { "build": { "executor": "@nx/webpack:webpack", - "outputs": ["{options.outputPath}"], + "outputs": [ + "{options.outputPath}" + ], "defaultConfiguration": "production", "options": { "compiler": "babel", @@ -22,7 +26,9 @@ "apps/portals/admin/src/mockServiceWorker.js", "apps/portals/admin/src/assets" ], - "styles": ["apps/portals/admin/src/styles.css"], + "styles": [ + "apps/portals/admin/src/styles.css" + ], "scripts": [], "webpackConfig": "apps/portals/admin/webpack.config.js" }, @@ -49,7 +55,9 @@ "node scripts/dockerfile-assets/bash/extract-environment.js apps/portals/admin/src" ] }, - "outputs": ["{workspaceRoot}/apps/portals/admin/src/index.html"] + "outputs": [ + "{workspaceRoot}/apps/portals/admin/src/index.html" + ] }, "serve": { "executor": "@nx/webpack:dev-server", @@ -75,7 +83,9 @@ }, "test": { "executor": "@nx/jest:jest", - "outputs": ["{workspaceRoot}/coverage/apps/portals/admin"], + "outputs": [ + "{workspaceRoot}/coverage/apps/portals/admin" + ], "options": { "jestConfig": "apps/portals/admin/jest.config.ts" } @@ -89,7 +99,9 @@ "dev-init": { "executor": "nx:run-commands", "options": { - "commands": ["yarn get-secrets portals-admin"], + "commands": [ + "yarn get-secrets portals-admin" + ], "parallel": false } }, @@ -97,7 +109,7 @@ "executor": "nx:run-commands", "options": { "commands": [ - "node -r esbuild-register src/cli/cli.ts run-local-env services-bff-admin-portal" + "node -r esbuild-register src/cli/cli.ts run-local-env services-bff-portals-admin" ], "cwd": "infra" } diff --git a/apps/services/bff/src/app/modules/auth/auth.controller.ts b/apps/services/bff/src/app/modules/auth/auth.controller.ts index 7ed39143a2b0..27663765a7f9 100644 --- a/apps/services/bff/src/app/modules/auth/auth.controller.ts +++ b/apps/services/bff/src/app/modules/auth/auth.controller.ts @@ -55,15 +55,10 @@ export class AuthController { async callbackBackchannelLogout( @Req() req: Request, @Body() body: CallbackLogoutDto, - ): Promise { - console.log('------------------------------------------------------------') - console.log('callbackBackchannelLogout', body) - console.log('------------------------------------------------------------') - - // TODO validate the token - //https://openid.net/specs/openid-connect-backchannel-1_0.html - // clear the cache - - return + ): Promise<{ + status: string + message: string + }> { + return this.authService.callbackLogout(body) } } diff --git a/apps/services/bff/src/app/modules/ids/ids.types.ts b/apps/services/bff/src/app/modules/ids/ids.types.ts index 83798d29ef51..61cd5a2f6824 100644 --- a/apps/services/bff/src/app/modules/ids/ids.types.ts +++ b/apps/services/bff/src/app/modules/ids/ids.types.ts @@ -45,3 +45,26 @@ export interface ErrorRes { } export type ApiResponse = SuccessResponse | ErrorRes + +export type LogoutTokenPayload = { + // Issuer of the token + iss: string + + // Subject of the token + sub: string + + // Audience of the token + aud: string + + // Time when the token was issued + iat: number + + // Time when the token expires + exp: number + + // Session ID + sid: string + + // Unique identifier for the token. + jti: string +} diff --git a/apps/services/bff/src/app/modules/proxy/dto/api-proxy.dto.ts b/apps/services/bff/src/app/modules/proxy/dto/api-proxy.dto.ts index 51f965eb4cc0..10085530d836 100644 --- a/apps/services/bff/src/app/modules/proxy/dto/api-proxy.dto.ts +++ b/apps/services/bff/src/app/modules/proxy/dto/api-proxy.dto.ts @@ -1,6 +1,6 @@ import { IsString } from 'class-validator' -export class ApiDto { +export class ApiProxyDto { @IsString() url!: string } diff --git a/apps/services/bff/src/app/modules/proxy/proxy.controller.ts b/apps/services/bff/src/app/modules/proxy/proxy.controller.ts index 7f01ecc8f8c6..74af02361ec6 100644 --- a/apps/services/bff/src/app/modules/proxy/proxy.controller.ts +++ b/apps/services/bff/src/app/modules/proxy/proxy.controller.ts @@ -1,5 +1,6 @@ import { Controller, + Get, Post, Query, Req, @@ -9,7 +10,7 @@ import { import { Request, Response } from 'express' import { qsValidationPipe } from '../../utils/qs-validation-pipe' import { ProxyService } from './proxy.service' -import { ApiDto } from './dto/api-proxy.dto' +import { ApiProxyDto } from './dto/api-proxy.dto' @Controller({ path: 'api', @@ -18,14 +19,14 @@ import { ApiDto } from './dto/api-proxy.dto' export class ProxyController { constructor(private proxyService: ProxyService) {} - @Post() - async proxyApiUrlRequest( + @Get() + async forwardGetApiRequest( @Req() req: Request, @Res() res: Response, @Query(qsValidationPipe) - query: ApiDto, + query: ApiProxyDto, ): Promise { - return this.proxyService.proxyApiUrlRequest({ req, res, query }) + return this.proxyService.forwardGetApiRequest({ req, res, query }) } @Post('/graphql') diff --git a/apps/services/bff/src/app/modules/proxy/proxy.service.ts b/apps/services/bff/src/app/modules/proxy/proxy.service.ts index c508941ed162..da309449529f 100644 --- a/apps/services/bff/src/app/modules/proxy/proxy.service.ts +++ b/apps/services/bff/src/app/modules/proxy/proxy.service.ts @@ -18,7 +18,7 @@ import { AuthService } from '../auth/auth.service' import { CachedTokenResponse } from '../auth/auth.types' import { CacheService } from '../cache/cache.service' import { IdsService } from '../ids/ids.service' -import { ApiDto } from './dto/api-proxy.dto' +import { ApiProxyDto } from './dto/api-proxy.dto' const droppedResponseHeaders = ['access-control-allow-origin'] @@ -169,17 +169,17 @@ export class ProxyService { } /** - * Forwards an incoming HTTP POST request to the specified URL (provided in the query parameter), + * Forwards an incoming HTTP GET request to the specified URL (provided in the query string), * managing authentication, refreshing tokens if needed, and streaming the response back to the client. */ - async proxyApiUrlRequest({ + async forwardGetApiRequest({ req, res, query, }: { req: Request res: Response - query: ApiDto + query: ApiProxyDto }) { const { url } = query From be9e7449a9cd146dfd17397503679a40cf7fc1bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B3n=20Levy?= Date: Thu, 10 Oct 2024 15:11:21 +0000 Subject: [PATCH 106/248] fix: main conflict --- .../DelegationIncomingModal.tsx | 122 ------------------ 1 file changed, 122 deletions(-) delete mode 100644 libs/portals/shared-modules/delegations/src/components/delegations/incoming/DelegationIncomingModal/DelegationIncomingModal.tsx diff --git a/libs/portals/shared-modules/delegations/src/components/delegations/incoming/DelegationIncomingModal/DelegationIncomingModal.tsx b/libs/portals/shared-modules/delegations/src/components/delegations/incoming/DelegationIncomingModal/DelegationIncomingModal.tsx deleted file mode 100644 index 3e7828cf5a10..000000000000 --- a/libs/portals/shared-modules/delegations/src/components/delegations/incoming/DelegationIncomingModal/DelegationIncomingModal.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { useEffect } from 'react' -import { useUserInfo } from '@island.is/react-spa/bff' -import { Box } from '@island.is/island-ui/core' - -import { useLocale } from '@island.is/localization' -import { formatNationalId } from '@island.is/portals/core' -import { Modal, ModalProps } from '@island.is/react/components' -import { IdentityCard } from '../../../IdentityCard/IdentityCard' -import { AccessListContainer } from '../../../access/AccessList/AccessListContainer/AccessListContainer' -import { useAuthScopeTreeLazyQuery } from '../../../access/AccessList/AccessListContainer/AccessListContainer.generated' -import { AuthCustomDelegationIncoming } from '../../../../types/customDelegation' -import { m } from '../../../../lib/messages' -import format from 'date-fns/format' - -type DelegationIncomingModalProps = { - delegation?: AuthCustomDelegationIncoming -} & Pick - -export const DelegationIncomingModal = ({ - delegation, - onClose, - ...rest -}: DelegationIncomingModalProps) => { - const { formatMessage, lang } = useLocale() - const userInfo = useUserInfo() - const [getAuthScopeTree, { data: scopeTreeData, loading: scopeTreeLoading }] = - useAuthScopeTreeLazyQuery() - - useEffect(() => { - if (delegation && delegation.domain) { - getAuthScopeTree({ - variables: { - input: { - domain: delegation.domain.name, - lang, - }, - }, - }) - } - }, [delegation, getAuthScopeTree, lang]) - - const { authScopeTree } = scopeTreeData || {} - const fromName = delegation?.from?.name - const fromNationalId = delegation?.from?.nationalId - const toName = userInfo?.profile.name - const toNationalId = userInfo?.profile.nationalId - - return ( - - - - {fromName && fromNationalId && ( - - )} - {toName && toNationalId && ( - - )} - - - {delegation?.domain && ( - - )} - - {delegation?.type === 'GeneralMandate' && ( - - )} - - {delegation?.validTo && delegation.type === 'GeneralMandate' && ( - - )} - - {delegation?.type !== 'GeneralMandate' && ( - - )} - - ) -} From e884575b6683f5c66e03ede96fa50f5b6e66fb91 Mon Sep 17 00:00:00 2001 From: andes-it Date: Thu, 10 Oct 2024 15:12:01 +0000 Subject: [PATCH 107/248] chore: charts update dirty files --- charts/islandis/values.dev.yaml | 16 +++++++++++----- charts/islandis/values.prod.yaml | 16 +++++++++++----- charts/islandis/values.staging.yaml | 16 +++++++++++----- 3 files changed, 33 insertions(+), 15 deletions(-) diff --git a/charts/islandis/values.dev.yaml b/charts/islandis/values.dev.yaml index a59fe06d2792..702090bc98c4 100644 --- a/charts/islandis/values.dev.yaml +++ b/charts/islandis/values.dev.yaml @@ -1793,7 +1793,6 @@ namespaces: - 'services-sessions' - 'contentful-apps' - 'services-university-gateway' - - 'services-bff' portals-admin: enabled: true env: @@ -2281,7 +2280,7 @@ service-portal-api: eks.amazonaws.com/role-arn: 'arn:aws:iam::013313053092:role/service-portal-api' create: true name: 'service-portal-api' -services-bff-admin-portal: +services-bff-portals-admin: enabled: true env: BFF_ALLOWED_EXTERNAL_API_URLS: '["https://api.dev01.devland.is"]' @@ -2334,9 +2333,11 @@ services-bff-admin-portal: - host: 'beta.dev01.devland.is' paths: - '/stjornbord/bff' - namespace: 'services-bff' + namespace: 'portals-admin' podDisruptionBudget: maxUnavailable: 1 + podSecurityContext: + fsGroup: 65534 pvcs: [] replicaCount: default: 2 @@ -2350,12 +2351,17 @@ services-bff-admin-portal: cpu: '100m' memory: '256Mi' secrets: - BFF_TOKEN_SECRET_BASE64: '/k8s/services-bff/admin-portal/BFF_TOKEN_SECRET_BASE64' + BFF_TOKEN_SECRET_BASE64: '/k8s/services-bff/portals-admin/BFF_TOKEN_SECRET_BASE64' CONFIGCAT_SDK_KEY: '/k8s/configcat/CONFIGCAT_SDK_KEY' - IDENTITY_SERVER_CLIENT_SECRET: '/k8s/services-bff/admin-portal/IDENTITY_SERVER_CLIENT_SECRET' + IDENTITY_SERVER_CLIENT_SECRET: '/k8s/services-bff/portals-admin/IDENTITY_SERVER_CLIENT_SECRET' securityContext: allowPrivilegeEscalation: false privileged: false + serviceAccount: + annotations: + eks.amazonaws.com/role-arn: 'arn:aws:iam::013313053092:role/services-bff' + create: true + name: 'services-bff' services-documents: enabled: true env: diff --git a/charts/islandis/values.prod.yaml b/charts/islandis/values.prod.yaml index 67ed71f9414e..72b7357a0ec4 100644 --- a/charts/islandis/values.prod.yaml +++ b/charts/islandis/values.prod.yaml @@ -1658,7 +1658,6 @@ namespaces: - 'services-university-gateway' - 'contentful-apps' - 'contentful-entry-tagger' - - 'services-bff' portals-admin: enabled: true env: @@ -2152,7 +2151,7 @@ service-portal-api: eks.amazonaws.com/role-arn: 'arn:aws:iam::251502586493:role/service-portal-api' create: true name: 'service-portal-api' -services-bff-admin-portal: +services-bff-portals-admin: enabled: true env: BFF_ALLOWED_EXTERNAL_API_URLS: '["https://api.island.is"]' @@ -2208,9 +2207,11 @@ services-bff-admin-portal: - host: 'www.island.is' paths: - '/stjornbord/bff' - namespace: 'services-bff' + namespace: 'portals-admin' podDisruptionBudget: maxUnavailable: 1 + podSecurityContext: + fsGroup: 65534 pvcs: [] replicaCount: default: 2 @@ -2224,12 +2225,17 @@ services-bff-admin-portal: cpu: '100m' memory: '256Mi' secrets: - BFF_TOKEN_SECRET_BASE64: '/k8s/services-bff/admin-portal/BFF_TOKEN_SECRET_BASE64' + BFF_TOKEN_SECRET_BASE64: '/k8s/services-bff/portals-admin/BFF_TOKEN_SECRET_BASE64' CONFIGCAT_SDK_KEY: '/k8s/configcat/CONFIGCAT_SDK_KEY' - IDENTITY_SERVER_CLIENT_SECRET: '/k8s/services-bff/admin-portal/IDENTITY_SERVER_CLIENT_SECRET' + IDENTITY_SERVER_CLIENT_SECRET: '/k8s/services-bff/portals-admin/IDENTITY_SERVER_CLIENT_SECRET' securityContext: allowPrivilegeEscalation: false privileged: false + serviceAccount: + annotations: + eks.amazonaws.com/role-arn: 'arn:aws:iam::251502586493:role/services-bff' + create: true + name: 'services-bff' services-documents: enabled: true env: diff --git a/charts/islandis/values.staging.yaml b/charts/islandis/values.staging.yaml index 18ff6188973f..4c1f784e1c0d 100644 --- a/charts/islandis/values.staging.yaml +++ b/charts/islandis/values.staging.yaml @@ -1534,7 +1534,6 @@ namespaces: - 'license-api' - 'services-sessions' - 'services-university-gateway' - - 'services-bff' portals-admin: enabled: true env: @@ -2024,7 +2023,7 @@ service-portal-api: eks.amazonaws.com/role-arn: 'arn:aws:iam::261174024191:role/service-portal-api' create: true name: 'service-portal-api' -services-bff-admin-portal: +services-bff-portals-admin: enabled: true env: BFF_ALLOWED_EXTERNAL_API_URLS: '["https://api.staging01.devland.is"]' @@ -2078,9 +2077,11 @@ services-bff-admin-portal: - host: 'beta.staging01.devland.is' paths: - '/stjornbord/bff' - namespace: 'services-bff' + namespace: 'portals-admin' podDisruptionBudget: maxUnavailable: 1 + podSecurityContext: + fsGroup: 65534 pvcs: [] replicaCount: default: 2 @@ -2094,12 +2095,17 @@ services-bff-admin-portal: cpu: '100m' memory: '256Mi' secrets: - BFF_TOKEN_SECRET_BASE64: '/k8s/services-bff/admin-portal/BFF_TOKEN_SECRET_BASE64' + BFF_TOKEN_SECRET_BASE64: '/k8s/services-bff/portals-admin/BFF_TOKEN_SECRET_BASE64' CONFIGCAT_SDK_KEY: '/k8s/configcat/CONFIGCAT_SDK_KEY' - IDENTITY_SERVER_CLIENT_SECRET: '/k8s/services-bff/admin-portal/IDENTITY_SERVER_CLIENT_SECRET' + IDENTITY_SERVER_CLIENT_SECRET: '/k8s/services-bff/portals-admin/IDENTITY_SERVER_CLIENT_SECRET' securityContext: allowPrivilegeEscalation: false privileged: false + serviceAccount: + annotations: + eks.amazonaws.com/role-arn: 'arn:aws:iam::261174024191:role/services-bff' + create: true + name: 'services-bff' services-documents: enabled: true env: From 4b2170ca8baabf93972c3377433c94b34aa0a619 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B3n=20Levy?= Date: Wed, 9 Oct 2024 21:38:03 +0000 Subject: [PATCH 108/248] fix: prettier issues --- .prettierrc | 2 +- infra/src/dsl/portal-env.spec.ts | 125 ++++++++++++++----------------- 2 files changed, 59 insertions(+), 68 deletions(-) diff --git a/.prettierrc b/.prettierrc index 90779c7c514d..0dc4fc2525df 100644 --- a/.prettierrc +++ b/.prettierrc @@ -3,7 +3,7 @@ "semi": false, "trailingComma": "all", "arrowParens": "always", - "plugins": ["./scripts/prettier-plugins/sort-projects"], + "plugins": ["./scripts/prettier-plugins/sort-projects.js"], "endOfLine": "lf", "overrides": [ { diff --git a/infra/src/dsl/portal-env.spec.ts b/infra/src/dsl/portal-env.spec.ts index aedb59ea136c..70fdc8993f4f 100644 --- a/infra/src/dsl/portal-env.spec.ts +++ b/infra/src/dsl/portal-env.spec.ts @@ -7,21 +7,17 @@ import { generateOutputOne } from './processing/rendering-pipeline' import { createPortalEnv } from '../../../apps/services/bff/infra/utils/createPortalEnv' import { json } from './dsl' -import { - adminPortalScopes, - servicePortalScopes, -} from '../../../libs/auth/scopes/src/index' +import { adminPortalScopes } from '../../../libs/auth/scopes/src/index' import { FIVE_SECONDS_IN_MS } from '../../../apps/services/bff/src/app/constants/time' const ONE_HOUR_IN_MS = 60 * 60 * 1000 const ONE_WEEK_IN_MS = ONE_HOUR_IN_MS * 24 * 7 -const bffName = "services-bff" -const bffType = "stjornbord" -const clientName = "portals-admin" +const bffName = 'services-bff' +const bffType = 'stjornbord' +const clientName = 'portals-admin' const serviceName = `${bffName}-${clientName}` - const Staging: EnvironmentConfig = { auroraHost: 'a', redisHost: 'b', @@ -37,63 +33,60 @@ const Staging: EnvironmentConfig = { } describe('BFF PortalEnv serialization', () => { - const sut = - service(serviceName) - .namespace(clientName) - .image(bffName) - .redis() - .serviceAccount(bffName) - .env(createPortalEnv(bffType)) - .secrets({ - BFF_TOKEN_SECRET_BASE64: - `/k8s/${bffName}/${clientName}/BFF_TOKEN_SECRET_BASE64`, - IDENTITY_SERVER_CLIENT_SECRET: - `/k8s/${bffName}/${clientName}/IDENTITY_SERVER_CLIENT_SECRET`, - }) - .command('node') - .args('main.js') - .readiness('/health/check') - .liveness('/liveness') - .replicaCount({ - default: 2, - min: 2, - max: 3, - }) - .resources({ - limits: { - cpu: '400m', - memory: '512Mi', - }, - requests: { - cpu: '100m', - memory: '256Mi', + const sut = service(serviceName) + .namespace(clientName) + .image(bffName) + .redis() + .serviceAccount(bffName) + .env(createPortalEnv(bffType)) + .secrets({ + BFF_TOKEN_SECRET_BASE64: `/k8s/${bffName}/${clientName}/BFF_TOKEN_SECRET_BASE64`, + IDENTITY_SERVER_CLIENT_SECRET: `/k8s/${bffName}/${clientName}/IDENTITY_SERVER_CLIENT_SECRET`, + }) + .command('node') + .args('main.js') + .readiness('/health/check') + .liveness('/liveness') + .replicaCount({ + default: 2, + min: 2, + max: 3, + }) + .resources({ + limits: { + cpu: '400m', + memory: '512Mi', + }, + requests: { + cpu: '100m', + memory: '256Mi', + }, + }) + .ingress({ + primary: { + host: { + dev: ['beta'], + staging: ['beta'], + prod: ['', 'www.island.is'], }, - }) - .ingress({ - primary: { - host: { - dev: ['beta'], - staging: ['beta'], - prod: ['', 'www.island.is'], + extraAnnotations: { + dev: { + 'nginx.ingress.kubernetes.io/proxy-buffering': 'on', + 'nginx.ingress.kubernetes.io/proxy-buffer-size': '8k', + }, + staging: { + 'nginx.ingress.kubernetes.io/enable-global-auth': 'false', + 'nginx.ingress.kubernetes.io/proxy-buffering': 'on', + 'nginx.ingress.kubernetes.io/proxy-buffer-size': '8k', }, - extraAnnotations: { - dev: { - 'nginx.ingress.kubernetes.io/proxy-buffering': 'on', - 'nginx.ingress.kubernetes.io/proxy-buffer-size': '8k', - }, - staging: { - 'nginx.ingress.kubernetes.io/enable-global-auth': 'false', - 'nginx.ingress.kubernetes.io/proxy-buffering': 'on', - 'nginx.ingress.kubernetes.io/proxy-buffer-size': '8k', - }, - prod: { - 'nginx.ingress.kubernetes.io/proxy-buffering': 'on', - 'nginx.ingress.kubernetes.io/proxy-buffer-size': '8k', - }, + prod: { + 'nginx.ingress.kubernetes.io/proxy-buffering': 'on', + 'nginx.ingress.kubernetes.io/proxy-buffer-size': '8k', }, - paths: [`/${bffType}/bff`], }, - }) + paths: [`/${bffType}/bff`], + }, + }) let result: SerializeSuccess beforeEach(async () => { result = (await generateOutputOne({ @@ -150,7 +143,7 @@ describe('BFF PortalEnv serialization', () => { IDENTITY_SERVER_CLIENT_ID: `@admin.island.is/bff-${bffType}`, IDENTITY_SERVER_ISSUER_URL: 'https://identity-server.dev01.devland.is', // BFF - BFF_NAME: "stjornbord", + BFF_NAME: 'stjornbord', BFF_CLIENT_KEY_PATH: `/${bffType}`, BFF_PAR_SUPPORT_ENABLED: 'false', BFF_ALLOWED_REDIRECT_URIS: json(['https://beta.dev01.devland.is']), @@ -166,16 +159,14 @@ describe('BFF PortalEnv serialization', () => { NODE_OPTIONS: '--max-old-space-size=460 -r dd-trace/init', SERVERSIDE_FEATURES_ON: '', LOG_LEVEL: 'info', - REDIS_URL_NODE_01: "b" + REDIS_URL_NODE_01: 'b', }) }) it('secrets', () => { expect(result.serviceDef[0].secrets).toEqual({ - BFF_TOKEN_SECRET_BASE64: - `/k8s/${bffName}/${clientName}/BFF_TOKEN_SECRET_BASE64`, - IDENTITY_SERVER_CLIENT_SECRET: - `/k8s/${bffName}/${clientName}/IDENTITY_SERVER_CLIENT_SECRET`, + BFF_TOKEN_SECRET_BASE64: `/k8s/${bffName}/${clientName}/BFF_TOKEN_SECRET_BASE64`, + IDENTITY_SERVER_CLIENT_SECRET: `/k8s/${bffName}/${clientName}/IDENTITY_SERVER_CLIENT_SECRET`, CONFIGCAT_SDK_KEY: '/k8s/configcat/CONFIGCAT_SDK_KEY', }) }) From fbb3d62cb1c9a5a56a7f9fa0e92b05d02837000b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B3n=20Levy?= Date: Thu, 10 Oct 2024 15:14:07 +0000 Subject: [PATCH 109/248] chore: prettify --- apps/portals/admin/project.json | 24 +++++-------------- .../bff/src/app/modules/auth/auth.service.ts | 20 +++++++++------- infra/src/feature-env.ts | 9 +++---- 3 files changed, 22 insertions(+), 31 deletions(-) diff --git a/apps/portals/admin/project.json b/apps/portals/admin/project.json index b28683236526..c6dee8b5d7df 100644 --- a/apps/portals/admin/project.json +++ b/apps/portals/admin/project.json @@ -3,15 +3,11 @@ "$schema": "../../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "apps/portals/admin/src", "projectType": "application", - "tags": [ - "scope:portals-admin" - ], + "tags": ["scope:portals-admin"], "targets": { "build": { "executor": "@nx/webpack:webpack", - "outputs": [ - "{options.outputPath}" - ], + "outputs": ["{options.outputPath}"], "defaultConfiguration": "production", "options": { "compiler": "babel", @@ -26,9 +22,7 @@ "apps/portals/admin/src/mockServiceWorker.js", "apps/portals/admin/src/assets" ], - "styles": [ - "apps/portals/admin/src/styles.css" - ], + "styles": ["apps/portals/admin/src/styles.css"], "scripts": [], "webpackConfig": "apps/portals/admin/webpack.config.js" }, @@ -55,9 +49,7 @@ "node scripts/dockerfile-assets/bash/extract-environment.js apps/portals/admin/src" ] }, - "outputs": [ - "{workspaceRoot}/apps/portals/admin/src/index.html" - ] + "outputs": ["{workspaceRoot}/apps/portals/admin/src/index.html"] }, "serve": { "executor": "@nx/webpack:dev-server", @@ -83,9 +75,7 @@ }, "test": { "executor": "@nx/jest:jest", - "outputs": [ - "{workspaceRoot}/coverage/apps/portals/admin" - ], + "outputs": ["{workspaceRoot}/coverage/apps/portals/admin"], "options": { "jestConfig": "apps/portals/admin/jest.config.ts" } @@ -99,9 +89,7 @@ "dev-init": { "executor": "nx:run-commands", "options": { - "commands": [ - "yarn get-secrets portals-admin" - ], + "commands": ["yarn get-secrets portals-admin"], "parallel": false } }, diff --git a/apps/services/bff/src/app/modules/auth/auth.service.ts b/apps/services/bff/src/app/modules/auth/auth.service.ts index 8030a59cdb3f..e9d777b9f651 100644 --- a/apps/services/bff/src/app/modules/auth/auth.service.ts +++ b/apps/services/bff/src/app/modules/auth/auth.service.ts @@ -395,11 +395,12 @@ export class AuthService { query.sid, ) - const cachedTokenResponse = await this.cacheService.get( - currentLoginCacheKey, - // Do not throw an error if the key is not found - false, - ) + const cachedTokenResponse = + await this.cacheService.get( + currentLoginCacheKey, + // Do not throw an error if the key is not found + false, + ) if (!cachedTokenResponse) { this.logger.error( @@ -505,10 +506,11 @@ export class AuthService { 'current', payload.sid, ) - const cachedTokenResponse = await this.cacheService.get( - cacheKey, - false, // Do not throw an error if the key is not found - ) + const cachedTokenResponse = + await this.cacheService.get( + cacheKey, + false, // Do not throw an error if the key is not found + ) // Revoke refresh token and delete cache entry if (cachedTokenResponse) { diff --git a/infra/src/feature-env.ts b/infra/src/feature-env.ts index 70e14471df10..0111c5dba082 100644 --- a/infra/src/feature-env.ts +++ b/infra/src/feature-env.ts @@ -99,9 +99,10 @@ const buildIngressComment = (data: HelmService[]): string => .join('\n') const buildComment = (data: Services): string => { - return `Feature deployment of your services will begin shortly. Your feature will be accessible here:\n${buildIngressComment(Object.values(data)) ?? + return `Feature deployment of your services will begin shortly. Your feature will be accessible here:\n${ + buildIngressComment(Object.values(data)) ?? 'Feature deployment of your services will begin shortly. No web endpoints defined (no ingresses were defined)' - }` + }` } const deployedComment = ( data: ServiceBuilder[], @@ -116,7 +117,7 @@ yargs(process.argv.slice(2)) .command( 'values', 'get helm values file', - () => { }, + () => {}, async (argv: Arguments) => { const { habitat, affectedServices, env } = parseArguments(argv) const { included: featureYaml } = await getFeatureAffectedServices( @@ -140,7 +141,7 @@ yargs(process.argv.slice(2)) .command( 'ingress-comment', 'get helm values file', - () => { }, + () => {}, async (argv: Arguments) => { const { habitat, affectedServices, env } = parseArguments(argv) const { included: featureYaml, excluded } = From a59c6aa82f0a1193e41fa4dd477e1397fdebc226 Mon Sep 17 00:00:00 2001 From: andes-it Date: Thu, 10 Oct 2024 15:41:15 +0000 Subject: [PATCH 110/248] chore: nx format:write update dirty files --- apps/services/bff/infra/admin-portal.infra.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/apps/services/bff/infra/admin-portal.infra.ts b/apps/services/bff/infra/admin-portal.infra.ts index 83e80af88861..91c7140495ca 100644 --- a/apps/services/bff/infra/admin-portal.infra.ts +++ b/apps/services/bff/infra/admin-portal.infra.ts @@ -1,7 +1,7 @@ import { ServiceBuilder, service } from '../../../../infra/src/dsl/dsl' import { createPortalEnv } from './utils/createPortalEnv' -const bffName = "services-bff" -const clientName = "portals-admin" +const bffName = 'services-bff' +const clientName = 'portals-admin' const serviceName = `${bffName}-${clientName}` export const serviceSetup = (): ServiceBuilder => @@ -14,10 +14,8 @@ export const serviceSetup = (): ServiceBuilder => .secrets({ // The secret should be a valid 32-byte base64 key. // Generate key example: `openssl rand -base64 32` - BFF_TOKEN_SECRET_BASE64: - `/k8s/${bffName}/${clientName}/BFF_TOKEN_SECRET_BASE64`, - IDENTITY_SERVER_CLIENT_SECRET: - `/k8s/${bffName}/${clientName}/IDENTITY_SERVER_CLIENT_SECRET`, + BFF_TOKEN_SECRET_BASE64: `/k8s/${bffName}/${clientName}/BFF_TOKEN_SECRET_BASE64`, + IDENTITY_SERVER_CLIENT_SECRET: `/k8s/${bffName}/${clientName}/IDENTITY_SERVER_CLIENT_SECRET`, }) .readiness('/health/check') .liveness('/liveness') From 202c4e6e25f8df047c29c60acae00e578147840d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B3n=20Levy?= Date: Thu, 10 Oct 2024 18:44:05 +0000 Subject: [PATCH 111/248] ci: add services-bff to helm chart --- infra/helm/Chart.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/infra/helm/Chart.yaml b/infra/helm/Chart.yaml index 047a1ad4d13d..94d71d036217 100644 --- a/infra/helm/Chart.yaml +++ b/infra/helm/Chart.yaml @@ -158,5 +158,9 @@ dependencies: repository: file://libs/api-template version: 0.0.1 alias: services-university-gateway-worker + - name: api-template + repository: file://libs/api-template + version: 0.0.1 + alias: services-bff type: application version: 0.0.9 From 49a565ba584594768256b054ce82903758eca99a Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Fri, 11 Oct 2024 11:13:03 +0000 Subject: [PATCH 112/248] Fix env vars for feature deploy --- apps/services/bff/infra/utils/createPortalEnv.ts | 10 +++++----- apps/services/bff/src/app/bff.config.ts | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/services/bff/infra/utils/createPortalEnv.ts b/apps/services/bff/infra/utils/createPortalEnv.ts index 5b450c88ad8a..51f7bcd03098 100644 --- a/apps/services/bff/infra/utils/createPortalEnv.ts +++ b/apps/services/bff/infra/utils/createPortalEnv.ts @@ -46,31 +46,31 @@ export const createPortalEnv = (key: PortalKeys) => { BFF_PAR_SUPPORT_ENABLED: 'false', BFF_ALLOWED_REDIRECT_URIS: { local: json(['http://localhost:4200/stjornbord']), - dev: json(['https://beta.dev01.devland.is']), + dev: json(['https://featbff-beta.dev01.devland.is']), staging: json(['https://beta.staging01.devland.is']), prod: json(['https://island.is']), }, BFF_CLIENT_BASE_URL: { local: 'http://localhost:4200', - dev: 'https://beta.dev01.devland.is', + dev: 'https://featbff-beta.dev01.devland.is', staging: 'https://beta.staging01.devland.is', prod: 'https://island.is', }, BFF_LOGOUT_REDIRECT_URI: { local: 'http://localhost:4200/stjornbord', - dev: 'https://beta.dev01.devland.is', + dev: 'https://featbff-beta.dev01.devland.is', staging: 'https://beta.staging01.devland.is', prod: 'https://island.is', }, BFF_CALLBACKS_BASE_PATH: { local: `http://localhost:3010/${key}/bff/callbacks`, - dev: `https://beta.dev01.devland.is/${key}/bff/callbacks`, + dev: `https://featbff-beta.dev01.devland.is/${key}/bff/callbacks`, staging: `https://beta.staging01.devland.is/${key}/bff/callbacks`, prod: `https://island.is/${key}/bff/callbacks`, }, BFF_PROXY_API_ENDPOINT: { local: 'http://localhost:4444/api/graphql', - dev: 'https://beta.dev01.devland.is/api/graphql', + dev: 'https://featbff-beta.dev01.devland.is/api/graphql', staging: 'https://beta.staging01.devland.is/api/graphql', prod: 'https://island.is/api/graphql', }, diff --git a/apps/services/bff/src/app/bff.config.ts b/apps/services/bff/src/app/bff.config.ts index 116ab115bbe4..b16123093ed6 100644 --- a/apps/services/bff/src/app/bff.config.ts +++ b/apps/services/bff/src/app/bff.config.ts @@ -78,7 +78,7 @@ export const BffConfig = defineConfig({ // Redis nodes are only required in production // In development, we can use a local Redis server or // rely on the default in-memory cache provided by CacheModule - nodes: env.requiredJSON('BFF_REDIS_URL_NODES', []), + nodes: env.requiredJSON('REDIS_URL_NODE_01', []), ssl: env.optionalJSON('BFF_REDIS_SSL', false) ?? true, }, ids: { From 083cc38dfd970cb116b794a15042d3d6e528d164 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Mon, 14 Oct 2024 09:24:23 +0000 Subject: [PATCH 113/248] Fix health check to be excluded from prefix --- apps/services/bff/src/main.ts | 6 +++++- libs/infra-nest-server/src/lib/bootstrap.ts | 2 +- libs/infra-nest-server/src/lib/types.ts | 5 +++++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/services/bff/src/main.ts b/apps/services/bff/src/main.ts index 1f615d01932a..8c9e82a3e5a1 100644 --- a/apps/services/bff/src/main.ts +++ b/apps/services/bff/src/main.ts @@ -2,13 +2,17 @@ import { bootstrap } from '@island.is/infra-nest-server' import { AppModule } from './app/app.module' import { environment } from './environment' +import { RequestMethod } from '@nestjs/common' bootstrap({ appModule: AppModule, name: 'bff', port: environment.port, globalPrefix: `${environment.keyPath}/bff`, + globalPrefixOptions: { + exclude: [{ path: 'health/check', method: RequestMethod.GET }], + }, healthCheck: { - timeout: 1000, + database: true, }, }) diff --git a/libs/infra-nest-server/src/lib/bootstrap.ts b/libs/infra-nest-server/src/lib/bootstrap.ts index 28495fef23ae..da33e7b0dab9 100644 --- a/libs/infra-nest-server/src/lib/bootstrap.ts +++ b/libs/infra-nest-server/src/lib/bootstrap.ts @@ -71,7 +71,7 @@ export const createApp = async ({ ) if (options.globalPrefix) { - app.setGlobalPrefix(options.globalPrefix) + app.setGlobalPrefix(options.globalPrefix, options.globalPrefixOptions) } if (options.collectMetrics !== false) { diff --git a/libs/infra-nest-server/src/lib/types.ts b/libs/infra-nest-server/src/lib/types.ts index 8426a6d00cf4..19cb5941819d 100644 --- a/libs/infra-nest-server/src/lib/types.ts +++ b/libs/infra-nest-server/src/lib/types.ts @@ -4,6 +4,7 @@ import { Server } from 'http' import { CorsOptions } from '@nestjs/common/interfaces/external/cors-options.interface' import { HealthCheckOptions } from './infra/health/types' +import { GlobalPrefixOptions } from '@nestjs/common/interfaces' export type RunServerOptions = { /** @@ -41,6 +42,10 @@ export type RunServerOptions = { * Global url prefix for the app */ globalPrefix?: string + /** + * Global prefix options to be used with the global prefix + */ + globalPrefixOptions?: GlobalPrefixOptions stripNonClassValidatorInputs?: boolean From 0cae4b9b6d7ac9255e313270663e5acd412c8c8f Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Mon, 14 Oct 2024 09:42:36 +0000 Subject: [PATCH 114/248] update global prefix logic --- libs/infra-nest-server/src/lib/bootstrap.ts | 10 ++++++++-- libs/infra-nest-server/src/lib/types.ts | 11 ++++++----- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/libs/infra-nest-server/src/lib/bootstrap.ts b/libs/infra-nest-server/src/lib/bootstrap.ts index da33e7b0dab9..147bb8e97ad4 100644 --- a/libs/infra-nest-server/src/lib/bootstrap.ts +++ b/libs/infra-nest-server/src/lib/bootstrap.ts @@ -70,8 +70,14 @@ export const createApp = async ({ }), ) - if (options.globalPrefix) { - app.setGlobalPrefix(options.globalPrefix, options.globalPrefixOptions) + const globalPrefix = options.globalPrefix + + if (globalPrefix) { + if (typeof globalPrefix === 'string') { + app.setGlobalPrefix(globalPrefix) + } else { + app.setGlobalPrefix(globalPrefix.prefix, globalPrefix.options) + } } if (options.collectMetrics !== false) { diff --git a/libs/infra-nest-server/src/lib/types.ts b/libs/infra-nest-server/src/lib/types.ts index 19cb5941819d..32704e90e0c9 100644 --- a/libs/infra-nest-server/src/lib/types.ts +++ b/libs/infra-nest-server/src/lib/types.ts @@ -41,11 +41,12 @@ export type RunServerOptions = { /** * Global url prefix for the app */ - globalPrefix?: string - /** - * Global prefix options to be used with the global prefix - */ - globalPrefixOptions?: GlobalPrefixOptions + globalPrefix?: + | string + | { + prefix: string + options?: GlobalPrefixOptions + } stripNonClassValidatorInputs?: boolean From 512589713230d9d8ec15cad2c4c2179b1600f799 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Mon, 14 Oct 2024 09:48:42 +0000 Subject: [PATCH 115/248] update bff services options --- apps/services/bff/src/main.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/services/bff/src/main.ts b/apps/services/bff/src/main.ts index 8c9e82a3e5a1..f975cf7b69a4 100644 --- a/apps/services/bff/src/main.ts +++ b/apps/services/bff/src/main.ts @@ -8,9 +8,11 @@ bootstrap({ appModule: AppModule, name: 'bff', port: environment.port, - globalPrefix: `${environment.keyPath}/bff`, - globalPrefixOptions: { - exclude: [{ path: 'health/check', method: RequestMethod.GET }], + globalPrefix: { + prefix: `${environment.keyPath}/bff`, + options: { + exclude: [{ path: 'health/check', method: RequestMethod.GET }], + }, }, healthCheck: { database: true, From 39747f9370cc1490dd6ba7f1abb75deb9087cc26 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Mon, 14 Oct 2024 09:54:53 +0000 Subject: [PATCH 116/248] Remove bff redis name env var --- apps/services/bff/src/app/bff.config.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/services/bff/src/app/bff.config.ts b/apps/services/bff/src/app/bff.config.ts index b16123093ed6..8a0745127a57 100644 --- a/apps/services/bff/src/app/bff.config.ts +++ b/apps/services/bff/src/app/bff.config.ts @@ -12,7 +12,6 @@ export const idsSchema = z.strictObject({ const BffConfigSchema = z.object({ redis: z.object({ - name: z.string(), nodes: z.array(z.string()), ssl: z.boolean(), }), @@ -74,7 +73,6 @@ export const BffConfig = defineConfig({ */ graphqlApiEndpoint: env.required('BFF_PROXY_API_ENDPOINT'), redis: { - name: env.required('BFF_REDIS_NAME', 'unnamed-bff'), // Redis nodes are only required in production // In development, we can use a local Redis server or // rely on the default in-memory cache provided by CacheModule From cc66d65ca4a9c0e7ad7120107d7415c77a697ae4 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Mon, 14 Oct 2024 10:02:54 +0000 Subject: [PATCH 117/248] Update bff config again --- apps/services/bff/src/app/bff.config.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/services/bff/src/app/bff.config.ts b/apps/services/bff/src/app/bff.config.ts index 8a0745127a57..a4fd95d97d40 100644 --- a/apps/services/bff/src/app/bff.config.ts +++ b/apps/services/bff/src/app/bff.config.ts @@ -11,7 +11,11 @@ export const idsSchema = z.strictObject({ }) const BffConfigSchema = z.object({ + /** + * Redis configuration + */ redis: z.object({ + name: z.string(), nodes: z.array(z.string()), ssl: z.boolean(), }), @@ -63,6 +67,7 @@ export const BffConfig = defineConfig({ const callbacksBaseRedirectPath = removeTrailingSlash( env.required('BFF_CALLBACKS_BASE_PATH'), ) + const bffName = env.required('BFF_NAME') return { parSupportEnabled: env.optionalJSON('BFF_PAR_SUPPORT_ENABLED') ?? false, @@ -73,6 +78,7 @@ export const BffConfig = defineConfig({ */ graphqlApiEndpoint: env.required('BFF_PROXY_API_ENDPOINT'), redis: { + name: `bff-${bffName}`, // Redis nodes are only required in production // In development, we can use a local Redis server or // rely on the default in-memory cache provided by CacheModule From b12be6cd8173bc1a4b831957f86d830b144e0c70 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Mon, 14 Oct 2024 10:44:50 +0000 Subject: [PATCH 118/248] Update portal env spec for feature branch --- .../bff/src/app/modules/auth/auth.service.ts | 2 -- infra/src/dsl/portal-env.spec.ts | 13 ++++++++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/apps/services/bff/src/app/modules/auth/auth.service.ts b/apps/services/bff/src/app/modules/auth/auth.service.ts index e9d777b9f651..2e127065c709 100644 --- a/apps/services/bff/src/app/modules/auth/auth.service.ts +++ b/apps/services/bff/src/app/modules/auth/auth.service.ts @@ -486,8 +486,6 @@ export class AuthService { } async callbackLogout(body: CallbackLogoutDto) { - // TODO: Remove this log statement when done testing on feature deploy - this.logger.warn('callbackBackchannelLogout', JSON.stringify(body, null, 2)) const logoutToken = body.logout_token if (!logoutToken) { diff --git a/infra/src/dsl/portal-env.spec.ts b/infra/src/dsl/portal-env.spec.ts index 70fdc8993f4f..eeea9c81f079 100644 --- a/infra/src/dsl/portal-env.spec.ts +++ b/infra/src/dsl/portal-env.spec.ts @@ -146,11 +146,14 @@ describe('BFF PortalEnv serialization', () => { BFF_NAME: 'stjornbord', BFF_CLIENT_KEY_PATH: `/${bffType}`, BFF_PAR_SUPPORT_ENABLED: 'false', - BFF_ALLOWED_REDIRECT_URIS: json(['https://beta.dev01.devland.is']), - BFF_CLIENT_BASE_URL: 'https://beta.dev01.devland.is', - BFF_LOGOUT_REDIRECT_URI: 'https://beta.dev01.devland.is', - BFF_CALLBACKS_BASE_PATH: `https://beta.dev01.devland.is/${bffType}/bff/callbacks`, - BFF_PROXY_API_ENDPOINT: 'https://beta.dev01.devland.is/api/graphql', + BFF_ALLOWED_REDIRECT_URIS: json([ + 'https://featbff-beta.dev01.devland.is', + ]), + BFF_CLIENT_BASE_URL: 'https://featbff-beta.dev01.devland.is', + BFF_LOGOUT_REDIRECT_URI: 'https://featbff-beta.dev01.devland.is', + BFF_CALLBACKS_BASE_PATH: `https://featbff-beta.dev01.devland.is/${bffType}/bff/callbacks`, + BFF_PROXY_API_ENDPOINT: + 'https://featbff-beta.dev01.devland.is/api/graphql', BFF_ALLOWED_EXTERNAL_API_URLS: json(['https://api.dev01.devland.is']), BFF_CACHE_USER_PROFILE_TTL_MS: ( ONE_HOUR_IN_MS - FIVE_SECONDS_IN_MS From 1194115f1d576372742b1863530c9327d5b8cc0e Mon Sep 17 00:00:00 2001 From: andes-it Date: Mon, 14 Oct 2024 10:45:50 +0000 Subject: [PATCH 119/248] chore: charts update dirty files --- charts/islandis/values.dev.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/charts/islandis/values.dev.yaml b/charts/islandis/values.dev.yaml index 702090bc98c4..191d23b82886 100644 --- a/charts/islandis/values.dev.yaml +++ b/charts/islandis/values.dev.yaml @@ -2284,16 +2284,16 @@ services-bff-portals-admin: enabled: true env: BFF_ALLOWED_EXTERNAL_API_URLS: '["https://api.dev01.devland.is"]' - BFF_ALLOWED_REDIRECT_URIS: '["https://beta.dev01.devland.is"]' + BFF_ALLOWED_REDIRECT_URIS: '["https://featbff-beta.dev01.devland.is"]' BFF_CACHE_USER_PROFILE_TTL_MS: '3595000' - BFF_CALLBACKS_BASE_PATH: 'https://beta.dev01.devland.is/stjornbord/bff/callbacks' - BFF_CLIENT_BASE_URL: 'https://beta.dev01.devland.is' + BFF_CALLBACKS_BASE_PATH: 'https://featbff-beta.dev01.devland.is/stjornbord/bff/callbacks' + BFF_CLIENT_BASE_URL: 'https://featbff-beta.dev01.devland.is' BFF_CLIENT_KEY_PATH: '/stjornbord' BFF_LOGIN_ATTEMPT_TTL_MS: '604800000' - BFF_LOGOUT_REDIRECT_URI: 'https://beta.dev01.devland.is' + BFF_LOGOUT_REDIRECT_URI: 'https://featbff-beta.dev01.devland.is' BFF_NAME: 'stjornbord' BFF_PAR_SUPPORT_ENABLED: 'false' - BFF_PROXY_API_ENDPOINT: 'https://beta.dev01.devland.is/api/graphql' + BFF_PROXY_API_ENDPOINT: 'https://featbff-beta.dev01.devland.is/api/graphql' IDENTITY_SERVER_CLIENT_ID: '@admin.island.is/bff-stjornbord' IDENTITY_SERVER_CLIENT_SCOPES: '["@admin.island.is/delegations","@admin.island.is/ads","@admin.island.is/regulations","@admin.island.is/regulations:manage","@admin.island.is/icelandic-names-registry","@admin.island.is/application-system:admin","@admin.island.is/application-system:institution","@admin.island.is/document-provider","@admin.island.is/auth","@admin.island.is/auth:admin","@admin.island.is/petitions","@admin.island.is/service-desk","@admin.island.is/ads:explicit","@admin.island.is/signature-collection:manage","@admin.island.is/signature-collection:process","@admin.island.is/form-system","@admin.island.is/form-system:admin","@admin.island.is/delegation-system","@admin.island.is/delegation-system:admin"]' IDENTITY_SERVER_ISSUER_URL: 'https://identity-server.dev01.devland.is' From 608deb9e7bf26a154987be50e972c73160d201e9 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Mon, 14 Oct 2024 11:47:07 +0000 Subject: [PATCH 120/248] Update validation error log --- apps/services/bff/src/app/modules/auth/auth.service.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/services/bff/src/app/modules/auth/auth.service.ts b/apps/services/bff/src/app/modules/auth/auth.service.ts index 2e127065c709..bca28fc03f7a 100644 --- a/apps/services/bff/src/app/modules/auth/auth.service.ts +++ b/apps/services/bff/src/app/modules/auth/auth.service.ts @@ -167,7 +167,11 @@ export class AuthService { targetLinkUri && !validateUri(targetLinkUri, this.config.allowedRedirectUris) ) { - this.logger.error('Invalid target_link_uri provided:', targetLinkUri) + this.logger.error( + `Invalid target_link_uri provided: ${targetLinkUri}. Allowed redirect uris: "${this.config.allowedRedirectUris.join( + ', ', + )}"`, + ) return this.redirectWithError(res, { code: 400, From 6f3d84e9429161204bdb67e72b64adc47bc2ea0a Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Tue, 15 Oct 2024 09:42:50 +0000 Subject: [PATCH 121/248] Remove database healthcheck --- apps/services/bff/src/main.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/services/bff/src/main.ts b/apps/services/bff/src/main.ts index f975cf7b69a4..f9259f554864 100644 --- a/apps/services/bff/src/main.ts +++ b/apps/services/bff/src/main.ts @@ -14,7 +14,5 @@ bootstrap({ exclude: [{ path: 'health/check', method: RequestMethod.GET }], }, }, - healthCheck: { - database: true, - }, + healthCheck: true, }) From 106b9bfaf7f7bd64dbc79bf2c4dfdb5c76877da8 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Tue, 15 Oct 2024 15:36:04 +0000 Subject: [PATCH 122/248] Revert globalprefix options and update liveness and readiness infra checks --- apps/services/bff/infra/admin-portal.infra.ts | 8 +++++--- apps/services/bff/src/main.ts | 8 +------- libs/infra-nest-server/src/lib/bootstrap.ts | 6 +----- libs/infra-nest-server/src/lib/types.ts | 8 +------- 4 files changed, 8 insertions(+), 22 deletions(-) diff --git a/apps/services/bff/infra/admin-portal.infra.ts b/apps/services/bff/infra/admin-portal.infra.ts index 91c7140495ca..c70b500b928f 100644 --- a/apps/services/bff/infra/admin-portal.infra.ts +++ b/apps/services/bff/infra/admin-portal.infra.ts @@ -1,8 +1,10 @@ import { ServiceBuilder, service } from '../../../../infra/src/dsl/dsl' import { createPortalEnv } from './utils/createPortalEnv' + const bffName = 'services-bff' const clientName = 'portals-admin' const serviceName = `${bffName}-${clientName}` +const key = 'stjornbord' export const serviceSetup = (): ServiceBuilder => service(serviceName) @@ -10,15 +12,15 @@ export const serviceSetup = (): ServiceBuilder => .image(bffName) .redis() .serviceAccount(bffName) - .env(createPortalEnv('stjornbord')) + .env(createPortalEnv(key)) .secrets({ // The secret should be a valid 32-byte base64 key. // Generate key example: `openssl rand -base64 32` BFF_TOKEN_SECRET_BASE64: `/k8s/${bffName}/${clientName}/BFF_TOKEN_SECRET_BASE64`, IDENTITY_SERVER_CLIENT_SECRET: `/k8s/${bffName}/${clientName}/IDENTITY_SERVER_CLIENT_SECRET`, }) - .readiness('/health/check') - .liveness('/liveness') + .readiness(`/${key}/bff/health/check`) + .liveness(`/${key}/bff/liveness`) .replicaCount({ default: 2, min: 2, diff --git a/apps/services/bff/src/main.ts b/apps/services/bff/src/main.ts index f9259f554864..2e68f2bcbc6e 100644 --- a/apps/services/bff/src/main.ts +++ b/apps/services/bff/src/main.ts @@ -2,17 +2,11 @@ import { bootstrap } from '@island.is/infra-nest-server' import { AppModule } from './app/app.module' import { environment } from './environment' -import { RequestMethod } from '@nestjs/common' bootstrap({ appModule: AppModule, name: 'bff', port: environment.port, - globalPrefix: { - prefix: `${environment.keyPath}/bff`, - options: { - exclude: [{ path: 'health/check', method: RequestMethod.GET }], - }, - }, + globalPrefix: `${environment.keyPath}/bff`, healthCheck: true, }) diff --git a/libs/infra-nest-server/src/lib/bootstrap.ts b/libs/infra-nest-server/src/lib/bootstrap.ts index 147bb8e97ad4..686fe5cfe07c 100644 --- a/libs/infra-nest-server/src/lib/bootstrap.ts +++ b/libs/infra-nest-server/src/lib/bootstrap.ts @@ -73,11 +73,7 @@ export const createApp = async ({ const globalPrefix = options.globalPrefix if (globalPrefix) { - if (typeof globalPrefix === 'string') { - app.setGlobalPrefix(globalPrefix) - } else { - app.setGlobalPrefix(globalPrefix.prefix, globalPrefix.options) - } + app.setGlobalPrefix(globalPrefix) } if (options.collectMetrics !== false) { diff --git a/libs/infra-nest-server/src/lib/types.ts b/libs/infra-nest-server/src/lib/types.ts index 32704e90e0c9..8426a6d00cf4 100644 --- a/libs/infra-nest-server/src/lib/types.ts +++ b/libs/infra-nest-server/src/lib/types.ts @@ -4,7 +4,6 @@ import { Server } from 'http' import { CorsOptions } from '@nestjs/common/interfaces/external/cors-options.interface' import { HealthCheckOptions } from './infra/health/types' -import { GlobalPrefixOptions } from '@nestjs/common/interfaces' export type RunServerOptions = { /** @@ -41,12 +40,7 @@ export type RunServerOptions = { /** * Global url prefix for the app */ - globalPrefix?: - | string - | { - prefix: string - options?: GlobalPrefixOptions - } + globalPrefix?: string stripNonClassValidatorInputs?: boolean From bba34147984d92c4f4a1ee4a19e79acebf05c8f0 Mon Sep 17 00:00:00 2001 From: andes-it Date: Tue, 15 Oct 2024 15:38:59 +0000 Subject: [PATCH 123/248] chore: charts update dirty files --- charts/islandis/values.dev.yaml | 4 ++-- charts/islandis/values.prod.yaml | 4 ++-- charts/islandis/values.staging.yaml | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/charts/islandis/values.dev.yaml b/charts/islandis/values.dev.yaml index 191d23b82886..7bac2d568f97 100644 --- a/charts/islandis/values.dev.yaml +++ b/charts/islandis/values.dev.yaml @@ -2306,11 +2306,11 @@ services-bff-portals-admin: healthCheck: liveness: initialDelaySeconds: 3 - path: '/liveness' + path: '/stjornbord/bff/liveness' timeoutSeconds: 3 readiness: initialDelaySeconds: 3 - path: '/health/check' + path: '/stjornbord/bff/health/check' timeoutSeconds: 3 hpa: scaling: diff --git a/charts/islandis/values.prod.yaml b/charts/islandis/values.prod.yaml index 72b7357a0ec4..0b3152c4be2c 100644 --- a/charts/islandis/values.prod.yaml +++ b/charts/islandis/values.prod.yaml @@ -2177,11 +2177,11 @@ services-bff-portals-admin: healthCheck: liveness: initialDelaySeconds: 3 - path: '/liveness' + path: '/stjornbord/bff/liveness' timeoutSeconds: 3 readiness: initialDelaySeconds: 3 - path: '/health/check' + path: '/stjornbord/bff/health/check' timeoutSeconds: 3 hpa: scaling: diff --git a/charts/islandis/values.staging.yaml b/charts/islandis/values.staging.yaml index 4c1f784e1c0d..54a3cd631488 100644 --- a/charts/islandis/values.staging.yaml +++ b/charts/islandis/values.staging.yaml @@ -2049,11 +2049,11 @@ services-bff-portals-admin: healthCheck: liveness: initialDelaySeconds: 3 - path: '/liveness' + path: '/stjornbord/bff/liveness' timeoutSeconds: 3 readiness: initialDelaySeconds: 3 - path: '/health/check' + path: '/stjornbord/bff/health/check' timeoutSeconds: 3 hpa: scaling: From 8c406b90c67c370f8da0949cc80b5b7608ceb9ea Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Thu, 17 Oct 2024 11:50:25 +0000 Subject: [PATCH 124/248] Add auth controller tests --- .../app/modules/auth/auth.controller.spec.ts | 622 ++++++++++++++++++ .../src/app/modules/auth/auth.controller.ts | 14 +- .../bff/src/app/modules/auth/auth.service.ts | 9 +- .../bff/src/app/modules/ids/ids.service.ts | 21 +- .../bff/src/app/modules/ids/ids.types.ts | 13 + apps/services/bff/test/setup.ts | 22 +- apps/services/bff/test/setupTestServer.ts | 8 + libs/testing/nest/src/lib/testServer.ts | 5 +- 8 files changed, 688 insertions(+), 26 deletions(-) create mode 100644 apps/services/bff/src/app/modules/auth/auth.controller.spec.ts create mode 100644 apps/services/bff/test/setupTestServer.ts diff --git a/apps/services/bff/src/app/modules/auth/auth.controller.spec.ts b/apps/services/bff/src/app/modules/auth/auth.controller.spec.ts new file mode 100644 index 000000000000..6df16c3b1d43 --- /dev/null +++ b/apps/services/bff/src/app/modules/auth/auth.controller.spec.ts @@ -0,0 +1,622 @@ +import { ConfigType } from '@island.is/nest/config' +import { CACHE_MANAGER } from '@nestjs/cache-manager' +import { HttpStatus, INestApplication } from '@nestjs/common' +import jwt from 'jsonwebtoken' +import request from 'supertest' +import { setupTestServer } from '../../../../test/setupTestServer' +import { BffConfig } from '../../bff.config' +import { IdsService } from '../ids/ids.service' +import { + GetLoginSearchParamsReturnValue, + ParResponse, + TokenResponse, +} from '../ids/ids.types' + +const SID_VALUE = 'fake_uuid' +const SESSION_COOKIE_NAME = 'sid' +const KID = 'test-kid' +const TEST_PRIVATE_KEY = ` +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC7uKPR7Gq2Cz3u +BDzUfTYirK6SaK9TnHD8KuOhCDp91j982NIivA/6SwfJhXAfqPdlbgxCsdk0R48E +ZwVuOCXbS4d4V39F+BpgEtuYelc56Ag0n3FuTpKgKiEQePkJSXzmgFD9W1ghXFNu +9cYmoPe7EfN7oFjZFCVn41ooztJZNpFyyJNnMR5m/IWKVPD4JGOjJDvfsyz17hP+ +dKsbxKLstzh9ziBoLzrlq/N5AGdaaYTLr2dbMS1wiuCdESQkIRJw8yh8Emu3rtf4 +X/p0VHbd/6MErxJE7OuPNnaJuc8D5Fc2vpQUTqoS6oY/THRVXdosbgAG19jwEGTY +31OYE2YbAgMBAAECggEADVZZHh7P41I/wRImmxEWw3aFMRxkXiqBy1cywavnfmEc +zqTko5iJnBNjoL/gNc4LK11nwpjtoTpdh21x/dRAbRwow/YxGHDZuUftt5/AypNZ +R3/cKJjgvKXYBo18+1nWZW9Qq2EnjR5vRE8MmCcDCUgFU09fsKcVR0dodJJi1vkb +cLFQExaU4kmcnvqxV2FazXuQaC8EbTtMcS6WHKNSCIYbd64hAcu906c7JgdC9Uvc +myaU0MLeb7WAlmN3Xpvyg6bCxwMLKjTdAr5p4J7/Tv/rimVPIOEsqI0NVP5Ajx/p ++w9kKanQ3L/7lt8+Q094U1iZ80zvBNh4XaAqUvhQQQKBgQDpDUMQHv29oilYiL73 ++A70K4X2LptcH5fOGa/EHE8AeM9vlJm0HAnsOT+iERFsl6peD3bcoiyV84z72Q7P +WNddA+rynB4gBu8ymUS/CpXwZQSi33xxT55rB7hLJK+22udVDXzCrNcSQPd4Gwn3 +lNqSUYf+8tv7Ub0Qm1R5lJYswQKBgQDONLEH3U/5aNV0/E0zcd0SVI9OtKu8qDxJ +J6v/P3bwbWvOmLRON4gFeAqK8awrYZd6Zupsdx15uqu/0Ikym/0C5bOzQa3E/9tT +vOZRoNch416J0AKY9wFemwxkDcheiOxSanP8PbNZPp0N86JehuXxy41guEu41qWZ +dDvrrpld2wKBgEqXZA+U28IGVRVxLy5Oxvp/s7DH2hHySrQ8pHUwWljcUgh0l31+ +O+7Po/5LWDhZkr3oVTLo9TxJZ6Z0IrlaxhOPXXOpZDr7/TNEuywqRzNaIdG/liTu +RtYa8nGanGL6TXB7kKL+jxfYk1xtyxLjIdITJmQDd0VJNCpMjQ0c8bQBAoGBAKn3 +/MRCxB0NMIWRQgFZpaPqV4XEnpqPAcI7FSb8JQng57APZu/iDhiT7fzBX+0SME4Q +bsKhHIauO8uMFMrGkTLGK+1iAd4UF7FaT26RaULhq5dlAf8b+uEEZJ5EThi+PC1i +2d/c6+xwE/zgCcJo5zj7U7mZr7DYHP/0Mz/9VyVpAoGBALvLo78LnijdHTf8mrWT +AlSQjhcJtHb1er4VKlPDF7p3hcVtrpgQaxnGL67DeLl4tt4dU4DoMMQKL/AyVDzI +gjQdXRT3AQDbB39P43an+11pXZOcyE1hCmW1VntOxY2DSD2GK45sh2eDQ6kuClbP +VIQ36zUj7NhPnYWM6aUHNLwA +-----END PRIVATE KEY----- +` + +const TEST_PUBLIC_KEY = ` +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu7ij0exqtgs97gQ81H02 +IqyukmivU5xw/CrjoQg6fdY/fNjSIrwP+ksHyYVwH6j3ZW4MQrHZNEePBGcFbjgl +20uHeFd/RfgaYBLbmHpXOegINJ9xbk6SoCohEHj5CUl85oBQ/VtYIVxTbvXGJqD3 +uxHze6BY2RQlZ+NaKM7SWTaRcsiTZzEeZvyFilTw+CRjoyQ737Ms9e4T/nSrG8Si +7Lc4fc4gaC865avzeQBnWmmEy69nWzEtcIrgnREkJCEScPMofBJrt67X+F/6dFR2 +3f+jBK8SROzrjzZ2ibnPA+RXNr6UFE6qEuqGP0x0VV3aLG4ABtfY8BBk2N9TmBNm +GwIDAQAB +-----END PUBLIC KEY----- +` + +jest.mock('uuid', () => ({ + v4: jest.fn().mockReturnValue('fake_uuid'), +})) + +const validSigningKey = { + kid: KID, + alg: 'RS256', + getPublicKey: jest.fn().mockReturnValue(TEST_PUBLIC_KEY), +} + +const noMatchKidSigningKey = { + kid: 'invalid-kid', + alg: 'RS256', + getPublicKey: jest.fn().mockReturnValue(TEST_PUBLIC_KEY), +} + +const invalidPublicKeySigningKey = { + kid: KID, + alg: 'RS256', + getPublicKey: jest.fn().mockReturnValue('invalid-public-key'), +} + +const mockedSigningKeys = jest.fn().mockReturnValue([validSigningKey]) + +jest.mock('jwks-rsa', () => { + return jest.fn().mockImplementation(() => ({ + getSigningKeys: mockedSigningKeys, + })) +}) + +const mockCacheStore = new Map() + +const mockCacheManagerValue = { + set: jest.fn((key, value) => mockCacheStore.set(key, value)), + get: jest.fn((key) => mockCacheStore.get(key)), + del: jest.fn((key) => mockCacheStore.delete(key)), +} + +const parResponse: ParResponse = { + request_uri: 'urn:ietf:params:oauth:request_uri:abc123', + expires_in: 600, +} + +const tokensResponse: TokenResponse = { + access_token: + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.mockSignature', + id_token: jwt.sign( + { + iss: 'https://example.com', + sub: '1234567890', + exp: Math.floor(Date.now() / 1000) + 3600, // 1 hour expiration + sid: SID_VALUE, + }, + 'mockSecret', + { algorithm: 'HS256' }, + ), + refresh_token: 'mockRefreshToken1234567890', + scope: 'openid profile email', + token_type: 'Bearer', + expires_in: 3600, +} + +const allowedTargetLinkUri = 'http://test-client.com/testclient' + +const mockIdsService = { + getPar: jest.fn().mockResolvedValue({ + type: 'success', + data: parResponse, + }), + getTokens: jest.fn().mockResolvedValue({ + type: 'success', + data: tokensResponse, + }), + revokeToken: jest.fn().mockResolvedValue({ + type: 'success', + }), + getLoginSearchParams: jest.fn().mockImplementation( + (args: { + sid: string + codeChallenge: string + loginHint?: string + prompt?: string + }): GetLoginSearchParamsReturnValue => ({ + client_id: '@test_client_id', + redirect_uri: 'http://localhost:3010/testclient/bff/callbacks/login', + response_type: 'code', + response_mode: 'query', + scope: 'test_scope offline_access openid profile', + state: SID_VALUE, + code_challenge: 'test_code_challenge', + code_challenge_method: 'test_code_challenge_method', + ...(args.loginHint && { login_hint: args.loginHint }), + ...(args.prompt && { prompt: args.prompt }), + }), + ), +} + +describe('AuthController', () => { + let app: INestApplication + let server: request.SuperTest + let mockConfig: ConfigType + let baseUrlWithKey: string + + beforeAll(async () => { + const app = await setupTestServer({ + override: (builder) => + builder + .overrideProvider(CACHE_MANAGER) + .useValue(mockCacheManagerValue) + .overrideProvider(IdsService) + .useValue(mockIdsService), + }) + + mockConfig = app.get>(BffConfig.KEY) + baseUrlWithKey = `${mockConfig.clientBaseUrl}${process.env.BFF_CLIENT_KEY_PATH}` + + server = request(app.getHttpServer()) + }) + + afterEach(() => { + mockCacheStore.clear() + jest.clearAllMocks() + }) + + afterAll(async () => { + if (app) { + await app.close() + } + }) + + describe('GET /login', () => { + it('should cache the login attempt', async () => { + // Arrange + const setSpy = jest.spyOn(mockCacheManagerValue, 'set') + + // Act + const res = await server + .get('/login') + .query({ target_link_uri: allowedTargetLinkUri }) + + // Assert + expect(res.status).toEqual(HttpStatus.FOUND) + expect(setSpy).toHaveBeenCalled() + + const [key, value] = setSpy.mock.calls[0] + + expect(key).toEqual(`attempt_${SID_VALUE}`) + expect(value).toMatchObject({ + originUrl: baseUrlWithKey, + codeVerifier: expect.any(String), + targetLinkUri: allowedTargetLinkUri, + }) + }) + + it('should call login endpoint with correct parameters', async () => { + // Arrange + const expectedParams = { + client_id: mockConfig.ids.clientId, + response_type: 'code', + response_mode: 'query', + scope: 'test_scope offline_access openid profile', + redirect_uri: mockConfig.callbacksRedirectUris.login, + code_challenge_method: 'test_code_challenge_method', + } + + const unknownValueParams = ['state', 'code_challenge'] + + // Act + const res = await server.get('/login') + + // Assert + expect(res.status).toEqual(HttpStatus.FOUND) + + // Check if the location header starts with the issuer ID + expect(res.headers.location).toMatch( + new RegExp(`^${mockConfig.ids.issuer}?`), + ) + + const url = new URL(res.headers.location) + + // Verify that each expected parameter is present + for (const [key, value] of Object.entries(expectedParams)) { + if (key === 'scope') { + for (const scope of value.split(' ')) { + expect(url.searchParams.get('scope')).toContain(scope) + } + } else { + expect(url.searchParams.get(key)).toEqual(value) + } + } + + // Verify that each unknown value parameter is present + for (const key of unknownValueParams) { + expect(url.searchParams.get(key)).toBeDefined() + } + }) + + it('should validate the query string param "target_link_uri" if not allowed', async () => { + // Arrange + const invalidTargetLinkUri = 'http://test-client.com/invalid' + + const searchParams = new URLSearchParams({ + bff_error_code: '400', + bff_error_description: 'Login failed!', + }) + + const errorUrl = `${baseUrlWithKey}?${searchParams.toString()}` + + // Act + const res = await server + .get('/login') + .query({ target_link_uri: invalidTargetLinkUri }) + + // Assert + expect(res.status).toEqual(HttpStatus.FOUND) + expect(res.headers.location).toMatch(errorUrl) + }) + + it('should validate the query string param "target_link_uri" if allowed', async () => { + // Act + const res = await server + .get('/login') + .query({ target_link_uri: allowedTargetLinkUri }) + + // Assert + expect(res.status).toEqual(HttpStatus.FOUND) + expect(res.headers.location).toMatch( + new RegExp(`^${mockConfig.ids.issuer}?`), + ) + }) + + it('should support PAR (Pushed Authorization Request) when enabled in config', async () => { + // Arrange + const parResponse: ParResponse = { + request_uri: 'urn:ietf:params:oauth:request_uri:abc123', + expires_in: 600, + } + + const expectedParams = { + request_uri: parResponse.request_uri, + client_id: mockConfig.ids.clientId, + } + + const redirectUrlSearchParams = new URLSearchParams(expectedParams) + + const app = await setupTestServer({ + override: (builder) => + builder + .overrideProvider(IdsService) + .useValue(mockIdsService) + .overrideProvider(BffConfig.KEY) + .useValue({ + ...mockConfig, + parSupportEnabled: true, + }), + }) + + const newServer = request(app.getHttpServer()) + const getParSpy = jest.spyOn(mockIdsService, 'getPar') + + // Act + const res = await newServer.get('/login') + + // Assert + expect(getParSpy).toHaveBeenCalled() + expect(res.status).toEqual(HttpStatus.FOUND) + expect(res.headers.location).toEqual( + `${ + mockConfig.ids.issuer + }/connect/authorize?${redirectUrlSearchParams.toString()}`, + ) + }) + }) + + describe('GET /callbacks/login', () => { + it('should redirect with error if invalid_request is present', async () => { + // Arrange + const idsError = 'Invalid request' + const searchParams = new URLSearchParams({ + bff_error_code: '500', + bff_error_description: idsError, + }) + + const errorUrl = `${baseUrlWithKey}?${searchParams.toString()}` + + // Act + const res = await server.get('/callbacks/login').query({ + invalid_request: idsError, + }) + + // Assert + expect(res.status).toEqual(HttpStatus.FOUND) + expect(res.headers.location).toMatch(errorUrl) + }) + + it('should validate query string params and redirect with error if invalid', async () => { + // Arrange + const searchParams = new URLSearchParams({ + bff_error_code: '400', + bff_error_description: 'Login failed!', + }) + const errorUrl = `${baseUrlWithKey}?${searchParams.toString()}` + + // Act + const res = await server.get('/callbacks/login') + + // Assert + expect(res.status).toEqual(HttpStatus.FOUND) + expect(res.headers.location).toMatch(errorUrl) + }) + + const scenarios = [ + { + description: + 'should successfully finish callback login and redirect to fallback originUrl', + targetLinkUri: undefined, + expectedLocation: 'http://test-client.com/testclient', + }, + { + description: + 'should successfully finish callback login and redirect to target_link_uri query param', + targetLinkUri: allowedTargetLinkUri, + expectedLocation: allowedTargetLinkUri, + }, + ] + + it.each(scenarios)( + '$description', + async ({ targetLinkUri, expectedLocation }) => { + // Arrange + const code = 'testcode' + const getTokensSpy = jest.spyOn(mockIdsService, 'getTokens') + const deleteCacheSpy = jest.spyOn(mockCacheManagerValue, 'del') + const setCacheSpy = jest.spyOn(mockCacheManagerValue, 'set') + const getCacheSpy = jest.spyOn(mockCacheManagerValue, 'get') + + // Act - First request to cache the login attempt + await server + .get('/login') + .query(targetLinkUri ? { target_link_uri: targetLinkUri } : {}) + + const loginAttempt = setCacheSpy.mock.calls[0] + + // Assert - First request should cache the login attempt + expect(setCacheSpy.mock.calls[0]).toContain(`attempt_${SID_VALUE}`) + expect(loginAttempt[1]).toMatchObject({ + originUrl: baseUrlWithKey, + codeVerifier: expect.any(String), + targetLinkUri, + }) + + // Then make a callback to the login endpoint + const res = await server + .get('/callbacks/login') + .set('Cookie', [`${SESSION_COOKIE_NAME}=${SID_VALUE}`]) + .query({ code, state: SID_VALUE }) + + const currentLogin = setCacheSpy.mock.calls[1] + + // Assert + expect(setCacheSpy).toHaveBeenCalled() + + expect(currentLogin[0]).toContain(`current_${SID_VALUE}`) + // Check if the cache contains the correct values for the current login + expect(currentLogin[1]).toMatchObject(tokensResponse) + + expect(getCacheSpy).toHaveBeenCalled() + expect(getTokensSpy).toHaveBeenCalled() + expect(deleteCacheSpy).toHaveBeenCalled() + + expect(res.status).toEqual(HttpStatus.FOUND) + + // Should redirect to the expected location + expect(res.headers.location).toEqual(expectedLocation) + }, + ) + }) + + describe('GET /logout', () => { + it('should throw bad request if no sid query string is found', async () => { + // Act + await server + .get('/login') + .query({ target_link_uri: allowedTargetLinkUri }) + + await server + .get('/callbacks/login') + .set('Cookie', [`${SESSION_COOKIE_NAME}=${SID_VALUE}`]) + .query({ code: 'some_code', state: SID_VALUE }) + const res = await server.get('/logout') + + // Assert + expect(res.status).toEqual(HttpStatus.BAD_REQUEST) + }) + + it('should validate if no session cookie is found', async () => { + // Act + const res = await server.get('/logout').query({ sid: SID_VALUE }) + + // Assert + expect(res.status).toEqual(HttpStatus.FOUND) + expect(res.headers.location).toEqual(mockConfig.logoutRedirectUri) + }) + + it('should redirect to logout redirect uri with error params if cookie and session state do not match', async () => { + // Arrange + const searchParams = new URLSearchParams({ + bff_error_code: '400', + bff_error_description: 'Logout failed!', + }) + + const errorUrl = `${allowedTargetLinkUri}?${searchParams.toString()}` + + // Act + const res = await server + .get('/logout') + .query({ sid: SID_VALUE }) + .set('Cookie', [`${SESSION_COOKIE_NAME}=invalid_uuid`]) + + // Assert + expect(res.status).toEqual(HttpStatus.FOUND) + expect(res.headers.location).toMatch(errorUrl) + }) + + it('should successfully logout and redirect to logout redirect uri', async () => { + // Arrange + const deleteCacheSpy = jest.spyOn(mockCacheManagerValue, 'del') + const revokeRefreshTokenSpy = jest.spyOn(mockIdsService, 'revokeToken') + const searchParams = new URLSearchParams({ + id_token_hint: tokensResponse.id_token, + post_logout_redirect_uri: mockConfig.logoutRedirectUri, + }) + + // Act + await server + .get('/login') + .query({ target_link_uri: allowedTargetLinkUri }) + + await server + .get('/callbacks/login') + .set('Cookie', [`${SESSION_COOKIE_NAME}=${SID_VALUE}`]) + .query({ code: 'some_code', state: SID_VALUE }) + + const res = await server + .get('/logout') + .query({ sid: SID_VALUE }) + .set('Cookie', [`${SESSION_COOKIE_NAME}=${SID_VALUE}`]) + + // Assert + expect(revokeRefreshTokenSpy).toHaveBeenCalled() + expect(deleteCacheSpy).toHaveBeenCalled() + expect(res.status).toEqual(HttpStatus.FOUND) + + expect(res.headers.location).toEqual( + `${ + mockConfig.ids.issuer + }/connect/endsession?${searchParams.toString()}`, + ) + }) + }) + + describe('POST /callbacks/logout', () => { + let tokenPayload: object + + beforeAll(() => { + tokenPayload = { + iss: mockConfig.ids.issuer, + sub: '1234567890', + exp: Math.floor(Date.now() / 1000) + 3600, + sid: SID_VALUE, + } + }) + + it('should throw 400 if logout_token is missing from body', async () => { + // Act + const res = await await server.post('/callbacks/logout') + + // Assert + expect(res.status).toEqual(HttpStatus.BAD_REQUEST) + // Expect error to be + expect(res.body).toMatchObject({ + statusCode: 400, + message: 'No param "logout_token" provided!', + }) + }) + + it('should throw an error for a invalid logout_token, no matching key found for kid', async () => { + // Arrange + mockedSigningKeys.mockImplementationOnce(() => [noMatchKidSigningKey]) + + const invalidToken = jwt.sign(tokenPayload, TEST_PRIVATE_KEY, { + algorithm: 'RS256', + header: { kid: KID }, + }) + + // Act + const res = await server + .post('/callbacks/logout') + .send({ logout_token: invalidToken }) + + // Assert + expect(res.status).toEqual(HttpStatus.UNAUTHORIZED) + }) + + it('should throw an error for a invalid logout_token, no sid in the token', async () => { + // Arrange + const invalidToken = jwt.sign( + { + ...tokenPayload, + sid: undefined, + }, + TEST_PRIVATE_KEY, + { + algorithm: 'RS256', + header: { kid: KID }, + }, + ) + + // Act + const res = await server + .post('/callbacks/logout') + .send({ logout_token: invalidToken }) + + // Assert + expect(res.status).toEqual(HttpStatus.UNAUTHORIZED) + }) + + it('should return a 200 success response', async () => { + // Arrange + const validToken = jwt.sign(tokenPayload, TEST_PRIVATE_KEY, { + algorithm: 'RS256', + header: { kid: KID }, + }) + + const getCacheSpy = jest.spyOn(mockCacheManagerValue, 'get') + const revokeRefreshTokenSpy = jest.spyOn(mockIdsService, 'revokeToken') + + // Act + await server + .get('/login') + .query({ target_link_uri: allowedTargetLinkUri }) + + await server + .get('/callbacks/login') + .set('Cookie', [`${SESSION_COOKIE_NAME}=${SID_VALUE}`]) + .query({ code: 'some_code', state: SID_VALUE }) + + const res = await server + .post('/callbacks/logout') + .send({ logout_token: validToken }) + + // Assert + expect(getCacheSpy).toHaveBeenCalled() + expect(revokeRefreshTokenSpy).toHaveBeenCalled() + expect(res.status).toEqual(HttpStatus.OK) + expect(res.body).toMatchObject({ + status: 'success', + message: 'Logout successful!', + }) + }) + }) +}) diff --git a/apps/services/bff/src/app/modules/auth/auth.controller.ts b/apps/services/bff/src/app/modules/auth/auth.controller.ts index 27663765a7f9..4ea72c95a879 100644 --- a/apps/services/bff/src/app/modules/auth/auth.controller.ts +++ b/apps/services/bff/src/app/modules/auth/auth.controller.ts @@ -53,12 +53,14 @@ export class AuthController { @Post('callbacks/logout') async callbackBackchannelLogout( - @Req() req: Request, + @Res() res: Response, @Body() body: CallbackLogoutDto, - ): Promise<{ - status: string - message: string - }> { - return this.authService.callbackLogout(body) + ): Promise< + Response<{ + status: string + message: string + }> + > { + return this.authService.callbackLogout(res, body) } } diff --git a/apps/services/bff/src/app/modules/auth/auth.service.ts b/apps/services/bff/src/app/modules/auth/auth.service.ts index bca28fc03f7a..7280053f868b 100644 --- a/apps/services/bff/src/app/modules/auth/auth.service.ts +++ b/apps/services/bff/src/app/modules/auth/auth.service.ts @@ -2,6 +2,7 @@ import type { Logger } from '@island.is/logging' import { LOGGER_PROVIDER } from '@island.is/logging' import { BadRequestException, + HttpStatus, Inject, Injectable, InternalServerErrorException, @@ -489,7 +490,7 @@ export class AuthService { } } - async callbackLogout(body: CallbackLogoutDto) { + async callbackLogout(res: Response, body: CallbackLogoutDto) { const logoutToken = body.logout_token if (!logoutToken) { @@ -521,10 +522,10 @@ export class AuthService { await this.cacheService.delete(cacheKey) - return { + return res.status(HttpStatus.OK).json({ status: 'success', - message: 'Logout successful and cache cleared.', - } + message: 'Logout successful!', + }) } catch (error) { // Check if error is an UnauthorizedException and just throw it, // since it is already being logged in the validateLogoutToken method. diff --git a/apps/services/bff/src/app/modules/ids/ids.service.ts b/apps/services/bff/src/app/modules/ids/ids.service.ts index 192dec2db6c5..77b744598e88 100644 --- a/apps/services/bff/src/app/modules/ids/ids.service.ts +++ b/apps/services/bff/src/app/modules/ids/ids.service.ts @@ -5,7 +5,13 @@ import type { EnhancedFetchAPI } from '@island.is/clients/middlewares' import { BffConfig } from '../../bff.config' import { CryptoService } from '../../services/crypto.service' import { ENHANCED_FETCH_PROVIDER_KEY } from '../enhancedFetch/enhanced-fetch.provider' -import { ApiResponse, ErrorRes, ParResponse, TokenResponse } from './ids.types' +import { + ApiResponse, + ErrorRes, + GetLoginSearchParamsReturnValue, + ParResponse, + TokenResponse, +} from './ids.types' @Injectable() export class IdsService { @@ -94,18 +100,7 @@ export class IdsService { codeChallenge: string loginHint?: string prompt?: string - }): { - client_id: string - redirect_uri: string - response_type: string - response_mode: string - scope: string - state: string - code_challenge: string - code_challenge_method: string - login_hint?: string - prompt?: string - } { + }): GetLoginSearchParamsReturnValue { const { ids } = this.config return { client_id: ids.clientId, diff --git a/apps/services/bff/src/app/modules/ids/ids.types.ts b/apps/services/bff/src/app/modules/ids/ids.types.ts index 61cd5a2f6824..8f88ced6f832 100644 --- a/apps/services/bff/src/app/modules/ids/ids.types.ts +++ b/apps/services/bff/src/app/modules/ids/ids.types.ts @@ -1,3 +1,16 @@ +export type GetLoginSearchParamsReturnValue = { + client_id: string + redirect_uri: string + response_type: string + response_mode: string + scope: string + state: string + code_challenge: string + code_challenge_method: string + login_hint?: string + prompt?: string +} + export interface ErrorResponse { // Error code, e.g. invalid_grant, invalid_request, ... error: string diff --git a/apps/services/bff/test/setup.ts b/apps/services/bff/test/setup.ts index 4a9c7f4d0cca..9c8cbeee3c85 100644 --- a/apps/services/bff/test/setup.ts +++ b/apps/services/bff/test/setup.ts @@ -1,3 +1,21 @@ process.env.PORT = '3010' -process.env.BFF_CLIENT_KEY_PATH = '/my-client-key-path' -process.env.BFF_NAME = 'bff-some-placeholder-name' +process.env.BFF_NAME = 'testclient' +process.env.BFF_CACHE_USER_PROFILE_TTL_MS = '3595000' // 1 hour - 5 seconds +process.env.BFF_LOGIN_ATTEMPT_TTL_MS = '604800000' // 1 week +process.env.IDENTITY_SERVER_CLIENT_SECRET = 'some secret' +process.env.IDENTITY_SERVER_ISSUER_URL = + 'https://identity-server.dev01.devland.is' +process.env.IDENTITY_SERVER_CLIENT_SCOPES = '["testscope"]' +process.env.IDENTITY_SERVER_CLIENT_ID = '@test_client_id' +process.env.BFF_PAR_SUPPORT_ENABLED = 'false' + +process.env.BFF_CLIENT_KEY_PATH = '/testclient' +process.env.BFF_CLIENT_BASE_URL = 'http://test-client.com' +process.env.BFF_ALLOWED_REDIRECT_URIS = '["http://test-client.com/testclient"]' +process.env.BFF_ALLOWED_EXTERNAL_API_URLS = '["https://api.external.com"]' +process.env.BFF_CALLBACKS_BASE_PATH = + 'http://localhost:3010/testclient/bff/callbacks' +process.env.BFF_LOGOUT_REDIRECT_URI = 'http://localhost:4200/testclient' +process.env.BFF_PROXY_API_ENDPOINT = 'http://localhost:4444/api/graphql' +process.env.BFF_TOKEN_SECRET_BASE64 = + 'Y0ROrC3mxDBnveN+EpAnLtSubttyjZZWcV43dyk7OQI=' diff --git a/apps/services/bff/test/setupTestServer.ts b/apps/services/bff/test/setupTestServer.ts new file mode 100644 index 000000000000..db90e4f77a9a --- /dev/null +++ b/apps/services/bff/test/setupTestServer.ts @@ -0,0 +1,8 @@ +import { testServer, TestServerOptions } from '@island.is/testing/nest' +import { AppModule } from '../src/app/app.module' + +export const setupTestServer = async (options?: Partial) => + testServer({ + appModule: AppModule, + ...options, + }) diff --git a/libs/testing/nest/src/lib/testServer.ts b/libs/testing/nest/src/lib/testServer.ts index 00ae8af89a00..fefd302a8214 100644 --- a/libs/testing/nest/src/lib/testServer.ts +++ b/libs/testing/nest/src/lib/testServer.ts @@ -3,7 +3,8 @@ import { Test } from '@nestjs/testing' import { TestingModuleBuilder } from '@nestjs/testing/testing-module.builder' import { InfraModule, HealthCheckOptions } from '@island.is/infra-nest-server' -import bodyParser from 'body-parser' + +import cookieParser from 'cookie-parser' type CleanUp = () => Promise | undefined @@ -74,6 +75,8 @@ export const testServer = async ({ await beforeServerStart(app) } + app.use(cookieParser()) + await app.init() const hookCleanups = await Promise.all( From 29235b5d1bc8124f8b48cde428abe41fe21d841c Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Thu, 17 Oct 2024 11:52:09 +0000 Subject: [PATCH 125/248] Add logout log for testing in feature deploy --- apps/services/bff/src/app/modules/auth/auth.service.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/services/bff/src/app/modules/auth/auth.service.ts b/apps/services/bff/src/app/modules/auth/auth.service.ts index 7280053f868b..f9e0d6008d42 100644 --- a/apps/services/bff/src/app/modules/auth/auth.service.ts +++ b/apps/services/bff/src/app/modules/auth/auth.service.ts @@ -493,6 +493,10 @@ export class AuthService { async callbackLogout(res: Response, body: CallbackLogoutDto) { const logoutToken = body.logout_token + this.logger.info('Callback backchannel logout initiated', { + logoutToken, + }) + if (!logoutToken) { const errorMessage = 'No param "logout_token" provided!' this.logger.error(errorMessage) From f46d382b04d40b0d7ae277d758c77140815992a8 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Thu, 17 Oct 2024 14:23:16 +0000 Subject: [PATCH 126/248] remove unused --- .../bff/src/app/modules/auth/auth.controller.spec.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/apps/services/bff/src/app/modules/auth/auth.controller.spec.ts b/apps/services/bff/src/app/modules/auth/auth.controller.spec.ts index 6df16c3b1d43..e9cdad425f03 100644 --- a/apps/services/bff/src/app/modules/auth/auth.controller.spec.ts +++ b/apps/services/bff/src/app/modules/auth/auth.controller.spec.ts @@ -74,12 +74,6 @@ const noMatchKidSigningKey = { getPublicKey: jest.fn().mockReturnValue(TEST_PUBLIC_KEY), } -const invalidPublicKeySigningKey = { - kid: KID, - alg: 'RS256', - getPublicKey: jest.fn().mockReturnValue('invalid-public-key'), -} - const mockedSigningKeys = jest.fn().mockReturnValue([validSigningKey]) jest.mock('jwks-rsa', () => { From 98ed4a571a4a6f67b2af392c771e7011cd72fa06 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Thu, 17 Oct 2024 14:34:21 +0000 Subject: [PATCH 127/248] clean up auth controller test --- .../app/modules/auth/auth.controller.spec.ts | 66 ++++--------------- 1 file changed, 13 insertions(+), 53 deletions(-) diff --git a/apps/services/bff/src/app/modules/auth/auth.controller.spec.ts b/apps/services/bff/src/app/modules/auth/auth.controller.spec.ts index e9cdad425f03..0a066107dffb 100644 --- a/apps/services/bff/src/app/modules/auth/auth.controller.spec.ts +++ b/apps/services/bff/src/app/modules/auth/auth.controller.spec.ts @@ -15,48 +15,8 @@ import { const SID_VALUE = 'fake_uuid' const SESSION_COOKIE_NAME = 'sid' const KID = 'test-kid' -const TEST_PRIVATE_KEY = ` ------BEGIN PRIVATE KEY----- -MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC7uKPR7Gq2Cz3u -BDzUfTYirK6SaK9TnHD8KuOhCDp91j982NIivA/6SwfJhXAfqPdlbgxCsdk0R48E -ZwVuOCXbS4d4V39F+BpgEtuYelc56Ag0n3FuTpKgKiEQePkJSXzmgFD9W1ghXFNu -9cYmoPe7EfN7oFjZFCVn41ooztJZNpFyyJNnMR5m/IWKVPD4JGOjJDvfsyz17hP+ -dKsbxKLstzh9ziBoLzrlq/N5AGdaaYTLr2dbMS1wiuCdESQkIRJw8yh8Emu3rtf4 -X/p0VHbd/6MErxJE7OuPNnaJuc8D5Fc2vpQUTqoS6oY/THRVXdosbgAG19jwEGTY -31OYE2YbAgMBAAECggEADVZZHh7P41I/wRImmxEWw3aFMRxkXiqBy1cywavnfmEc -zqTko5iJnBNjoL/gNc4LK11nwpjtoTpdh21x/dRAbRwow/YxGHDZuUftt5/AypNZ -R3/cKJjgvKXYBo18+1nWZW9Qq2EnjR5vRE8MmCcDCUgFU09fsKcVR0dodJJi1vkb -cLFQExaU4kmcnvqxV2FazXuQaC8EbTtMcS6WHKNSCIYbd64hAcu906c7JgdC9Uvc -myaU0MLeb7WAlmN3Xpvyg6bCxwMLKjTdAr5p4J7/Tv/rimVPIOEsqI0NVP5Ajx/p -+w9kKanQ3L/7lt8+Q094U1iZ80zvBNh4XaAqUvhQQQKBgQDpDUMQHv29oilYiL73 -+A70K4X2LptcH5fOGa/EHE8AeM9vlJm0HAnsOT+iERFsl6peD3bcoiyV84z72Q7P -WNddA+rynB4gBu8ymUS/CpXwZQSi33xxT55rB7hLJK+22udVDXzCrNcSQPd4Gwn3 -lNqSUYf+8tv7Ub0Qm1R5lJYswQKBgQDONLEH3U/5aNV0/E0zcd0SVI9OtKu8qDxJ -J6v/P3bwbWvOmLRON4gFeAqK8awrYZd6Zupsdx15uqu/0Ikym/0C5bOzQa3E/9tT -vOZRoNch416J0AKY9wFemwxkDcheiOxSanP8PbNZPp0N86JehuXxy41guEu41qWZ -dDvrrpld2wKBgEqXZA+U28IGVRVxLy5Oxvp/s7DH2hHySrQ8pHUwWljcUgh0l31+ -O+7Po/5LWDhZkr3oVTLo9TxJZ6Z0IrlaxhOPXXOpZDr7/TNEuywqRzNaIdG/liTu -RtYa8nGanGL6TXB7kKL+jxfYk1xtyxLjIdITJmQDd0VJNCpMjQ0c8bQBAoGBAKn3 -/MRCxB0NMIWRQgFZpaPqV4XEnpqPAcI7FSb8JQng57APZu/iDhiT7fzBX+0SME4Q -bsKhHIauO8uMFMrGkTLGK+1iAd4UF7FaT26RaULhq5dlAf8b+uEEZJ5EThi+PC1i -2d/c6+xwE/zgCcJo5zj7U7mZr7DYHP/0Mz/9VyVpAoGBALvLo78LnijdHTf8mrWT -AlSQjhcJtHb1er4VKlPDF7p3hcVtrpgQaxnGL67DeLl4tt4dU4DoMMQKL/AyVDzI -gjQdXRT3AQDbB39P43an+11pXZOcyE1hCmW1VntOxY2DSD2GK45sh2eDQ6kuClbP -VIQ36zUj7NhPnYWM6aUHNLwA ------END PRIVATE KEY----- -` - -const TEST_PUBLIC_KEY = ` ------BEGIN PUBLIC KEY----- -MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu7ij0exqtgs97gQ81H02 -IqyukmivU5xw/CrjoQg6fdY/fNjSIrwP+ksHyYVwH6j3ZW4MQrHZNEePBGcFbjgl -20uHeFd/RfgaYBLbmHpXOegINJ9xbk6SoCohEHj5CUl85oBQ/VtYIVxTbvXGJqD3 -uxHze6BY2RQlZ+NaKM7SWTaRcsiTZzEeZvyFilTw+CRjoyQ737Ms9e4T/nSrG8Si -7Lc4fc4gaC865avzeQBnWmmEy69nWzEtcIrgnREkJCEScPMofBJrt67X+F/6dFR2 -3f+jBK8SROzrjzZ2ibnPA+RXNr6UFE6qEuqGP0x0VV3aLG4ABtfY8BBk2N9TmBNm -GwIDAQAB ------END PUBLIC KEY----- -` +const ALGORITM_TYPE = 'HS256' +const SECRET_KEY = 'mock_secret' jest.mock('uuid', () => ({ v4: jest.fn().mockReturnValue('fake_uuid'), @@ -64,14 +24,14 @@ jest.mock('uuid', () => ({ const validSigningKey = { kid: KID, - alg: 'RS256', - getPublicKey: jest.fn().mockReturnValue(TEST_PUBLIC_KEY), + alg: ALGORITM_TYPE, + getPublicKey: jest.fn().mockReturnValue(SECRET_KEY), } const noMatchKidSigningKey = { kid: 'invalid-kid', - alg: 'RS256', - getPublicKey: jest.fn().mockReturnValue(TEST_PUBLIC_KEY), + alg: ALGORITM_TYPE, + getPublicKey: jest.fn().mockReturnValue(SECRET_KEY), } const mockedSigningKeys = jest.fn().mockReturnValue([validSigningKey]) @@ -106,7 +66,7 @@ const tokensResponse: TokenResponse = { sid: SID_VALUE, }, 'mockSecret', - { algorithm: 'HS256' }, + { algorithm: ALGORITM_TYPE }, ), refresh_token: 'mockRefreshToken1234567890', scope: 'openid profile email', @@ -542,8 +502,8 @@ describe('AuthController', () => { // Arrange mockedSigningKeys.mockImplementationOnce(() => [noMatchKidSigningKey]) - const invalidToken = jwt.sign(tokenPayload, TEST_PRIVATE_KEY, { - algorithm: 'RS256', + const invalidToken = jwt.sign(tokenPayload, SECRET_KEY, { + algorithm: ALGORITM_TYPE, header: { kid: KID }, }) @@ -563,9 +523,9 @@ describe('AuthController', () => { ...tokenPayload, sid: undefined, }, - TEST_PRIVATE_KEY, + SECRET_KEY, { - algorithm: 'RS256', + algorithm: ALGORITM_TYPE, header: { kid: KID }, }, ) @@ -581,8 +541,8 @@ describe('AuthController', () => { it('should return a 200 success response', async () => { // Arrange - const validToken = jwt.sign(tokenPayload, TEST_PRIVATE_KEY, { - algorithm: 'RS256', + const validToken = jwt.sign(tokenPayload, SECRET_KEY, { + algorithm: ALGORITM_TYPE, header: { kid: KID }, }) From 0730ac73c0bc2dfb6732e9c91fb1c7150d456a83 Mon Sep 17 00:00:00 2001 From: andes-it Date: Thu, 17 Oct 2024 15:12:04 +0000 Subject: [PATCH 128/248] chore: nx format:write update dirty files --- apps/portals/admin/project.json | 24 ++++++------------------ 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/apps/portals/admin/project.json b/apps/portals/admin/project.json index b28683236526..c6dee8b5d7df 100644 --- a/apps/portals/admin/project.json +++ b/apps/portals/admin/project.json @@ -3,15 +3,11 @@ "$schema": "../../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "apps/portals/admin/src", "projectType": "application", - "tags": [ - "scope:portals-admin" - ], + "tags": ["scope:portals-admin"], "targets": { "build": { "executor": "@nx/webpack:webpack", - "outputs": [ - "{options.outputPath}" - ], + "outputs": ["{options.outputPath}"], "defaultConfiguration": "production", "options": { "compiler": "babel", @@ -26,9 +22,7 @@ "apps/portals/admin/src/mockServiceWorker.js", "apps/portals/admin/src/assets" ], - "styles": [ - "apps/portals/admin/src/styles.css" - ], + "styles": ["apps/portals/admin/src/styles.css"], "scripts": [], "webpackConfig": "apps/portals/admin/webpack.config.js" }, @@ -55,9 +49,7 @@ "node scripts/dockerfile-assets/bash/extract-environment.js apps/portals/admin/src" ] }, - "outputs": [ - "{workspaceRoot}/apps/portals/admin/src/index.html" - ] + "outputs": ["{workspaceRoot}/apps/portals/admin/src/index.html"] }, "serve": { "executor": "@nx/webpack:dev-server", @@ -83,9 +75,7 @@ }, "test": { "executor": "@nx/jest:jest", - "outputs": [ - "{workspaceRoot}/coverage/apps/portals/admin" - ], + "outputs": ["{workspaceRoot}/coverage/apps/portals/admin"], "options": { "jestConfig": "apps/portals/admin/jest.config.ts" } @@ -99,9 +89,7 @@ "dev-init": { "executor": "nx:run-commands", "options": { - "commands": [ - "yarn get-secrets portals-admin" - ], + "commands": ["yarn get-secrets portals-admin"], "parallel": false } }, From 5eff85f43822fbc818e04fa4f732ecbb8af1d163 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Fri, 18 Oct 2024 13:46:43 +0000 Subject: [PATCH 129/248] Add tests for proxy controller --- .../app/modules/auth/auth.controller.spec.ts | 55 +--- .../modules/proxy/proxy.controller.spec.ts | 246 ++++++++++++++++++ .../bff/src/app/modules/proxy/proxy.types.ts | 9 + apps/services/bff/test/sharedConstants.ts | 53 ++++ 4 files changed, 317 insertions(+), 46 deletions(-) create mode 100644 apps/services/bff/src/app/modules/proxy/proxy.controller.spec.ts create mode 100644 apps/services/bff/src/app/modules/proxy/proxy.types.ts create mode 100644 apps/services/bff/test/sharedConstants.ts diff --git a/apps/services/bff/src/app/modules/auth/auth.controller.spec.ts b/apps/services/bff/src/app/modules/auth/auth.controller.spec.ts index 0a066107dffb..ac2b1d951a9d 100644 --- a/apps/services/bff/src/app/modules/auth/auth.controller.spec.ts +++ b/apps/services/bff/src/app/modules/auth/auth.controller.spec.ts @@ -4,18 +4,18 @@ import { HttpStatus, INestApplication } from '@nestjs/common' import jwt from 'jsonwebtoken' import request from 'supertest' import { setupTestServer } from '../../../../test/setupTestServer' +import { + mockedTokensResponse as tokensResponse, + SID_VALUE, + SESSION_COOKIE_NAME, + ALGORITM_TYPE, + getLoginSearchParmsFn, +} from '../../../../test/sharedConstants' import { BffConfig } from '../../bff.config' import { IdsService } from '../ids/ids.service' -import { - GetLoginSearchParamsReturnValue, - ParResponse, - TokenResponse, -} from '../ids/ids.types' +import { ParResponse } from '../ids/ids.types' -const SID_VALUE = 'fake_uuid' -const SESSION_COOKIE_NAME = 'sid' const KID = 'test-kid' -const ALGORITM_TYPE = 'HS256' const SECRET_KEY = 'mock_secret' jest.mock('uuid', () => ({ @@ -55,25 +55,6 @@ const parResponse: ParResponse = { expires_in: 600, } -const tokensResponse: TokenResponse = { - access_token: - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.mockSignature', - id_token: jwt.sign( - { - iss: 'https://example.com', - sub: '1234567890', - exp: Math.floor(Date.now() / 1000) + 3600, // 1 hour expiration - sid: SID_VALUE, - }, - 'mockSecret', - { algorithm: ALGORITM_TYPE }, - ), - refresh_token: 'mockRefreshToken1234567890', - scope: 'openid profile email', - token_type: 'Bearer', - expires_in: 3600, -} - const allowedTargetLinkUri = 'http://test-client.com/testclient' const mockIdsService = { @@ -88,25 +69,7 @@ const mockIdsService = { revokeToken: jest.fn().mockResolvedValue({ type: 'success', }), - getLoginSearchParams: jest.fn().mockImplementation( - (args: { - sid: string - codeChallenge: string - loginHint?: string - prompt?: string - }): GetLoginSearchParamsReturnValue => ({ - client_id: '@test_client_id', - redirect_uri: 'http://localhost:3010/testclient/bff/callbacks/login', - response_type: 'code', - response_mode: 'query', - scope: 'test_scope offline_access openid profile', - state: SID_VALUE, - code_challenge: 'test_code_challenge', - code_challenge_method: 'test_code_challenge_method', - ...(args.loginHint && { login_hint: args.loginHint }), - ...(args.prompt && { prompt: args.prompt }), - }), - ), + getLoginSearchParams: jest.fn().mockImplementation(getLoginSearchParmsFn), } describe('AuthController', () => { diff --git a/apps/services/bff/src/app/modules/proxy/proxy.controller.spec.ts b/apps/services/bff/src/app/modules/proxy/proxy.controller.spec.ts new file mode 100644 index 000000000000..1e97fcbe39c8 --- /dev/null +++ b/apps/services/bff/src/app/modules/proxy/proxy.controller.spec.ts @@ -0,0 +1,246 @@ +import { ConfigType } from '@island.is/nest/config' +import { CACHE_MANAGER } from '@nestjs/cache-manager' +import { HttpStatus, INestApplication } from '@nestjs/common' +import request from 'supertest' + +import { setupTestServer } from '../../../../test/setupTestServer' +import { + SESSION_COOKIE_NAME, + SID_VALUE, + getLoginSearchParmsFn, + mockedTokensResponse as tokensResponse, +} from '../../../../test/sharedConstants' +import { BffConfig } from '../../bff.config' +import { hasTimestampExpiredInMS } from '../../utils/has-timestamp-expired-in-ms' +import { IdsService } from '../ids/ids.service' +import { TokenResponse } from '../ids/ids.types' +import { ProxyService } from './proxy.service' +import { ExecuteStreamRequestArgs } from './proxy.types' + +jest.mock('uuid', () => ({ + v4: jest.fn().mockReturnValue('fake_uuid'), +})) + +const allowedExternalApiUrl = JSON.parse( + process.env.BFF_ALLOWED_EXTERNAL_API_URLS as unknown as string, +)[0] + +const mockCacheStore = new Map() + +const mockCacheManagerValue = { + set: jest.fn((key, value) => mockCacheStore.set(key, value)), + get: jest.fn((key) => mockCacheStore.get(key)), + del: jest.fn((key) => mockCacheStore.delete(key)), +} + +const EXPIRED_TOKEN_RESPONSE: TokenResponse = { + ...tokensResponse, + expires_in: 0, +} + +const mockIdsService = { + refreshToken: jest.fn().mockResolvedValue({ + type: 'success', + data: tokensResponse, + }), + getTokens: jest.fn().mockResolvedValue({ + type: 'success', + data: tokensResponse, + }), + revokeToken: jest.fn().mockResolvedValue({ + type: 'success', + }), + getLoginSearchParams: jest.fn().mockImplementation(getLoginSearchParmsFn), +} + +const createTestProxyService = ( + cb: (args: ExecuteStreamRequestArgs) => void, +) => { + return class TestProxyService extends ProxyService { + async executeStreamRequest(args: ExecuteStreamRequestArgs): Promise { + cb(args) + } + } +} + +describe('ProxyController', () => { + let app: INestApplication + let server: request.SuperTest + let capturedArgs: ExecuteStreamRequestArgs + let mockConfig: ConfigType + + beforeAll(async () => { + const app = await setupTestServer({ + override: (builder) => + builder + .overrideProvider(CACHE_MANAGER) + .useValue(mockCacheManagerValue) + .overrideProvider(IdsService) + .useValue(mockIdsService) + .overrideProvider(ProxyService) + .useClass( + createTestProxyService((args) => { + capturedArgs = args + args.res.status(HttpStatus.OK).json({ message: 'success' }) + }), + ), + }) + + mockConfig = app.get>(BffConfig.KEY) + server = request(app.getHttpServer()) + }) + + afterEach(() => { + mockCacheStore.clear() + jest.clearAllMocks() + }) + + afterAll(async () => { + if (app) { + await app.close() + } + }) + + describe('GET /api', () => { + it('should throw 400 bad request when url param is not provided', async () => { + // Act + const res = await server.get('/api') + + // Assert + expect(res.status).toEqual(HttpStatus.BAD_REQUEST) + }) + + it('should throw 400 bad request when url param is invalid', async () => { + // Act + const res = await server.get('/api').query({ url: 'http://example.com' }) + + // Assert + expect(res.status).toEqual(HttpStatus.BAD_REQUEST) + }) + + it('should throw unauthorized exception when sid cookie is not provided', async () => { + // Act + const res = await server.get('/api').query({ url: allowedExternalApiUrl }) + + // Assert + expect(res.status).toEqual(HttpStatus.UNAUTHORIZED) + }) + + it('should throw unauthorized exception when cache key is not found', async () => { + // Act + const res = await server + .get('/api') + .query({ url: allowedExternalApiUrl }) + .set('Cookie', [`${SESSION_COOKIE_NAME}=${SID_VALUE}`]) + + // Assert + expect(res.status).toEqual(HttpStatus.UNAUTHORIZED) + }) + + it('should successfully proxy api request', async () => { + // Act + await server.get('/login') + + await server + .get('/callbacks/login') + .set('Cookie', [`${SESSION_COOKIE_NAME}=${SID_VALUE}`]) + .query({ code: 'some_code', state: SID_VALUE }) + + const res = await server + .get('/api') + .query({ url: allowedExternalApiUrl }) + .set('Cookie', [`${SESSION_COOKIE_NAME}=${SID_VALUE}`]) + + // Assert + expect(res.status).toEqual(HttpStatus.OK) + expect(res.body).toEqual({ message: 'success' }) + }) + }) + + describe('GET /api/graphql', () => { + beforeEach(() => { + jest.clearAllMocks() + mockIdsService.getTokens.mockResolvedValue({ + type: 'success', + data: tokensResponse, + }) + }) + + it('should throw 401 unauthorized when not logged in', async () => { + // Act + const res = await server.post('/api/graphql') + + // Assert + expect(res.status).toEqual(HttpStatus.UNAUTHORIZED) + }) + + it('should successfully proxy graphql request', async () => { + // Act + await server.get('/login') + + await server + .get('/callbacks/login') + .set('Cookie', [`${SESSION_COOKIE_NAME}=${SID_VALUE}`]) + .query({ code: 'some_code', state: SID_VALUE }) + + const res = await server + .post('/api/graphql') + .set('Cookie', [`${SESSION_COOKIE_NAME}=${SID_VALUE}`]) + + // Assert + expect(res.status).toEqual(HttpStatus.OK) + expect(res.body).toEqual({ message: 'success' }) + }) + + it('should append query string when proxing requests', async () => { + // Act + await server.get('/login') + + await server + .get('/callbacks/login') + .set('Cookie', [`${SESSION_COOKIE_NAME}=${SID_VALUE}`]) + .query({ code: 'some_code', state: SID_VALUE }) + + const res = await server + .post('/api/graphql?op=test') + .set('Cookie', [`${SESSION_COOKIE_NAME}=${SID_VALUE}`]) + + // Assert + expect(res.status).toEqual(HttpStatus.OK) + expect(capturedArgs.targetUrl).toEqual( + `${mockConfig.graphqlApiEndpoint}?op=test`, + ) + }) + + it('should correctly identify that the token has expired', () => { + const isExpired = hasTimestampExpiredInMS( + EXPIRED_TOKEN_RESPONSE.expires_in, + ) + + expect(isExpired).toBe(true) + }) + + it('should call refreshToken and cache token response when access_token is expired', async () => { + // Arrange + mockIdsService.getTokens.mockResolvedValue({ + type: 'success', + data: EXPIRED_TOKEN_RESPONSE, + }) + + // Act + await server.get('/login') + await server + .get('/callbacks/login') + .set('Cookie', [`${SESSION_COOKIE_NAME}=${SID_VALUE}`]) + .query({ code: 'some_code', state: SID_VALUE }) + + const res = await server + .post('/api/graphql') + .set('Cookie', [`${SESSION_COOKIE_NAME}=${SID_VALUE}`]) + + // Assert + expect(mockIdsService.refreshToken).toHaveBeenCalled() + expect(res.status).toEqual(HttpStatus.OK) + }) + }) +}) diff --git a/apps/services/bff/src/app/modules/proxy/proxy.types.ts b/apps/services/bff/src/app/modules/proxy/proxy.types.ts new file mode 100644 index 000000000000..1cde81d086e7 --- /dev/null +++ b/apps/services/bff/src/app/modules/proxy/proxy.types.ts @@ -0,0 +1,9 @@ +import { Request, Response } from 'express' + +export type ExecuteStreamRequestArgs = { + targetUrl: string + accessToken: string + req: Request + res: Response + body?: Record +} diff --git a/apps/services/bff/test/sharedConstants.ts b/apps/services/bff/test/sharedConstants.ts new file mode 100644 index 000000000000..99ec6d196209 --- /dev/null +++ b/apps/services/bff/test/sharedConstants.ts @@ -0,0 +1,53 @@ +import jwt from 'jsonwebtoken' +import { + TokenResponse, + GetLoginSearchParamsReturnValue, +} from '../src/app/modules/ids/ids.types' + +export const SESSION_COOKIE_NAME = 'sid' +export const ALGORITM_TYPE = 'HS256' +export const SID_VALUE = 'fake_uuid' + +const ONE_HOUR_EXPIRATION = Math.floor(Date.now() / 1000) + 3600 + +export const mockedTokensResponse: TokenResponse = { + access_token: jwt.sign( + { + exp: ONE_HOUR_EXPIRATION, + }, + 'mockSecret', + { algorithm: ALGORITM_TYPE }, + ), + id_token: jwt.sign( + { + iss: 'https://example.com', + sub: '1234567890', + exp: ONE_HOUR_EXPIRATION, + sid: SID_VALUE, + }, + 'mockSecret', + { algorithm: ALGORITM_TYPE }, + ), + refresh_token: 'mockRefreshToken1234567890', + scope: 'openid profile email', + token_type: 'Bearer', + expires_in: 3600, +} + +export const getLoginSearchParmsFn = (args: { + sid: string + codeChallenge: string + loginHint?: string + prompt?: string +}): GetLoginSearchParamsReturnValue => ({ + client_id: '@test_client_id', + redirect_uri: 'http://localhost:3010/testclient/bff/callbacks/login', + response_type: 'code', + response_mode: 'query', + scope: 'test_scope offline_access openid profile', + state: SID_VALUE, + code_challenge: 'test_code_challenge', + code_challenge_method: 'test_code_challenge_method', + ...(args.loginHint && { login_hint: args.loginHint }), + ...(args.prompt && { prompt: args.prompt }), +}) From f041e0c59a30ddc8d0c3dc483bc024b6136e721c Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Fri, 18 Oct 2024 15:11:17 +0000 Subject: [PATCH 130/248] Add ref to infra for api --- apps/services/bff/infra/admin-portal.infra.ts | 10 ++++++++-- apps/services/bff/infra/utils/createPortalEnv.ts | 15 +++++++-------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/apps/services/bff/infra/admin-portal.infra.ts b/apps/services/bff/infra/admin-portal.infra.ts index c70b500b928f..9dfb97f7f726 100644 --- a/apps/services/bff/infra/admin-portal.infra.ts +++ b/apps/services/bff/infra/admin-portal.infra.ts @@ -6,13 +6,19 @@ const clientName = 'portals-admin' const serviceName = `${bffName}-${clientName}` const key = 'stjornbord' -export const serviceSetup = (): ServiceBuilder => +export type BffInfraServices = { + api: ServiceBuilder<'api'> +} + +export const serviceSetup = ( + services: BffInfraServices, +): ServiceBuilder => service(serviceName) .namespace(clientName) .image(bffName) .redis() .serviceAccount(bffName) - .env(createPortalEnv(key)) + .env(createPortalEnv(key, services)) .secrets({ // The secret should be a valid 32-byte base64 key. // Generate key example: `openssl rand -base64 32` diff --git a/apps/services/bff/infra/utils/createPortalEnv.ts b/apps/services/bff/infra/utils/createPortalEnv.ts index 51f7bcd03098..acf37c5f2413 100644 --- a/apps/services/bff/infra/utils/createPortalEnv.ts +++ b/apps/services/bff/infra/utils/createPortalEnv.ts @@ -1,10 +1,11 @@ /* eslint-disable @nx/enforce-module-boundaries */ -import { json } from '../../../../../infra/src/dsl/dsl' +import { json, ref } from '../../../../../infra/src/dsl/dsl' import { adminPortalScopes, servicePortalScopes, } from '../../../../../libs/auth/scopes/src/index' import { FIVE_SECONDS_IN_MS } from '../../src/app/constants/time' +import { BffInfraServices } from '../admin-portal.infra' const ONE_HOUR_IN_MS = 60 * 60 * 1000 const ONE_WEEK_IN_MS = ONE_HOUR_IN_MS * 24 * 7 @@ -24,7 +25,10 @@ const getScopes = (key: PortalKeys) => { } } -export const createPortalEnv = (key: PortalKeys) => { +export const createPortalEnv = ( + key: PortalKeys, + services: BffInfraServices, +) => { return { // Idenity server IDENTITY_SERVER_CLIENT_SCOPES: json(getScopes(key)), @@ -68,12 +72,7 @@ export const createPortalEnv = (key: PortalKeys) => { staging: `https://beta.staging01.devland.is/${key}/bff/callbacks`, prod: `https://island.is/${key}/bff/callbacks`, }, - BFF_PROXY_API_ENDPOINT: { - local: 'http://localhost:4444/api/graphql', - dev: 'https://featbff-beta.dev01.devland.is/api/graphql', - staging: 'https://beta.staging01.devland.is/api/graphql', - prod: 'https://island.is/api/graphql', - }, + BFF_PROXY_API_ENDPOINT: ref((h) => `http://${h.svc(services.api)}`), BFF_ALLOWED_EXTERNAL_API_URLS: { local: json(['http://localhost:3377/download/v1']), dev: json(['https://api.dev01.devland.is']), From ac566f7e46f113a1e836afd703540d1efa8619d9 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Mon, 21 Oct 2024 08:55:12 +0000 Subject: [PATCH 131/248] update charts --- infra/src/uber-charts/islandis.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/infra/src/uber-charts/islandis.ts b/infra/src/uber-charts/islandis.ts index e98ad75bf9e6..ef54b85791f9 100644 --- a/infra/src/uber-charts/islandis.ts +++ b/infra/src/uber-charts/islandis.ts @@ -82,8 +82,6 @@ const appSystemApi = appSystemApiSetup({ }) const appSystemApiWorker = appSystemApiWorkerSetup() -const bffAdminPortalService = bffAdminPortalServiceSetup() - const adminPortal = adminPortalSetup() const nameRegistryBackend = serviceNameRegistryBackendSetup() @@ -119,12 +117,13 @@ const api = apiSetup({ userNotificationService, }) const servicePortal = servicePortalSetup({ graphql: api }) -const appSystemForm = appSystemFormSetup({ api: api }) -const web = webSetup({ api: api }) +const bffAdminPortalService = bffAdminPortalServiceSetup({ api }) +const appSystemForm = appSystemFormSetup({ api }) +const web = webSetup({ api }) const searchIndexer = searchIndexerSetup() const contentfulEntryTagger = contentfulEntryTaggerSetup() const contentfulApps = contentfulAppsSetup() -const consultationPortal = consultationPortalSetup({ api: api }) +const consultationPortal = consultationPortalSetup({ api }) const xroadCollector = xroadCollectorSetup() From df27f323066c54dcf79283d4fdc99117f69fe76f Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Mon, 21 Oct 2024 10:46:09 +0000 Subject: [PATCH 132/248] add zed editor config to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index a83266ecb971..f5a4781602bf 100644 --- a/.gitignore +++ b/.gitignore @@ -98,3 +98,4 @@ apps/**/index.html .next .nx/ +.zed/ From 58f57e412f5ec7a457d801ff7db0e91e03957b2e Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Mon, 21 Oct 2024 10:50:52 +0000 Subject: [PATCH 133/248] Add support for mocks --- apps/portals/admin/project.json | 33 ++++++++++++++++++---- apps/portals/admin/src/app/App.tsx | 16 +++++++++-- libs/react-spa/bff/src/index.ts | 1 + libs/react-spa/bff/src/lib/BffProvider.tsx | 15 ++++++++-- libs/react-spa/bff/src/lib/bff.mocks.ts | 19 +++++++++++++ 5 files changed, 73 insertions(+), 11 deletions(-) create mode 100644 libs/react-spa/bff/src/lib/bff.mocks.ts diff --git a/apps/portals/admin/project.json b/apps/portals/admin/project.json index c6dee8b5d7df..0a346f4d2fdc 100644 --- a/apps/portals/admin/project.json +++ b/apps/portals/admin/project.json @@ -3,11 +3,15 @@ "$schema": "../../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "apps/portals/admin/src", "projectType": "application", - "tags": ["scope:portals-admin"], + "tags": [ + "scope:portals-admin" + ], "targets": { "build": { "executor": "@nx/webpack:webpack", - "outputs": ["{options.outputPath}"], + "outputs": [ + "{options.outputPath}" + ], "defaultConfiguration": "production", "options": { "compiler": "babel", @@ -22,7 +26,9 @@ "apps/portals/admin/src/mockServiceWorker.js", "apps/portals/admin/src/assets" ], - "styles": ["apps/portals/admin/src/styles.css"], + "styles": [ + "apps/portals/admin/src/styles.css" + ], "scripts": [], "webpackConfig": "apps/portals/admin/webpack.config.js" }, @@ -49,7 +55,9 @@ "node scripts/dockerfile-assets/bash/extract-environment.js apps/portals/admin/src" ] }, - "outputs": ["{workspaceRoot}/apps/portals/admin/src/index.html"] + "outputs": [ + "{workspaceRoot}/apps/portals/admin/src/index.html" + ] }, "serve": { "executor": "@nx/webpack:dev-server", @@ -75,7 +83,9 @@ }, "test": { "executor": "@nx/jest:jest", - "outputs": ["{workspaceRoot}/coverage/apps/portals/admin"], + "outputs": [ + "{workspaceRoot}/coverage/apps/portals/admin" + ], "options": { "jestConfig": "apps/portals/admin/jest.config.ts" } @@ -89,7 +99,9 @@ "dev-init": { "executor": "nx:run-commands", "options": { - "commands": ["yarn get-secrets portals-admin"], + "commands": [ + "yarn get-secrets portals-admin" + ], "parallel": false } }, @@ -111,6 +123,15 @@ ] } }, + "mockmode": { + "executor": "nx:run-commands", + "options": { + "commands": [ + "yarn nx run portals-admin:start-bff", + "API_MOCKS=true yarn start portals-admin" + ] + } + }, "docker-static": { "executor": "Intentionally left blank, only so this target is valid when using `nx show projects --with-target docker-static`" } diff --git a/apps/portals/admin/src/app/App.tsx b/apps/portals/admin/src/app/App.tsx index 0921c6a3c271..bfd895f61368 100644 --- a/apps/portals/admin/src/app/App.tsx +++ b/apps/portals/admin/src/app/App.tsx @@ -1,7 +1,7 @@ import { ApolloProvider } from '@apollo/client' import { LocaleProvider } from '@island.is/localization' import { ApplicationErrorBoundary, PortalRouter } from '@island.is/portals/core' -import { BffProvider } from '@island.is/react-spa/bff' +import { BffProvider, createMockedInitialState } from '@island.is/react-spa/bff' import { FeatureFlagProvider } from '@island.is/react/feature-flags' import { defaultLanguage } from '@island.is/shared/constants' import environment from '../environments/environment' @@ -9,12 +9,24 @@ import { client } from '../graphql' import { modules } from '../lib/modules' import { AdminPortalPaths } from '../lib/paths' import { createRoutes } from '../lib/routes' +import { adminPortalScopes } from '@island.is/auth/scopes' + +const isMockMode = process.env.API_MOCKS === 'true' + +const mockedInitialState = isMockMode + ? createMockedInitialState({ + scopes: adminPortalScopes, + }) + : undefined export const App = () => ( - + { const [showSessionExpiredScreen, setSessionExpiredScreen] = useState(false) const bffUrlGenerator = createBffUrlGenerator(applicationBasePath) - const [state, dispatch] = useReducer(reducer, initialState) + const [state, dispatch] = useReducer( + reducer, + mockedInitialState ?? initialState, + ) const { authState } = state const showErrorScreen = authState === 'error' @@ -172,7 +177,7 @@ export const BffProvider = ({ useEffectOnce(() => { const hasError = checkQueryStringError() - if (!hasError) { + if (!hasError && !isLoggedIn) { checkLogin() } }) @@ -186,6 +191,10 @@ export const BffProvider = ({ } const renderContent = () => { + if (mockedInitialState) { + return children + } + if (showErrorScreen) { return ( , +): LoggedInState => ({ + userInfo: { + profile: { + name: 'Mock', + locale: 'is', + nationalId: '0000000000', + ...user?.profile, + } as BffUser['profile'], + scopes: user?.scopes ?? [], + }, + authState: 'logged-in', + isAuthenticated: true, + error: null, +}) From 380bd01020ecb640ff7e3ca74cedcca6917e0961 Mon Sep 17 00:00:00 2001 From: andes-it Date: Mon, 21 Oct 2024 11:12:29 +0000 Subject: [PATCH 134/248] chore: nx format:write update dirty files --- apps/portals/admin/project.json | 24 ++++++------------------ 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/apps/portals/admin/project.json b/apps/portals/admin/project.json index 0a346f4d2fdc..5d6e0be3e50e 100644 --- a/apps/portals/admin/project.json +++ b/apps/portals/admin/project.json @@ -3,15 +3,11 @@ "$schema": "../../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "apps/portals/admin/src", "projectType": "application", - "tags": [ - "scope:portals-admin" - ], + "tags": ["scope:portals-admin"], "targets": { "build": { "executor": "@nx/webpack:webpack", - "outputs": [ - "{options.outputPath}" - ], + "outputs": ["{options.outputPath}"], "defaultConfiguration": "production", "options": { "compiler": "babel", @@ -26,9 +22,7 @@ "apps/portals/admin/src/mockServiceWorker.js", "apps/portals/admin/src/assets" ], - "styles": [ - "apps/portals/admin/src/styles.css" - ], + "styles": ["apps/portals/admin/src/styles.css"], "scripts": [], "webpackConfig": "apps/portals/admin/webpack.config.js" }, @@ -55,9 +49,7 @@ "node scripts/dockerfile-assets/bash/extract-environment.js apps/portals/admin/src" ] }, - "outputs": [ - "{workspaceRoot}/apps/portals/admin/src/index.html" - ] + "outputs": ["{workspaceRoot}/apps/portals/admin/src/index.html"] }, "serve": { "executor": "@nx/webpack:dev-server", @@ -83,9 +75,7 @@ }, "test": { "executor": "@nx/jest:jest", - "outputs": [ - "{workspaceRoot}/coverage/apps/portals/admin" - ], + "outputs": ["{workspaceRoot}/coverage/apps/portals/admin"], "options": { "jestConfig": "apps/portals/admin/jest.config.ts" } @@ -99,9 +89,7 @@ "dev-init": { "executor": "nx:run-commands", "options": { - "commands": [ - "yarn get-secrets portals-admin" - ], + "commands": ["yarn get-secrets portals-admin"], "parallel": false } }, From 952be5d056b1415f9b07c1bd3f720c05095f4163 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Mon, 21 Oct 2024 11:25:02 +0000 Subject: [PATCH 135/248] Fix portal env spec --- infra/src/dsl/portal-env.spec.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/infra/src/dsl/portal-env.spec.ts b/infra/src/dsl/portal-env.spec.ts index eeea9c81f079..72609d2f48ee 100644 --- a/infra/src/dsl/portal-env.spec.ts +++ b/infra/src/dsl/portal-env.spec.ts @@ -32,13 +32,17 @@ const Staging: EnvironmentConfig = { global: {}, } +const services = { + api: service('api'), +} + describe('BFF PortalEnv serialization', () => { const sut = service(serviceName) .namespace(clientName) .image(bffName) .redis() .serviceAccount(bffName) - .env(createPortalEnv(bffType)) + .env(createPortalEnv(bffType, services)) .secrets({ BFF_TOKEN_SECRET_BASE64: `/k8s/${bffName}/${clientName}/BFF_TOKEN_SECRET_BASE64`, IDENTITY_SERVER_CLIENT_SECRET: `/k8s/${bffName}/${clientName}/IDENTITY_SERVER_CLIENT_SECRET`, @@ -152,8 +156,7 @@ describe('BFF PortalEnv serialization', () => { BFF_CLIENT_BASE_URL: 'https://featbff-beta.dev01.devland.is', BFF_LOGOUT_REDIRECT_URI: 'https://featbff-beta.dev01.devland.is', BFF_CALLBACKS_BASE_PATH: `https://featbff-beta.dev01.devland.is/${bffType}/bff/callbacks`, - BFF_PROXY_API_ENDPOINT: - 'https://featbff-beta.dev01.devland.is/api/graphql', + BFF_PROXY_API_ENDPOINT: 'http://web-api.islandis.svc.cluster.local', BFF_ALLOWED_EXTERNAL_API_URLS: json(['https://api.dev01.devland.is']), BFF_CACHE_USER_PROFILE_TTL_MS: ( ONE_HOUR_IN_MS - FIVE_SECONDS_IN_MS From ffbbc1ce1f26d9295e7e6ecdf476859d2a3fa0f6 Mon Sep 17 00:00:00 2001 From: andes-it Date: Mon, 21 Oct 2024 11:27:06 +0000 Subject: [PATCH 136/248] chore: charts update dirty files --- charts/islandis/values.dev.yaml | 2 +- charts/islandis/values.prod.yaml | 2 +- charts/islandis/values.staging.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/charts/islandis/values.dev.yaml b/charts/islandis/values.dev.yaml index 9d5ff229e32c..636892555120 100644 --- a/charts/islandis/values.dev.yaml +++ b/charts/islandis/values.dev.yaml @@ -2290,7 +2290,7 @@ services-bff-portals-admin: BFF_LOGOUT_REDIRECT_URI: 'https://featbff-beta.dev01.devland.is' BFF_NAME: 'stjornbord' BFF_PAR_SUPPORT_ENABLED: 'false' - BFF_PROXY_API_ENDPOINT: 'https://featbff-beta.dev01.devland.is/api/graphql' + BFF_PROXY_API_ENDPOINT: 'http://web-api.islandis.svc.cluster.local' IDENTITY_SERVER_CLIENT_ID: '@admin.island.is/bff-stjornbord' IDENTITY_SERVER_CLIENT_SCOPES: '["@admin.island.is/delegations","@admin.island.is/ads","@admin.island.is/regulations","@admin.island.is/regulations:manage","@admin.island.is/icelandic-names-registry","@admin.island.is/application-system:admin","@admin.island.is/application-system:institution","@admin.island.is/document-provider","@admin.island.is/auth","@admin.island.is/auth:admin","@admin.island.is/petitions","@admin.island.is/service-desk","@admin.island.is/ads:explicit","@admin.island.is/signature-collection:manage","@admin.island.is/signature-collection:process","@admin.island.is/form-system","@admin.island.is/form-system:admin","@admin.island.is/delegation-system","@admin.island.is/delegation-system:admin"]' IDENTITY_SERVER_ISSUER_URL: 'https://identity-server.dev01.devland.is' diff --git a/charts/islandis/values.prod.yaml b/charts/islandis/values.prod.yaml index 3d2173aaab6e..89ae071711fe 100644 --- a/charts/islandis/values.prod.yaml +++ b/charts/islandis/values.prod.yaml @@ -2160,7 +2160,7 @@ services-bff-portals-admin: BFF_LOGOUT_REDIRECT_URI: 'https://island.is' BFF_NAME: 'stjornbord' BFF_PAR_SUPPORT_ENABLED: 'false' - BFF_PROXY_API_ENDPOINT: 'https://island.is/api/graphql' + BFF_PROXY_API_ENDPOINT: 'http://web-api.islandis.svc.cluster.local' IDENTITY_SERVER_CLIENT_ID: '@admin.island.is/bff-stjornbord' IDENTITY_SERVER_CLIENT_SCOPES: '["@admin.island.is/delegations","@admin.island.is/ads","@admin.island.is/regulations","@admin.island.is/regulations:manage","@admin.island.is/icelandic-names-registry","@admin.island.is/application-system:admin","@admin.island.is/application-system:institution","@admin.island.is/document-provider","@admin.island.is/auth","@admin.island.is/auth:admin","@admin.island.is/petitions","@admin.island.is/service-desk","@admin.island.is/ads:explicit","@admin.island.is/signature-collection:manage","@admin.island.is/signature-collection:process","@admin.island.is/form-system","@admin.island.is/form-system:admin","@admin.island.is/delegation-system","@admin.island.is/delegation-system:admin"]' IDENTITY_SERVER_ISSUER_URL: 'https://innskra.island.is' diff --git a/charts/islandis/values.staging.yaml b/charts/islandis/values.staging.yaml index 737f7f5747b9..a4f8a226ca4d 100644 --- a/charts/islandis/values.staging.yaml +++ b/charts/islandis/values.staging.yaml @@ -2028,7 +2028,7 @@ services-bff-portals-admin: BFF_LOGOUT_REDIRECT_URI: 'https://beta.staging01.devland.is' BFF_NAME: 'stjornbord' BFF_PAR_SUPPORT_ENABLED: 'false' - BFF_PROXY_API_ENDPOINT: 'https://beta.staging01.devland.is/api/graphql' + BFF_PROXY_API_ENDPOINT: 'http://web-api.islandis.svc.cluster.local' IDENTITY_SERVER_CLIENT_ID: '@admin.island.is/bff-stjornbord' IDENTITY_SERVER_CLIENT_SCOPES: '["@admin.island.is/delegations","@admin.island.is/ads","@admin.island.is/regulations","@admin.island.is/regulations:manage","@admin.island.is/icelandic-names-registry","@admin.island.is/application-system:admin","@admin.island.is/application-system:institution","@admin.island.is/document-provider","@admin.island.is/auth","@admin.island.is/auth:admin","@admin.island.is/petitions","@admin.island.is/service-desk","@admin.island.is/ads:explicit","@admin.island.is/signature-collection:manage","@admin.island.is/signature-collection:process","@admin.island.is/form-system","@admin.island.is/form-system:admin","@admin.island.is/delegation-system","@admin.island.is/delegation-system:admin"]' IDENTITY_SERVER_ISSUER_URL: 'https://identity-server.staging01.devland.is' From 75af3461bae369595ba6f6d65a180e4496838032 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Mon, 21 Oct 2024 20:23:55 +0000 Subject: [PATCH 137/248] Update mocking server logic for portals --- apps/portals/admin/src/app/App.tsx | 17 ++++++----------- apps/portals/admin/src/main.tsx | 5 ++++- libs/portals/core/src/index.ts | 3 +++ libs/portals/core/src/mocks/index.ts | 14 ++++++++++++++ libs/portals/core/src/mocks/isMockMode.ts | 1 + .../core/src/mocks/mockedInitialState.ts | 9 +++++++++ libs/shared/mocking/src/msw/startMocking.ts | 19 +++++++++++++++++-- tsconfig.base.json | 2 ++ 8 files changed, 56 insertions(+), 14 deletions(-) create mode 100644 libs/portals/core/src/mocks/index.ts create mode 100644 libs/portals/core/src/mocks/isMockMode.ts create mode 100644 libs/portals/core/src/mocks/mockedInitialState.ts diff --git a/apps/portals/admin/src/app/App.tsx b/apps/portals/admin/src/app/App.tsx index bfd895f61368..304526fe3138 100644 --- a/apps/portals/admin/src/app/App.tsx +++ b/apps/portals/admin/src/app/App.tsx @@ -1,7 +1,11 @@ import { ApolloProvider } from '@apollo/client' import { LocaleProvider } from '@island.is/localization' -import { ApplicationErrorBoundary, PortalRouter } from '@island.is/portals/core' -import { BffProvider, createMockedInitialState } from '@island.is/react-spa/bff' +import { + ApplicationErrorBoundary, + PortalRouter, + mockedInitialState, +} from '@island.is/portals/core' +import { BffProvider } from '@island.is/react-spa/bff' import { FeatureFlagProvider } from '@island.is/react/feature-flags' import { defaultLanguage } from '@island.is/shared/constants' import environment from '../environments/environment' @@ -9,15 +13,6 @@ import { client } from '../graphql' import { modules } from '../lib/modules' import { AdminPortalPaths } from '../lib/paths' import { createRoutes } from '../lib/routes' -import { adminPortalScopes } from '@island.is/auth/scopes' - -const isMockMode = process.env.API_MOCKS === 'true' - -const mockedInitialState = isMockMode - ? createMockedInitialState({ - scopes: adminPortalScopes, - }) - : undefined export const App = () => ( diff --git a/apps/portals/admin/src/main.tsx b/apps/portals/admin/src/main.tsx index 68c71119e767..332fe52ae10f 100644 --- a/apps/portals/admin/src/main.tsx +++ b/apps/portals/admin/src/main.tsx @@ -1,4 +1,5 @@ -import '@island.is/api/mocks' +import { setupMocking } from '@island.is/portals/core' + import { userMonitoring } from '@island.is/user-monitoring' import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' @@ -8,6 +9,8 @@ import { isRunningOnEnvironment } from '@island.is/shared/utils' import environment from './environments/environment' import { App } from './app/App' +setupMocking() + if (!isRunningOnEnvironment('local')) { userMonitoring.initDdRum({ service: 'admin-portal', diff --git a/libs/portals/core/src/index.ts b/libs/portals/core/src/index.ts index 398eba645449..228aa2fa7539 100644 --- a/libs/portals/core/src/index.ts +++ b/libs/portals/core/src/index.ts @@ -1,3 +1,6 @@ +// mocks +export * from './mocks' + // libs export * from './lib/paths' export * from './lib/messages' diff --git a/libs/portals/core/src/mocks/index.ts b/libs/portals/core/src/mocks/index.ts new file mode 100644 index 000000000000..62df2140f83b --- /dev/null +++ b/libs/portals/core/src/mocks/index.ts @@ -0,0 +1,14 @@ +import { resolvers } from '@island.is/api/mocks/resolvers' +import { schema } from '@island.is/api/mocks/schema' +import { createGraphqlHandler, startMocking } from '@island.is/shared/mocking' +import { isMockMode } from './isMockMode' +import { mockedInitialState } from './mockedInitialState' + +const setupMocking = () => { + if (isMockMode) { + startMocking([ + createGraphqlHandler({ resolvers, schema, mask: '*/bff/api/graphql' }), + ]) + } +} +export { isMockMode, mockedInitialState, setupMocking } diff --git a/libs/portals/core/src/mocks/isMockMode.ts b/libs/portals/core/src/mocks/isMockMode.ts new file mode 100644 index 000000000000..2d1618cb1f70 --- /dev/null +++ b/libs/portals/core/src/mocks/isMockMode.ts @@ -0,0 +1 @@ +export const isMockMode = process.env.API_MOCKS === 'true' diff --git a/libs/portals/core/src/mocks/mockedInitialState.ts b/libs/portals/core/src/mocks/mockedInitialState.ts new file mode 100644 index 000000000000..8b6cf27c640f --- /dev/null +++ b/libs/portals/core/src/mocks/mockedInitialState.ts @@ -0,0 +1,9 @@ +import { adminPortalScopes } from '@island.is/auth/scopes' +import { createMockedInitialState } from '@island.is/react-spa/bff' +import { isMockMode } from './index' + +export const mockedInitialState = isMockMode + ? createMockedInitialState({ + scopes: adminPortalScopes, + }) + : undefined diff --git a/libs/shared/mocking/src/msw/startMocking.ts b/libs/shared/mocking/src/msw/startMocking.ts index 27baa3ce6c20..576469f7f151 100644 --- a/libs/shared/mocking/src/msw/startMocking.ts +++ b/libs/shared/mocking/src/msw/startMocking.ts @@ -3,6 +3,19 @@ import { RequestHandler } from 'msw' // eslint-disable-next-line @typescript-eslint/no-explicit-any export declare type RequestHandlersList = RequestHandler[] +const allowedKeyPaths = ['stjornbord', 'minarsidur'] + +const extractUniqueKeyPath = (url: string) => { + try { + const parsedUrl = new URL(url) + const pathSegments = parsedUrl.pathname.split('/').filter(Boolean) + return pathSegments.length > 0 ? pathSegments[0] : null + } catch (error) { + // coop + return null + } +} + export const startMocking = (requestHandlers: RequestHandlersList) => { if (typeof window === 'undefined') { // https://github.com/webpack/webpack/issues/8826 @@ -15,10 +28,12 @@ export const startMocking = (requestHandlers: RequestHandlersList) => { // eslint-disable-next-line @typescript-eslint/no-var-requires const { setupWorker } = require('msw') const worker = setupWorker(...requestHandlers) - if (location.pathname.split('/')[1] === 'minarsidur') { + const keyPath = extractUniqueKeyPath(location.href) + + if (keyPath && allowedKeyPaths.includes(keyPath)) { worker.start({ serviceWorker: { - url: '/minarsidur/mockServiceWorker.js', + url: `/${keyPath}/mockServiceWorker.js`, }, }) } else { diff --git a/tsconfig.base.json b/tsconfig.base.json index 1d7accc38f8c..dae7ff5593cf 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -247,6 +247,8 @@ ], "@island.is/api/mocks": ["libs/api/mocks/src/index.ts"], "@island.is/api/mocks-react": ["libs/api/mocks/src/react.ts"], + "@island.is/api/mocks/resolvers": ["libs/api/mocks/src/resolvers.ts"], + "@island.is/api/mocks/schema": ["libs/api/mocks/src/schema.ts"], "@island.is/api/schema": ["libs/api/schema/src/index.ts"], "@island.is/application/api/core": [ "libs/application/api/core/src/index.ts" From 5fe7c6025dfd1af28a0951fcdde1c586c803d495 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Tue, 22 Oct 2024 10:46:37 +0000 Subject: [PATCH 138/248] update mock logic --- apps/portals/admin/project.json | 5 +---- apps/portals/admin/src/app/App.tsx | 11 +++++++++-- apps/portals/admin/src/main.tsx | 4 +--- libs/portals/core/src/mocks/index.ts | 15 +-------------- libs/portals/core/src/mocks/isMockMode.ts | 1 - libs/portals/core/src/mocks/mockedInitialState.ts | 9 --------- libs/shared/mocking/src/msw/startMocking.ts | 11 ++++++++--- tsconfig.base.json | 2 -- 8 files changed, 20 insertions(+), 38 deletions(-) delete mode 100644 libs/portals/core/src/mocks/isMockMode.ts delete mode 100644 libs/portals/core/src/mocks/mockedInitialState.ts diff --git a/apps/portals/admin/project.json b/apps/portals/admin/project.json index 5d6e0be3e50e..53dda7697770 100644 --- a/apps/portals/admin/project.json +++ b/apps/portals/admin/project.json @@ -114,10 +114,7 @@ "mockmode": { "executor": "nx:run-commands", "options": { - "commands": [ - "yarn nx run portals-admin:start-bff", - "API_MOCKS=true yarn start portals-admin" - ] + "commands": ["API_MOCKS=true yarn start portals-admin"] } }, "docker-static": { diff --git a/apps/portals/admin/src/app/App.tsx b/apps/portals/admin/src/app/App.tsx index 304526fe3138..1627e3b6aa17 100644 --- a/apps/portals/admin/src/app/App.tsx +++ b/apps/portals/admin/src/app/App.tsx @@ -3,9 +3,9 @@ import { LocaleProvider } from '@island.is/localization' import { ApplicationErrorBoundary, PortalRouter, - mockedInitialState, + isMockMode, } from '@island.is/portals/core' -import { BffProvider } from '@island.is/react-spa/bff' +import { BffProvider, createMockedInitialState } from '@island.is/react-spa/bff' import { FeatureFlagProvider } from '@island.is/react/feature-flags' import { defaultLanguage } from '@island.is/shared/constants' import environment from '../environments/environment' @@ -13,6 +13,13 @@ import { client } from '../graphql' import { modules } from '../lib/modules' import { AdminPortalPaths } from '../lib/paths' import { createRoutes } from '../lib/routes' +import { adminPortalScopes } from '@island.is/auth/scopes' + +const mockedInitialState = isMockMode + ? createMockedInitialState({ + scopes: adminPortalScopes, + }) + : undefined export const App = () => ( diff --git a/apps/portals/admin/src/main.tsx b/apps/portals/admin/src/main.tsx index 332fe52ae10f..4cca91a26a05 100644 --- a/apps/portals/admin/src/main.tsx +++ b/apps/portals/admin/src/main.tsx @@ -1,4 +1,4 @@ -import { setupMocking } from '@island.is/portals/core' +import '@island.is/api/mocks' import { userMonitoring } from '@island.is/user-monitoring' import { StrictMode } from 'react' @@ -9,8 +9,6 @@ import { isRunningOnEnvironment } from '@island.is/shared/utils' import environment from './environments/environment' import { App } from './app/App' -setupMocking() - if (!isRunningOnEnvironment('local')) { userMonitoring.initDdRum({ service: 'admin-portal', diff --git a/libs/portals/core/src/mocks/index.ts b/libs/portals/core/src/mocks/index.ts index 62df2140f83b..2d1618cb1f70 100644 --- a/libs/portals/core/src/mocks/index.ts +++ b/libs/portals/core/src/mocks/index.ts @@ -1,14 +1 @@ -import { resolvers } from '@island.is/api/mocks/resolvers' -import { schema } from '@island.is/api/mocks/schema' -import { createGraphqlHandler, startMocking } from '@island.is/shared/mocking' -import { isMockMode } from './isMockMode' -import { mockedInitialState } from './mockedInitialState' - -const setupMocking = () => { - if (isMockMode) { - startMocking([ - createGraphqlHandler({ resolvers, schema, mask: '*/bff/api/graphql' }), - ]) - } -} -export { isMockMode, mockedInitialState, setupMocking } +export const isMockMode = process.env.API_MOCKS === 'true' diff --git a/libs/portals/core/src/mocks/isMockMode.ts b/libs/portals/core/src/mocks/isMockMode.ts deleted file mode 100644 index 2d1618cb1f70..000000000000 --- a/libs/portals/core/src/mocks/isMockMode.ts +++ /dev/null @@ -1 +0,0 @@ -export const isMockMode = process.env.API_MOCKS === 'true' diff --git a/libs/portals/core/src/mocks/mockedInitialState.ts b/libs/portals/core/src/mocks/mockedInitialState.ts deleted file mode 100644 index 8b6cf27c640f..000000000000 --- a/libs/portals/core/src/mocks/mockedInitialState.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { adminPortalScopes } from '@island.is/auth/scopes' -import { createMockedInitialState } from '@island.is/react-spa/bff' -import { isMockMode } from './index' - -export const mockedInitialState = isMockMode - ? createMockedInitialState({ - scopes: adminPortalScopes, - }) - : undefined diff --git a/libs/shared/mocking/src/msw/startMocking.ts b/libs/shared/mocking/src/msw/startMocking.ts index 576469f7f151..9c9b225997bb 100644 --- a/libs/shared/mocking/src/msw/startMocking.ts +++ b/libs/shared/mocking/src/msw/startMocking.ts @@ -8,10 +8,13 @@ const allowedKeyPaths = ['stjornbord', 'minarsidur'] const extractUniqueKeyPath = (url: string) => { try { const parsedUrl = new URL(url) - const pathSegments = parsedUrl.pathname.split('/').filter(Boolean) + const pathSegments = parsedUrl.pathname + .replace(/\/$/, '') + .split('/') + .filter(Boolean) return pathSegments.length > 0 ? pathSegments[0] : null } catch (error) { - // coop + // noop return null } } @@ -31,9 +34,11 @@ export const startMocking = (requestHandlers: RequestHandlersList) => { const keyPath = extractUniqueKeyPath(location.href) if (keyPath && allowedKeyPaths.includes(keyPath)) { + const normalizedPath = keyPath.endsWith('/') ? keyPath : `${keyPath}/` + worker.start({ serviceWorker: { - url: `/${keyPath}/mockServiceWorker.js`, + url: `/${normalizedPath}mockServiceWorker.js`, }, }) } else { diff --git a/tsconfig.base.json b/tsconfig.base.json index dae7ff5593cf..1d7accc38f8c 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -247,8 +247,6 @@ ], "@island.is/api/mocks": ["libs/api/mocks/src/index.ts"], "@island.is/api/mocks-react": ["libs/api/mocks/src/react.ts"], - "@island.is/api/mocks/resolvers": ["libs/api/mocks/src/resolvers.ts"], - "@island.is/api/mocks/schema": ["libs/api/mocks/src/schema.ts"], "@island.is/api/schema": ["libs/api/schema/src/index.ts"], "@island.is/application/api/core": [ "libs/application/api/core/src/index.ts" From 1395ef887be36efdbbe91dea8880ef14d46cc737 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B3n=20Levy?= Date: Wed, 23 Oct 2024 11:43:04 +0000 Subject: [PATCH 139/248] fix: public envs (#16493) * fix: merge conflict * fix: improved zod schema generation * test: update portal-env test for service building * fix: generate feature deploy urls * fix: improve getEnvUrl func * feat: integrated bff to ServiceBuilder * fix: more abstraction to dsl * fix: simplify and cleanup * chore: remove unused file * chore: cleanup dupes * chore: nx format:write update dirty files * chore: more cleanup --------- Co-authored-by: andes-it --- .prettierignore | 2 +- apps/services/bff/infra/admin-portal.infra.ts | 27 +- .../bff/infra/utils/createPortalEnv.ts | 3 +- charts/islandis/values.dev.yaml | 8 +- infra/package.json | 5 +- infra/scripts/generate-nx-schema.ts | 66 ++++ infra/src/cli/cli.ts | 27 ++ infra/src/common/logging.ts | 25 ++ infra/src/common/nx-command.ts | 65 ++++ infra/src/dsl/bff.ts | 70 +++++ infra/src/dsl/dsl.ts | 14 +- infra/src/dsl/feature-values.spec.ts | 81 +++-- infra/src/dsl/portal-env.spec.ts | 51 +-- .../pre-process-service.ts | 1 - infra/src/dsl/types/input-types.ts | 18 +- .../dsl/value-files-generators/local-setup.ts | 247 +++++++-------- infra/src/generated/nx-project-schema.ts | 220 +++++++++++++ infra/src/types/index.ts | 1 + infra/src/types/nx-project.ts | 10 + infra/src/uber-charts/islandis.ts | 1 + infra/yarn.lock | 294 +++++++++++++++++- 21 files changed, 1038 insertions(+), 198 deletions(-) create mode 100644 infra/scripts/generate-nx-schema.ts create mode 100644 infra/src/common/logging.ts create mode 100644 infra/src/common/nx-command.ts create mode 100644 infra/src/dsl/bff.ts create mode 100644 infra/src/generated/nx-project-schema.ts create mode 100644 infra/src/types/index.ts create mode 100644 infra/src/types/nx-project.ts diff --git a/.prettierignore b/.prettierignore index 2619b48cd441..7d2cdb83591b 100644 --- a/.prettierignore +++ b/.prettierignore @@ -14,4 +14,4 @@ /infra/helm/ /.nx/cache /.nx/workspace-data -apps/web/public/assets/pdf.worker.min.mjs \ No newline at end of file +apps/web/public/assets/pdf.worker.min.mjs diff --git a/apps/services/bff/infra/admin-portal.infra.ts b/apps/services/bff/infra/admin-portal.infra.ts index 9dfb97f7f726..f4399700f16a 100644 --- a/apps/services/bff/infra/admin-portal.infra.ts +++ b/apps/services/bff/infra/admin-portal.infra.ts @@ -1,15 +1,12 @@ -import { ServiceBuilder, service } from '../../../../infra/src/dsl/dsl' -import { createPortalEnv } from './utils/createPortalEnv' +/* eslint-disable @nx/enforce-module-boundaries */ +import { ServiceBuilder, service, json } from '../../../../infra/src/dsl/dsl' +import { BffInfraServices } from '../../../../infra/src/dsl/types/input-types' const bffName = 'services-bff' const clientName = 'portals-admin' const serviceName = `${bffName}-${clientName}` const key = 'stjornbord' -export type BffInfraServices = { - api: ServiceBuilder<'api'> -} - export const serviceSetup = ( services: BffInfraServices, ): ServiceBuilder => @@ -18,12 +15,18 @@ export const serviceSetup = ( .image(bffName) .redis() .serviceAccount(bffName) - .env(createPortalEnv(key, services)) - .secrets({ - // The secret should be a valid 32-byte base64 key. - // Generate key example: `openssl rand -base64 32` - BFF_TOKEN_SECRET_BASE64: `/k8s/${bffName}/${clientName}/BFF_TOKEN_SECRET_BASE64`, - IDENTITY_SERVER_CLIENT_SECRET: `/k8s/${bffName}/${clientName}/IDENTITY_SERVER_CLIENT_SECRET`, + .env({ + BFF_ALLOWED_EXTERNAL_API_URLS: { + local: json(['http://localhost:3377/download/v1']), + dev: json(['https://api.dev01.devland.is']), + staging: json(['https://api.staging01.devland.is']), + prod: json(['https://api.island.is']), + }, + }) + .bff({ + key: 'stjornbord', + clientName, + services, }) .readiness(`/${key}/bff/health/check`) .liveness(`/${key}/bff/liveness`) diff --git a/apps/services/bff/infra/utils/createPortalEnv.ts b/apps/services/bff/infra/utils/createPortalEnv.ts index acf37c5f2413..b3b2ad0a8a9e 100644 --- a/apps/services/bff/infra/utils/createPortalEnv.ts +++ b/apps/services/bff/infra/utils/createPortalEnv.ts @@ -1,11 +1,12 @@ /* eslint-disable @nx/enforce-module-boundaries */ +// FIXME: this file can be removed since the DSL is handling this now import { json, ref } from '../../../../../infra/src/dsl/dsl' import { adminPortalScopes, servicePortalScopes, } from '../../../../../libs/auth/scopes/src/index' import { FIVE_SECONDS_IN_MS } from '../../src/app/constants/time' -import { BffInfraServices } from '../admin-portal.infra' +import { BffInfraServices } from '../../../../../infra/src/dsl/types/input-types' const ONE_HOUR_IN_MS = 60 * 60 * 1000 const ONE_WEEK_IN_MS = ONE_HOUR_IN_MS * 24 * 7 diff --git a/charts/islandis/values.dev.yaml b/charts/islandis/values.dev.yaml index 636892555120..53bae0add330 100644 --- a/charts/islandis/values.dev.yaml +++ b/charts/islandis/values.dev.yaml @@ -2281,13 +2281,13 @@ services-bff-portals-admin: enabled: true env: BFF_ALLOWED_EXTERNAL_API_URLS: '["https://api.dev01.devland.is"]' - BFF_ALLOWED_REDIRECT_URIS: '["https://featbff-beta.dev01.devland.is"]' + BFF_ALLOWED_REDIRECT_URIS: '["https://beta.dev01.devland.is"]' BFF_CACHE_USER_PROFILE_TTL_MS: '3595000' - BFF_CALLBACKS_BASE_PATH: 'https://featbff-beta.dev01.devland.is/stjornbord/bff/callbacks' - BFF_CLIENT_BASE_URL: 'https://featbff-beta.dev01.devland.is' + BFF_CALLBACKS_BASE_PATH: 'https://beta.dev01.devland.is/stjornbord/bff/callbacks' + BFF_CLIENT_BASE_URL: 'https://beta.dev01.devland.is' BFF_CLIENT_KEY_PATH: '/stjornbord' BFF_LOGIN_ATTEMPT_TTL_MS: '604800000' - BFF_LOGOUT_REDIRECT_URI: 'https://featbff-beta.dev01.devland.is' + BFF_LOGOUT_REDIRECT_URI: 'https://beta.dev01.devland.is' BFF_NAME: 'stjornbord' BFF_PAR_SUPPORT_ENABLED: 'false' BFF_PROXY_API_ENDPOINT: 'http://web-api.islandis.svc.cluster.local' diff --git a/infra/package.json b/infra/package.json index db86f28487b8..7e28e370691d 100644 --- a/infra/package.json +++ b/infra/package.json @@ -7,7 +7,8 @@ "charts": "node -r esbuild-register src/cli/generate-chart-values.ts", "update": "yarn update:packagejson", "update:packagejson": "node -r esbuild-register scripts/update-package-json.ts", - "cli": "node -r esbuild-register src/cli/cli.ts" + "cli": "node -r esbuild-register src/cli/cli.ts", + "generate-nx-schema": "node -r esbuild-register ./scripts/generate-nx-schema.ts" }, "license": "MIT", "devDependencies": { @@ -30,6 +31,8 @@ "aws-sdk": "^2.1003.0", "glob": "10.3.3", "js-yaml": "4.0.0", + "json-refs": "3.0.15", + "json-schema-to-zod": "2.4.1", "lodash": "4.17.21", "yargs": "17.7.2" }, diff --git a/infra/scripts/generate-nx-schema.ts b/infra/scripts/generate-nx-schema.ts new file mode 100644 index 000000000000..26a21515110d --- /dev/null +++ b/infra/scripts/generate-nx-schema.ts @@ -0,0 +1,66 @@ +import { writeFileSync } from 'fs' +import { join } from 'path' +import fetch from 'node-fetch' +import jsonSchemaToZod, { Options } from 'json-schema-to-zod' + +const nxVersion = process.argv[2] || 'master' + +const schemaUrl = `https://raw.githubusercontent.com/nrwl/nx/refs/heads/${nxVersion}/packages/nx/schemas/project-schema.json` +const outputFilePath = join(__dirname, '../src/generated/nx-project-schema.ts') + +const downloadSchema = async (): Promise> => { + try { + const response = await fetch(schemaUrl) + if (!response.ok) { + throw new Error(`Failed to fetch schema: ${response.statusText}`) + } + const schemaData = await response.json() + return schemaData + } catch (error) { + console.error('Error downloading schema:', error) + throw error + } +} + +const transformSchemaToZod = async ( + jsonSchema: Record, +): Promise => { + try { + const options: Options = { + name: 'nxProjectSchema', + module: 'esm', + depth: 0, + type: true, + } + + const code = jsonSchemaToZod(jsonSchema, options) + + return code + } catch (error) { + console.error('Error transforming schema to Zod:', error) + throw error + } +} + +const writeSchemaToFile = (zodSchema: string): void => { + const fileContent = `// This file is auto-generated. Do not edit directly.\n\n${zodSchema}` + writeFileSync(outputFilePath, fileContent, { encoding: 'utf-8' }) + console.log(`Zod schema has been written to ${outputFilePath}`) +} + +const main = async (): Promise => { + try { + console.log(`Downloading schema for NX version: ${nxVersion}...`) + const jsonSchema = await downloadSchema() + + console.log('Transforming schema to Zod...') + const zodSchema = await transformSchemaToZod(jsonSchema) + + console.log('Writing Zod schema to file...') + writeSchemaToFile(zodSchema) + } catch (error) { + console.error('An error occurred:', error) + } +} + +main() diff --git a/infra/src/cli/cli.ts b/infra/src/cli/cli.ts index bfce0358ae0a..93a089ab1384 100644 --- a/infra/src/cli/cli.ts +++ b/infra/src/cli/cli.ts @@ -9,6 +9,33 @@ import { renderLocalServices, runLocalServices } from './render-local-mocks' const cli = yargs(process.argv.slice(2)) .scriptName('yarn cli') + .command( + 'nx ', + 'Run an NX command from the monorepo', + (yargs) => { + return yargs.positional('nxCommand', { + describe: 'The NX command to run (e.g., run my-app:build)', + type: 'string', + array: true, + }) + }, + async (argv) => { + const commandArr = argv.nxCommand as string[] + const command = commandArr.join(' ') + + try { + const result = await nxCommand({ command }) + if (result.stdout) { + console.log(result.stdout) // Raw output, e.g., JSON + } + if (result.stderr) { + logger.error(`NX Warning/Error:\n${result.stderr}`) + } + } catch (error) { + logger.error(`Error running NX command: ${error}`) + } + }, + ) .command( 'render-env', 'Render a chart for environment', diff --git a/infra/src/common/logging.ts b/infra/src/common/logging.ts new file mode 100644 index 000000000000..6b4d1ec1b776 --- /dev/null +++ b/infra/src/common/logging.ts @@ -0,0 +1,25 @@ +import winston from 'winston' + +const logLevelEnv = process.env.LOG_LEVEL?.toLowerCase() || 'info' +const logger = winston.createLogger({ + level: logLevelEnv, + format: winston.format.combine( + winston.format.colorize(), + winston.format.printf(({ level, message, timestamp, ...meta }) => { + const logMessage = + typeof message === 'object' ? JSON.stringify(message, null, 2) : message + // Stringify meta if it's an object and not empty + const metaString = + meta && Object.keys(meta).length ? JSON.stringify(meta, null, 2) : '' + + return `[${level}]: ${logMessage} ${metaString}` + }), + ), + transports: [ + new winston.transports.Console({ + level: logLevelEnv, + }), + ], +}) + +export { logger } diff --git a/infra/src/common/nx-command.ts b/infra/src/common/nx-command.ts new file mode 100644 index 000000000000..791ff6a9edfc --- /dev/null +++ b/infra/src/common/nx-command.ts @@ -0,0 +1,65 @@ +import { exec } from 'child_process' +import { promisify } from 'util' +import { join } from 'path' +import { rootDir } from '../dsl/consts' +import { z } from 'zod' // Import Zod for runtime validation + +const execPromise = promisify(exec) + +export const nxCommand = async (options: { + command: string + parseJson?: boolean + schema?: z.ZodSchema // Accept any Zod schema for runtime validation +}): Promise => { + const { command, parseJson = false, schema } = options + + try { + const { stdout, stderr } = await execPromise(`yarn nx ${command}`, { + cwd: rootDir, + }) + + if (stderr) { + console.warn(stderr) + } + + // If parseJson is true, validate the JSON output using the provided schema + if (parseJson && schema) { + return safelyParseAndValidateJson(stdout, schema) + } + + // If not parsing JSON, return raw stdout + return stdout as unknown as T + } catch (error) { + handleNxCommandError(error) + throw error + } +} + +/** + * Safely parses JSON and validates it using the provided Zod schema. + */ +const safelyParseAndValidateJson = ( + jsonString: string, + schema: z.ZodSchema, +): T => { + try { + const parsedJson = JSON.parse(jsonString) + return schema.parse(parsedJson) // Validate with the provided schema + } catch (error) { + console.error(`Failed to parse/validate JSON from NX output: ${error}`) + throw new Error( + 'Invalid JSON format or validation error in NX command output.', + ) + } +} + +/** + * Handles errors thrown during the execution of the NX command. + */ +const handleNxCommandError = (error: unknown): void => { + if (error instanceof Error) { + console.error(`Failed to run NX command: ${error.message}`) + } else { + console.error('An unknown error occurred while running the NX command.') + } +} diff --git a/infra/src/dsl/bff.ts b/infra/src/dsl/bff.ts new file mode 100644 index 000000000000..37c510ec82ac --- /dev/null +++ b/infra/src/dsl/bff.ts @@ -0,0 +1,70 @@ +import { json, ref } from './dsl' +import { BffInfo, PortalKeys, Context } from './types/input-types' + +import { + adminPortalScopes, + servicePortalScopes, +} from '../../../libs/auth/scopes/src/index' + +export const getScopes = (key: PortalKeys) => { + switch (key) { + case 'minarsidur': + return servicePortalScopes + case 'stjornbord': + return adminPortalScopes + default: + throw new Error('Invalid BFF client') + } +} + +export const bffConfig = (info: BffInfo) => { + const { key, services, clientName } = info + + const getBaseUrl = (ctx: Context) => + ctx.featureDeploymentName + ? `${ctx.featureDeploymentName}.${ctx.env.domain}` + : ctx.env.type === 'prod' + ? ctx.env.domain + : `beta.${ctx.env.domain}` + + return { + env: { + IDENTITY_SERVER_CLIENT_SCOPES: json(getScopes(key)), + IDENTITY_SERVER_CLIENT_ID: `@admin.island.is/bff-${key}`, + IDENTITY_SERVER_ISSUER_URL: { + dev: 'https://identity-server.dev01.devland.is', + staging: 'https://identity-server.staging01.devland.is', + prod: 'https://innskra.island.is', + }, + BFF_NAME: { + local: key, + dev: key, + staging: key, + prod: key, + }, + BFF_CLIENT_KEY_PATH: `/${key}`, + BFF_PAR_SUPPORT_ENABLED: 'false', + BFF_CLIENT_BASE_URL: { + dev: ref((h) => h.svc(`https://${getBaseUrl(h)}`)), + staging: ref((h) => h.svc(`https://${getBaseUrl(h)}`)), + prod: 'https://island.is', + local: ref((h) => h.svc('http://localhost:4200')), + }, + BFF_ALLOWED_REDIRECT_URIS: ref((ctx) => + json([`https://${getBaseUrl(ctx)}`]), + ), + // BFF_CLIENT_BASE_URL: ref((ctx) => `https://${getBaseUrl(ctx)}`), + BFF_LOGOUT_REDIRECT_URI: ref((ctx) => `https://${getBaseUrl(ctx)}`), + BFF_CALLBACKS_BASE_PATH: ref( + (ctx) => `https://${getBaseUrl(ctx)}/${key}/bff/callbacks`, + ), + BFF_PROXY_API_ENDPOINT: ref((ctx) => `http://${ctx.svc(services.api)}`), + BFF_CACHE_USER_PROFILE_TTL_MS: (60 * 60 * 1000 - 5000).toString(), + BFF_LOGIN_ATTEMPT_TTL_MS: (60 * 60 * 1000 * 24 * 7).toString(), + }, + secrets: { + BFF_TOKEN_SECRET_BASE64: `/k8s/services-bff/${clientName}/BFF_TOKEN_SECRET_BASE64`, + IDENTITY_SERVER_CLIENT_SECRET: `/k8s/services-bff/${clientName}/IDENTITY_SERVER_CLIENT_SECRET`, + }, + } +} diff --git a/infra/src/dsl/dsl.ts b/infra/src/dsl/dsl.ts index 3317e32736e3..98856eff3f0e 100644 --- a/infra/src/dsl/dsl.ts +++ b/infra/src/dsl/dsl.ts @@ -5,7 +5,6 @@ import { ExtraValues, Features, HealthProbe, - Ingress, InitContainers, MountedFile, PersistentVolumeClaim, @@ -18,7 +17,9 @@ import { XroadConfig, PodDisruptionBudget, IngressMapping, + BffInfo, } from './types/input-types' +import { bffConfig } from './bff' import { logger } from '../logging' import { COMMON_SECRETS } from './consts' @@ -184,6 +185,15 @@ export class ServiceBuilder { return this } + bff(config: BffInfo) { + this.serviceDef.env = { ...bffConfig(config).env, ...this.serviceDef.env } + this.serviceDef.secrets = { + ...bffConfig(config).secrets, + ...this.serviceDef.secrets, + } + return this + } + /** * X-Road configuration blocks to inject to the container. Types of XroadConfig can contain environment variables and/or secrets that define how to contact an external service through X-Road. * @param ...configs - X-road configs @@ -337,7 +347,7 @@ export class ServiceBuilder { /** * Initializes a container with optional parameters and checks for unique container names. * If the 'withDB' flag is set or if the 'postgres' property is present in the input object, - * it grants database access to the container. + * it grants database access toethe container. * Maps to a Pod specification for an [initContainer](https://kubernetes.io/docs/concepts/workloads/pods/init-containers/). * * @param ic - The initial container configuration. diff --git a/infra/src/dsl/feature-values.spec.ts b/infra/src/dsl/feature-values.spec.ts index 9ee13cb2f03d..2f3b5f8bd8c1 100644 --- a/infra/src/dsl/feature-values.spec.ts +++ b/infra/src/dsl/feature-values.spec.ts @@ -1,4 +1,4 @@ -import { ServiceBuilder, ref, service } from './dsl' +import { ServiceBuilder, ref, service, json } from './dsl' import { Kubernetes } from './kubernetes-runtime' import { EnvironmentConfig } from './types/charts' import { getFeatureAffectedServices } from './feature-deployments' @@ -6,12 +6,13 @@ import { HelmValueFile } from './types/output-types' import { getHelmValueFile } from './value-files-generators/helm-value-file' import { renderers } from './upstream-dependencies' import { generateOutput } from './processing/rendering-pipeline' +import { getScopes } from './bff' const getEnvironment = ( options: EnvironmentConfig = { auroraHost: 'a', redisHost: 'b', - domain: 'staging01.devland.is', + domain: 'dev01.devland.is', type: 'dev', featuresOn: [], defaultMaxReplicas: 3, @@ -49,8 +50,7 @@ describe('Feature-deployment support', () => { const dependencyA = service('service-a').namespace('A') const dependencyB = service('service-b') const dependencyC = service('service-c') - - const apiService = service('graphql') + const apiService = service('api') .env({ A: ref((h) => `${h.svc(dependencyA)}`), B: ref( @@ -76,10 +76,25 @@ describe('Feature-deployment support', () => { }) .db() + const bff = service('services-bff-portals-admin') + .bff({ + key: 'stjornbord', + clientName: 'portals-admin', + services: { api: apiService }, + }) + .env({ + BFF_ALLOWED_EXTERNAL_API_URLS: { + local: json(['http://localhost:3377/download/v1']), + dev: json(['https://api.dev01.devland.is']), + staging: json(['https://api.staging01.devland.is']), + prod: json(['https://api.island.is']), + }, + }) + .redis() dev = getEnvironment() const services1 = await getFeatureAffectedServices( - [apiService, dependencyA, dependencyB], - [dependencyA, dependencyC], + [apiService, dependencyA, dependencyB, bff], + [dependencyA, dependencyC, bff], [dependencyC], dev, ) @@ -87,11 +102,11 @@ describe('Feature-deployment support', () => { }) it('dynamic service name generation', () => { - expect(values.services.graphql.env).toEqual({ + expect(values.services.api.env).toEqual({ A: 'web-service-a', B: 'feature-web-service-b.islandis.svc.cluster.local', - DB_USER: 'feature_feature_A_graphql', - DB_NAME: 'feature_feature_A_graphql', + DB_USER: 'feature_feature_A_api', + DB_NAME: 'feature_feature_A_api', DB_HOST: 'a', DB_REPLICAS_HOST: 'a', NODE_OPTIONS: '--max-old-space-size=230 -r dd-trace/init', @@ -100,8 +115,30 @@ describe('Feature-deployment support', () => { DB_EXTENSIONS: 'foo', }) }) + it('bff feature env urls', () => { + expect(values.services['services-bff-portals-admin'].env).toEqual({ + IDENTITY_SERVER_CLIENT_SCOPES: json(getScopes('stjornbord')), + IDENTITY_SERVER_CLIENT_ID: `@admin.island.is/bff-stjornbord`, + IDENTITY_SERVER_ISSUER_URL: 'https://identity-server.dev01.devland.is', + BFF_NAME: 'stjornbord', + BFF_CLIENT_KEY_PATH: `/stjornbord`, + BFF_PAR_SUPPORT_ENABLED: 'false', + BFF_ALLOWED_REDIRECT_URIS: json(['https://feature-A.dev01.devland.is']), + BFF_CLIENT_BASE_URL: 'https://feature-A.dev01.devland.is', + BFF_LOGOUT_REDIRECT_URI: 'https://feature-A.dev01.devland.is', + BFF_CALLBACKS_BASE_PATH: `https://feature-A.dev01.devland.is/stjornbord/bff/callbacks`, + BFF_PROXY_API_ENDPOINT: 'http://web-api', + BFF_ALLOWED_EXTERNAL_API_URLS: json(['https://api.dev01.devland.is']), + BFF_CACHE_USER_PROFILE_TTL_MS: '3595000', + BFF_LOGIN_ATTEMPT_TTL_MS: '604800000', + NODE_OPTIONS: '--max-old-space-size=230 -r dd-trace/init', + SERVERSIDE_FEATURES_ON: '', + LOG_LEVEL: 'info', + REDIS_URL_NODE_01: 'b', + }) + }) it('db extensions are set', () => { - expect(values.services.graphql.initContainer?.env).toEqual( + expect(values.services.api.initContainer?.env).toEqual( expect.objectContaining({ DB_EXTENSIONS: 'foo', }), @@ -109,27 +146,27 @@ describe('Feature-deployment support', () => { }) it('dynamic secrets path', () => { - expect(values.services.graphql.secrets).toHaveProperty('DB_PASS') - expect(values.services.graphql.secrets!.DB_PASS).toEqual( - '/k8s/feature-feature-A-graphql/DB_PASSWORD', + expect(values.services.api.secrets).toHaveProperty('DB_PASS') + expect(values.services.api.secrets!.DB_PASS).toEqual( + '/k8s/feature-feature-A-api/DB_PASSWORD', ) }) it('dynamic secrets path', () => { - expect(values.services.graphql.initContainer?.secrets).toHaveProperty( - 'DB_PASS', - ) - expect(values.services.graphql.initContainer?.secrets!.DB_PASS).toEqual( - '/k8s/feature-feature-A-graphql/DB_PASSWORD', + expect(values.services.api.initContainer?.secrets).toHaveProperty('DB_PASS') + expect(values.services.api.initContainer?.secrets!.DB_PASS).toEqual( + '/k8s/feature-feature-A-api/DB_PASSWORD', ) }) it('feature deployment namespaces', () => { expect(Object.keys(values.services).sort()).toEqual([ - 'graphql', + 'api', 'service-a', + 'services-bff-portals-admin', ]) - expect(values.services['graphql'].namespace).toEqual( + expect(values.services['api'].namespace).toEqual(`feature-${dev.feature}`) + expect(values.services['services-bff-portals-admin'].namespace).toEqual( `feature-${dev.feature}`, ) expect(values.services['service-a'].namespace).toEqual( @@ -138,7 +175,7 @@ describe('Feature-deployment support', () => { }) it('feature deployment ingress', () => { - expect(values.services.graphql.ingress).toEqual({ + expect(values.services.api.ingress).toEqual({ 'primary-alb': { annotations: { 'kubernetes.io/ingress.class': 'nginx-external-alb', @@ -146,7 +183,7 @@ describe('Feature-deployment support', () => { }, hosts: [ { - host: 'feature-A-a.staging01.devland.is', + host: 'feature-A-a.dev01.devland.is', paths: ['/'], }, ], diff --git a/infra/src/dsl/portal-env.spec.ts b/infra/src/dsl/portal-env.spec.ts index 72609d2f48ee..a022b6dc8425 100644 --- a/infra/src/dsl/portal-env.spec.ts +++ b/infra/src/dsl/portal-env.spec.ts @@ -1,4 +1,4 @@ -import { service } from './dsl' +import { ServiceBuilder, service } from './dsl' import { Kubernetes } from './kubernetes-runtime' import { SerializeSuccess, HelmService } from './types/output-types' import { EnvironmentConfig } from './types/charts' @@ -14,11 +14,11 @@ const ONE_HOUR_IN_MS = 60 * 60 * 1000 const ONE_WEEK_IN_MS = ONE_HOUR_IN_MS * 24 * 7 const bffName = 'services-bff' -const bffType = 'stjornbord' const clientName = 'portals-admin' const serviceName = `${bffName}-${clientName}` +const key = 'stjornbord' -const Staging: EnvironmentConfig = { +const Dev: EnvironmentConfig = { auroraHost: 'a', redisHost: 'b', domain: 'dev01.devland.is', @@ -37,20 +37,29 @@ const services = { } describe('BFF PortalEnv serialization', () => { + const services = { api: service('api') } const sut = service(serviceName) .namespace(clientName) .image(bffName) .redis() .serviceAccount(bffName) - .env(createPortalEnv(bffType, services)) - .secrets({ - BFF_TOKEN_SECRET_BASE64: `/k8s/${bffName}/${clientName}/BFF_TOKEN_SECRET_BASE64`, - IDENTITY_SERVER_CLIENT_SECRET: `/k8s/${bffName}/${clientName}/IDENTITY_SERVER_CLIENT_SECRET`, + .env({ + BFF_ALLOWED_EXTERNAL_API_URLS: { + local: json(['http://localhost:3377/download/v1']), + dev: json(['https://api.dev01.devland.is']), + staging: json(['https://api.staging01.devland.is']), + prod: json(['https://api.island.is']), + }, + }) + .bff({ + key: 'stjornbord', + clientName, + services, }) .command('node') .args('main.js') - .readiness('/health/check') - .liveness('/liveness') + .readiness(`/${key}/bff/health/check`) + .liveness(`/${key}/bff/liveness`) .replicaCount({ default: 2, min: 2, @@ -88,7 +97,7 @@ describe('BFF PortalEnv serialization', () => { 'nginx.ingress.kubernetes.io/proxy-buffer-size': '8k', }, }, - paths: [`/${bffType}/bff`], + paths: [`/${key}/bff`], }, }) let result: SerializeSuccess @@ -96,8 +105,8 @@ describe('BFF PortalEnv serialization', () => { result = (await generateOutputOne({ outputFormat: renderers.helm, service: sut, - runtime: new Kubernetes(Staging), - env: Staging, + runtime: new Kubernetes(Dev), + env: Dev, })) as SerializeSuccess }) @@ -144,18 +153,16 @@ describe('BFF PortalEnv serialization', () => { it('environment variables', () => { expect(result.serviceDef[0].env).toEqual({ IDENTITY_SERVER_CLIENT_SCOPES: json(adminPortalScopes), - IDENTITY_SERVER_CLIENT_ID: `@admin.island.is/bff-${bffType}`, + IDENTITY_SERVER_CLIENT_ID: `@admin.island.is/bff-${key}`, IDENTITY_SERVER_ISSUER_URL: 'https://identity-server.dev01.devland.is', // BFF BFF_NAME: 'stjornbord', - BFF_CLIENT_KEY_PATH: `/${bffType}`, + BFF_CLIENT_KEY_PATH: `/${key}`, BFF_PAR_SUPPORT_ENABLED: 'false', - BFF_ALLOWED_REDIRECT_URIS: json([ - 'https://featbff-beta.dev01.devland.is', - ]), - BFF_CLIENT_BASE_URL: 'https://featbff-beta.dev01.devland.is', - BFF_LOGOUT_REDIRECT_URI: 'https://featbff-beta.dev01.devland.is', - BFF_CALLBACKS_BASE_PATH: `https://featbff-beta.dev01.devland.is/${bffType}/bff/callbacks`, + BFF_ALLOWED_REDIRECT_URIS: json(['https://beta.dev01.devland.is']), + BFF_CLIENT_BASE_URL: 'https://beta.dev01.devland.is', + BFF_LOGOUT_REDIRECT_URI: 'https://beta.dev01.devland.is', + BFF_CALLBACKS_BASE_PATH: `https://beta.dev01.devland.is/${key}/bff/callbacks`, BFF_PROXY_API_ENDPOINT: 'http://web-api.islandis.svc.cluster.local', BFF_ALLOWED_EXTERNAL_API_URLS: json(['https://api.dev01.devland.is']), BFF_CACHE_USER_PROFILE_TTL_MS: ( @@ -217,8 +224,8 @@ describe('Env definition defaults', () => { result = (await generateOutputOne({ outputFormat: renderers.helm, service: sut, - runtime: new Kubernetes(Staging), - env: Staging, + runtime: new Kubernetes(Dev), + env: Dev, })) as SerializeSuccess }) it('replica max count', () => { diff --git a/infra/src/dsl/service-to-environment/pre-process-service.ts b/infra/src/dsl/service-to-environment/pre-process-service.ts index 46c78b6b58f3..1476f8880b7d 100644 --- a/infra/src/dsl/service-to-environment/pre-process-service.ts +++ b/infra/src/dsl/service-to-environment/pre-process-service.ts @@ -323,7 +323,6 @@ function addFeaturesConfig( secrets: v.secrets, } }) - return { envs: featureEnvs.reduce( (acc, feature) => ({ ...acc, ...feature.envs }), diff --git a/infra/src/dsl/types/input-types.ts b/infra/src/dsl/types/input-types.ts index 43232dd01562..5afc4616e0ae 100644 --- a/infra/src/dsl/types/input-types.ts +++ b/infra/src/dsl/types/input-types.ts @@ -16,6 +16,15 @@ export type ValueSource = string | ((e: Context) => string) export type ValueType = MissingSettingType | ValueSource // See https://kubernetes.io/docs/concepts/storage/persistent-volumes/#access-modes for more info export type AccessModes = 'ReadWrite' | 'ReadOnly' + +export type BffInfo = { + key: PortalKeys + clientName: string + services: BffInfraServices + env?: EnvironmentVariables + secrets?: Secrets +} + export type PostgresInfo = { host?: { [env in OpsEnv]: string @@ -48,7 +57,9 @@ export type HealthProbe = { timeoutSeconds: number } -export type Secrets = { [name: string]: string } +export type Secrets = { + [name: string]: string | ValueType +} export type EnvironmentVariableValue = | Optional< @@ -79,6 +90,11 @@ export type Feature = { export type Features = { [name in FeatureNames]: Feature } export type MountedFile = { filename: string; env: string } +export type PortalKeys = 'stjornbord' | 'minarsidur' + +export interface BffInfraServices { + api: ServiceBuilder | string +} export type ServiceDefinitionCore = { liveness: HealthProbe diff --git a/infra/src/dsl/value-files-generators/local-setup.ts b/infra/src/dsl/value-files-generators/local-setup.ts index 4073157cebe8..ecb09d8534be 100644 --- a/infra/src/dsl/value-files-generators/local-setup.ts +++ b/infra/src/dsl/value-files-generators/local-setup.ts @@ -5,50 +5,50 @@ import { } from '../types/output-types' import { Localhost } from '../localhost-runtime' import { shouldIncludeEnv } from '../../cli/render-env-vars' -import { readFile, writeFile } from 'fs/promises' -import { globSync } from 'glob' +import { writeFile } from 'fs/promises' import { join } from 'path' import { rootDir } from '../consts' -import { logger } from '../../common' +import { logger } from '../../common/logging' +import { nxCommand } from '../../common/nx-command' +import { z } from 'zod' +import { type ProjectInfo, nxProjectSchema } from '../../types/nx-project' -const mapServiceToNXname = async (serviceName: string) => { - const projectRootPath = join(__dirname, '..', '..', '..', '..') - const projects = globSync(['apps/*/project.json', 'apps/*/*/project.json'], { - cwd: projectRootPath, - }) +/** + * Maps a service name to its corresponding NX project name and path. + * Uses nxCommand to retrieve and validate the project metadata using the nxProjectSchema. + */ +export const mapServiceToNXname = async ( + serviceName: string, +): Promise => { + try { + if (serviceName.startsWith('services-bff-')) { + serviceName = 'services-bff' + } - // This is a hack to make sure we are running `services-bff` project with the desired infra config. - // We have multiple infra files under the `services-bff` project, e.g. `services-bff-admin-portal`, `services-bff-my-pages-portal`, etc. - // For the project to run correctly, we need to run the `services-bff` project. - if (serviceName.startsWith('services-bff-')) { - serviceName = 'services-bff' - } + const validatedProjectMeta = await nxCommand({ + command: `show project ${serviceName} --json --output-style static`, + parseJson: true, + schema: nxProjectSchema, // Pass the actual Zod schema for runtime validation + }) - const nxName = ( - await Promise.all( - projects.map(async (path) => { - const project: { - name: string - targets: { [name: string]: any } - } = JSON.parse( - await readFile(join(projectRootPath, path), { - encoding: 'utf-8', - }), - ) - return typeof project.targets[`service-${serviceName}`] !== 'undefined' - ? project.name - : null - }), - ) - ).filter((name) => name !== null) as string[] + if (!validatedProjectMeta.name || !validatedProjectMeta.sourceRoot) { + throw new Error( + `Project metadata is missing required fields: name or sourceRoot.`, + ) + } - if (nxName.length > 1) - throw new Error( - `More then one NX projects found with service name ${serviceName} - ${nxName.join( - ',', - )}`, - ) - return nxName.length === 1 ? nxName[0] : serviceName + return { + serviceName: validatedProjectMeta.name, + projectPath: validatedProjectMeta.sourceRoot, + } + } catch (error) { + logger.error('Error in mapServiceToNXname:', error) + + if (error instanceof Error) { + throw new Error(`Unexpected error: ${error.message}`) + } + throw new Error('An unknown error occurred.') + } } /** @@ -70,8 +70,8 @@ export const getLocalrunValueFile = async ( ): Promise => { logger.debug('getLocalrunValueFile', { runtime, services }) - logger.debug('Process services', { services }) const dockerComposeServices = {} as Services + for (const [name, service] of Object.entries(services)) { const portConfig = runtime.ports[name] ? { PORT: runtime.ports[name].toString() } @@ -79,21 +79,23 @@ export const getLocalrunValueFile = async ( const serviceNXName = await mapServiceToNXname(name) logger.debug('Process service', { name, service, serviceNXName }) - dockerComposeServices[name] = { - env: Object.assign( - {}, - Object.entries(service.env) - .filter(shouldIncludeEnv) - .reduce((acc, [k, v]) => ({ ...acc, [k]: v }), {}), - { PROD_MODE: 'true' }, - portConfig, - ) as Record, - commands: [ - `cd "${rootDir}"`, - `. ./.env.${serviceNXName}`, // `source` is bashism - `echo "Starting ${name} in $PWD"`, - `yarn nx serve ${serviceNXName}`, - ], + + if (serviceNXName) { + dockerComposeServices[name] = { + env: { + ...Object.entries(service.env) + .filter(shouldIncludeEnv) + .reduce((acc, [k, v]) => ({ ...acc, [k]: v }), {}), + PROD_MODE: 'true', + ...portConfig, + }, + commands: [ + `cd "${rootDir}"`, + `. ./.env.${serviceNXName.serviceName}`, // `source` is bashism + `echo "Starting ${name} in $PWD"`, + `yarn nx serve ${serviceNXName.serviceName}`, + ], + } } } @@ -102,91 +104,83 @@ export const getLocalrunValueFile = async ( dockerComposeServices, [`${firstService}.env`]: dockerComposeServices[firstService]?.env, }) + await Promise.all( - Object.entries(dockerComposeServices).map( - async ([name, svc]: [string, LocalrunService]) => { - const serviceNXName = await mapServiceToNXname(name) - logger.debug(`Writing env to file for ${name}`, { name, serviceNXName }) - if (options.dryRun) return - await writeFile( - join(rootDir, `.env.${serviceNXName}`), - Object.entries(svc.env) - .filter(([name, value]) => shouldIncludeEnv(name) && !!value) - .map(([name, value]) => { - // Basic shell sanitation - const escapedValue = value - .replace(/'/g, "'\\''") - .replace(/[\n\r]/g, '') - const localizedValue = escapedValue - // .replace( - // /^(https?:\/\/)[^/]+(?=$|\/)/g, - // '$1localhost', - // ) - const exportedKeyValue = `export ${name}='${localizedValue}'` - logger.debug('Env rewrite debug', { - escapedValue, - localizedValue, - exportedKeyValue, - }) - - return exportedKeyValue - }) - .join('\n'), - { encoding: 'utf-8' }, - ) - }, - ), + Object.entries(dockerComposeServices).map(async ([name, svc]) => { + const result = await mapServiceToNXname(name) + if (result === null) { + throw new Error('No NX project found for the given service name.') + } + const { serviceName } = result + logger.debug(`Writing env to file for ${name}`, { name, serviceName }) + if (options.dryRun) return + await writeFile( + join(rootDir, `.env.${serviceName}`), + Object.entries(svc.env) + .filter(([key, value]) => shouldIncludeEnv(key) && !!value) + .map(([key, value]) => { + const escapedValue = value + .replace(/'/g, "'\\''") + .replace(/[\n\r]/g, '') + return `export ${key}='${escapedValue}'` + }) + .join('\n'), + { encoding: 'utf-8' }, + ) + }), ) + const mocksConfigs = Object.entries(runtime.mocks).reduce( - (acc, [name, target]) => { - return { - ports: [...acc.ports, runtime.ports[name]], - configs: [ - ...acc.configs, - { - protocol: 'http', - name: name, - port: runtime.ports[name], - stubs: [ - { - predicates: [{ equals: {} }], - responses: [ - { - proxy: { - to: target.replace('localhost', 'host.docker.internal'), - mode: 'proxyAlways', - predicateGenerators: [ - { - matches: { - method: true, - path: true, - query: true, - body: true, - }, + (acc, [name, target]) => ({ + ports: [...acc.ports, runtime.ports[name]], + configs: [ + ...acc.configs, + { + protocol: 'http', + name: name, + port: runtime.ports[name], + stubs: [ + { + predicates: [{ equals: {} }], + responses: [ + { + proxy: { + to: target.replace('localhost', 'host.docker.internal'), + mode: 'proxyAlways', + predicateGenerators: [ + { + matches: { + method: true, + path: true, + query: true, + body: true, }, - ], - }, + }, + ], }, - ], - }, - ], - }, - ], - } - }, + }, + ], + }, + ], + }, + ], + }), { ports: [] as number[], configs: [] as any[] }, ) + const defaultMountebankConfig = 'mountebank-imposter-config.json' logger.debug('Writing default mountebank config to file', { defaultMountebankConfig, mocksConfigs, }) - if (!options.dryRun) + + if (!options.dryRun) { await writeFile( defaultMountebankConfig, JSON.stringify({ imposters: mocksConfigs.configs }), { encoding: 'utf-8' }, ) + } const mocksObj = { containerer: 'docker', @@ -208,19 +202,16 @@ export const getLocalrunValueFile = async ( mocksObj.image, mocksObj.command, ] - const mocksStr = mocks.join(' ') + logger.debug(`Docker command for mocks:`, { mocks }) const renderedServices: Services = {} - logger.debug('Debugging dockerComposeServices', { - dockerComposeServices, - }) for (const [name, service] of Object.entries(dockerComposeServices)) { renderedServices[name] = { commands: service.commands, env: service.env } - logger.debug(`Docker command for ${name}:`, { command: service.commands }) } + return { services: renderedServices, - mocks: mocksStr, + mocks: mocks.join(' '), } } diff --git a/infra/src/generated/nx-project-schema.ts b/infra/src/generated/nx-project-schema.ts new file mode 100644 index 000000000000..bbd06e64aa60 --- /dev/null +++ b/infra/src/generated/nx-project-schema.ts @@ -0,0 +1,220 @@ +// This file is auto-generated. Do not edit directly. + +import { z } from 'zod' + +export const nxProjectSchema = z.object({ + name: z + .string() + .describe("Project's name. Optional if specified in workspace.json") + .optional(), + root: z + .string() + .describe("Project's location relative to the root of the workspace") + .optional(), + sourceRoot: z + .string() + .describe( + "The location of project's sources relative to the root of the workspace", + ) + .optional(), + projectType: z + .enum(['library', 'application']) + .describe('Type of project supported') + .optional(), + generators: z + .record(z.any()) + .describe('List of default values used by generators') + .optional(), + namedInputs: z + .record(z.any()) + .describe('Named inputs used by inputs defined in targets') + .optional(), + targets: z + .record( + z.object({ + executor: z + .string() + .describe('The function that Nx will invoke when you run this target') + .optional(), + options: z.record(z.any()).optional(), + outputs: z.array(z.string()).optional(), + defaultConfiguration: z + .string() + .describe( + 'The name of a configuration to use as the default if a configuration is not provided', + ) + .optional(), + configurations: z + .record(z.record(z.any())) + .describe( + 'provides extra sets of values that will be merged into the options map', + ) + .optional(), + inputs: z.any().optional(), + dependsOn: z + .array( + z.any().superRefine((x, ctx) => { + const schemas = [ + z.string(), + z + .object({ + projects: z + .any() + .superRefine((x, ctx) => { + const schemas = [ + z.string().describe('A project name'), + z + .array(z.string()) + .describe('An array of project names'), + ] + const errors = schemas.reduce( + (errors, schema) => + ((result) => + result.error + ? [...errors, result.error] + : errors)(schema.safeParse(x)), + [], + ) + if (schemas.length - errors.length !== 1) { + ctx.addIssue({ + path: ctx.path, + code: 'invalid_union', + unionErrors: errors, + message: 'Invalid input: Should pass single schema', + }) + } + }) + .optional(), + dependencies: z.boolean().optional(), + target: z + .string() + .describe('The name of the target.') + .optional(), + params: z + .enum(['ignore', 'forward']) + .describe('Configuration for params handling.') + .default('ignore'), + }) + .strict() + .and( + z.any().superRefine((x, ctx) => { + const schemas = [ + z.any(), + z.any(), + z + .any() + .refine( + (value) => + !z.union([z.any(), z.any()]).safeParse(value) + .success, + 'Invalid input: Should NOT be valid against schema', + ), + ] + const errors = schemas.reduce( + (errors, schema) => + ((result) => + result.error ? [...errors, result.error] : errors)( + schema.safeParse(x), + ), + [], + ) + if (schemas.length - errors.length !== 1) { + ctx.addIssue({ + path: ctx.path, + code: 'invalid_union', + unionErrors: errors, + message: 'Invalid input: Should pass single schema', + }) + } + }), + ), + ] + const errors = schemas.reduce( + (errors, schema) => + ((result) => + result.error ? [...errors, result.error] : errors)( + schema.safeParse(x), + ), + [], + ) + if (schemas.length - errors.length !== 1) { + ctx.addIssue({ + path: ctx.path, + code: 'invalid_union', + unionErrors: errors, + message: 'Invalid input: Should pass single schema', + }) + } + }), + ) + .optional(), + command: z + .string() + .describe('A shorthand for using the nx:run-commands executor') + .optional(), + cache: z + .boolean() + .describe('Specifies if the given target should be cacheable') + .optional(), + parallelism: z + .boolean() + .describe( + 'Whether this target can be run in parallel with other tasks', + ) + .default(true), + metadata: z + .object({ + description: z + .string() + .describe('A description of the target') + .optional(), + }) + .catchall(z.any()) + .describe('Metadata about the target') + .optional(), + syncGenerators: z + .array(z.string()) + .describe( + 'List of generators to run before the target to ensure the workspace is up to date', + ) + .optional(), + }), + ) + .describe( + 'Configures all the targets which define what tasks you can run against the project', + ) + .optional(), + tags: z.array(z.string()).optional(), + implicitDependencies: z.array(z.string()).optional(), + metadata: z + .object({ + description: z + .string() + .describe('A description of the project.') + .optional(), + }) + .catchall(z.any()) + .describe('Metadata about the project.') + .optional(), + release: z + .object({ + version: z + .object({ + generator: z + .string() + .describe( + 'The version generator to use. Defaults to @nx/js:release-version.', + ) + .optional(), + generatorOptions: z + .record(z.any()) + .describe('Options for the version generator.') + .optional(), + }) + .describe('Configuration for the nx release version command.') + .optional(), + }) + .describe('Configuration for the nx release commands.') + .optional(), +}) +export type NxProjectSchema = z.infer diff --git a/infra/src/types/index.ts b/infra/src/types/index.ts new file mode 100644 index 000000000000..d572bc7ee94e --- /dev/null +++ b/infra/src/types/index.ts @@ -0,0 +1 @@ +export * from './nx-project' diff --git a/infra/src/types/nx-project.ts b/infra/src/types/nx-project.ts new file mode 100644 index 000000000000..3ffb3016e61d --- /dev/null +++ b/infra/src/types/nx-project.ts @@ -0,0 +1,10 @@ +import { nxProjectSchema } from '../generated/nx-project-schema' +import { z } from 'zod' + +export interface ProjectInfo { + serviceName: string + projectPath: string +} + +export type NxProjectSchema = z.infer +export { nxProjectSchema } diff --git a/infra/src/uber-charts/islandis.ts b/infra/src/uber-charts/islandis.ts index ef54b85791f9..372e30eeaa3f 100644 --- a/infra/src/uber-charts/islandis.ts +++ b/infra/src/uber-charts/islandis.ts @@ -116,6 +116,7 @@ const api = apiSetup({ universityGatewayApi: universityGatewayService, userNotificationService, }) + const servicePortal = servicePortalSetup({ graphql: api }) const bffAdminPortalService = bffAdminPortalServiceSetup({ api }) const appSystemForm = appSystemFormSetup({ api }) diff --git a/infra/yarn.lock b/infra/yarn.lock index c97ff807bf19..b8110816e3e0 100644 --- a/infra/yarn.lock +++ b/infra/yarn.lock @@ -4108,6 +4108,13 @@ __metadata: languageName: node linkType: hard +"asap@npm:^2.0.0": + version: 2.0.6 + resolution: "asap@npm:2.0.6" + checksum: b296c92c4b969e973260e47523207cd5769abd27c245a68c26dc7a0fe8053c55bb04360237cb51cab1df52be939da77150ace99ad331fb7fb13b3423ed73ff3d + languageName: node + linkType: hard + "assign-symbols@npm:^1.0.0": version: 1.0.0 resolution: "assign-symbols@npm:1.0.0" @@ -4623,6 +4630,19 @@ __metadata: languageName: node linkType: hard +"call-bind@npm:^1.0.7": + version: 1.0.7 + resolution: "call-bind@npm:1.0.7" + dependencies: + es-define-property: ^1.0.0 + es-errors: ^1.3.0 + function-bind: ^1.1.2 + get-intrinsic: ^1.2.4 + set-function-length: ^1.2.1 + checksum: 295c0c62b90dd6522e6db3b0ab1ce26bdf9e7404215bda13cfee25b626b5ff1a7761324d58d38b1ef1607fc65aca2d06e44d2e18d0dfc6c14b465b00d8660029 + languageName: node + linkType: hard + "callsites@npm:^3.0.0": version: 3.1.0 resolution: "callsites@npm:3.1.0" @@ -4888,6 +4908,13 @@ __metadata: languageName: node linkType: hard +"commander@npm:~4.1.1": + version: 4.1.1 + resolution: "commander@npm:4.1.1" + checksum: d7b9913ff92cae20cb577a4ac6fcc121bd6223319e54a40f51a14740a681ad5c574fd29a57da478a5f234a6fa6c52cbf0b7c641353e03c648b1ae85ba670b977 + languageName: node + linkType: hard + "component-emitter@npm:^1.2.1": version: 1.3.0 resolution: "component-emitter@npm:1.3.0" @@ -4895,6 +4922,13 @@ __metadata: languageName: node linkType: hard +"component-emitter@npm:^1.3.0": + version: 1.3.1 + resolution: "component-emitter@npm:1.3.1" + checksum: 94550aa462c7bd5a61c1bc480e28554aa306066930152d1b1844a0dd3845d4e5db7e261ddec62ae184913b3e59b55a2ad84093b9d3596a8f17c341514d6c483d + languageName: node + linkType: hard + "concat-map@npm:0.0.1": version: 0.0.1 resolution: "concat-map@npm:0.0.1" @@ -4932,6 +4966,13 @@ __metadata: languageName: node linkType: hard +"cookiejar@npm:^2.1.3": + version: 2.1.4 + resolution: "cookiejar@npm:2.1.4" + checksum: c4442111963077dc0e5672359956d6556a195d31cbb35b528356ce5f184922b99ac48245ac05ed86cf993f7df157c56da10ab3efdadfed79778a0d9b1b092d5b + languageName: node + linkType: hard + "copy-descriptor@npm:^0.1.0": version: 0.1.1 resolution: "copy-descriptor@npm:0.1.1" @@ -5071,6 +5112,18 @@ __metadata: languageName: node linkType: hard +"debug@npm:^4.3.4": + version: 4.3.7 + resolution: "debug@npm:4.3.7" + dependencies: + ms: ^2.1.3 + peerDependenciesMeta: + supports-color: + optional: true + checksum: 822d74e209cd910ef0802d261b150314bbcf36c582ccdbb3e70f0894823c17e49a50d3e66d96b633524263975ca16b6a833f3e3b7e030c157169a5fabac63160 + languageName: node + linkType: hard + "decimal.js@npm:^10.2.1": version: 10.4.3 resolution: "decimal.js@npm:10.4.3" @@ -5119,6 +5172,17 @@ __metadata: languageName: node linkType: hard +"define-data-property@npm:^1.1.4": + version: 1.1.4 + resolution: "define-data-property@npm:1.1.4" + dependencies: + es-define-property: ^1.0.0 + es-errors: ^1.3.0 + gopd: ^1.0.1 + checksum: 8068ee6cab694d409ac25936eb861eea704b7763f7f342adbdfe337fc27c78d7ae0eff2364b2917b58c508d723c7a074326d068eef2e45c4edcd85cf94d0313b + languageName: node + linkType: hard + "define-lazy-prop@npm:^2.0.0": version: 2.0.0 resolution: "define-lazy-prop@npm:2.0.0" @@ -5205,6 +5269,16 @@ __metadata: languageName: node linkType: hard +"dezalgo@npm:^1.0.4": + version: 1.0.4 + resolution: "dezalgo@npm:1.0.4" + dependencies: + asap: ^2.0.0 + wrappy: 1 + checksum: 895389c6aead740d2ab5da4d3466d20fa30f738010a4d3f4dcccc9fc645ca31c9d10b7e1804ae489b1eb02c7986f9f1f34ba132d409b043082a86d9a4e745624 + languageName: node + linkType: hard + "diff-sequences@npm:^26.6.2": version: 26.6.2 resolution: "diff-sequences@npm:26.6.2" @@ -5373,6 +5447,22 @@ __metadata: languageName: node linkType: hard +"es-define-property@npm:^1.0.0": + version: 1.0.0 + resolution: "es-define-property@npm:1.0.0" + dependencies: + get-intrinsic: ^1.2.4 + checksum: f66ece0a887b6dca71848fa71f70461357c0e4e7249696f81bad0a1f347eed7b31262af4a29f5d726dc026426f085483b6b90301855e647aa8e21936f07293c6 + languageName: node + linkType: hard + +"es-errors@npm:^1.3.0": + version: 1.3.0 + resolution: "es-errors@npm:1.3.0" + checksum: ec1414527a0ccacd7f15f4a3bc66e215f04f595ba23ca75cdae0927af099b5ec865f9f4d33e9d7e86f512f252876ac77d4281a7871531a50678132429b1271b5 + languageName: node + linkType: hard + "esbuild-android-64@npm:0.14.39": version: 0.14.39 resolution: "esbuild-android-64@npm:0.14.39" @@ -5813,6 +5903,13 @@ __metadata: languageName: node linkType: hard +"fast-safe-stringify@npm:^2.1.1": + version: 2.1.1 + resolution: "fast-safe-stringify@npm:2.1.1" + checksum: a851cbddc451745662f8f00ddb622d6766f9bd97642dabfd9a405fb0d646d69fc0b9a1243cbf67f5f18a39f40f6fa821737651ff1bceeba06c9992ca2dc5bd3d + languageName: node + linkType: hard + "fast-xml-parser@npm:3.19.0": version: 3.19.0 resolution: "fast-xml-parser@npm:3.19.0" @@ -5956,6 +6053,18 @@ __metadata: languageName: node linkType: hard +"formidable@npm:^2.0.1": + version: 2.1.2 + resolution: "formidable@npm:2.1.2" + dependencies: + dezalgo: ^1.0.4 + hexoid: ^1.0.0 + once: ^1.4.0 + qs: ^6.11.0 + checksum: 81c8e5d89f5eb873e992893468f0de22c01678ca3d315db62be0560f9de1c77d4faefc9b1f4575098eb2263b3c81ba1024833a9fc3206297ddbac88a4f69b7a8 + languageName: node + linkType: hard + "fragment-cache@npm:^0.2.1": version: 0.2.1 resolution: "fragment-cache@npm:0.2.1" @@ -6085,6 +6194,19 @@ __metadata: languageName: node linkType: hard +"get-intrinsic@npm:^1.2.4": + version: 1.2.4 + resolution: "get-intrinsic@npm:1.2.4" + dependencies: + es-errors: ^1.3.0 + function-bind: ^1.1.2 + has-proto: ^1.0.1 + has-symbols: ^1.0.3 + hasown: ^2.0.0 + checksum: 414e3cdf2c203d1b9d7d33111df746a4512a1aa622770b361dadddf8ed0b5aeb26c560f49ca077e24bfafb0acb55ca908d1f709216ccba33ffc548ec8a79a951 + languageName: node + linkType: hard + "get-package-type@npm:^0.1.0": version: 0.1.0 resolution: "get-package-type@npm:0.1.0" @@ -6231,6 +6353,15 @@ __metadata: languageName: node linkType: hard +"graphlib@npm:^2.1.8": + version: 2.1.8 + resolution: "graphlib@npm:2.1.8" + dependencies: + lodash: ^4.17.15 + checksum: 1e0db4dea1c8187d59103d5582ecf32008845ebe2103959a51d22cb6dae495e81fb9263e22c922bca3aaecb56064a45cd53424e15a4626cfb5a0c52d0aff61a8 + languageName: node + linkType: hard + "has-flag@npm:^3.0.0": version: 3.0.0 resolution: "has-flag@npm:3.0.0" @@ -6263,6 +6394,15 @@ __metadata: languageName: node linkType: hard +"has-property-descriptors@npm:^1.0.2": + version: 1.0.2 + resolution: "has-property-descriptors@npm:1.0.2" + dependencies: + es-define-property: ^1.0.0 + checksum: fcbb246ea2838058be39887935231c6d5788babed499d0e9d0cc5737494c48aba4fe17ba1449e0d0fbbb1e36175442faa37f9c427ae357d6ccb1d895fbcd3de3 + languageName: node + linkType: hard + "has-proto@npm:^1.0.1": version: 1.0.1 resolution: "has-proto@npm:1.0.1" @@ -6350,6 +6490,13 @@ __metadata: languageName: node linkType: hard +"hexoid@npm:^1.0.0": + version: 1.0.0 + resolution: "hexoid@npm:1.0.0" + checksum: 27a148ca76a2358287f40445870116baaff4a0ed0acc99900bf167f0f708ffd82e044ff55e9949c71963852b580fc024146d3ac6d5d76b508b78d927fa48ae2d + languageName: node + linkType: hard + "hosted-git-info@npm:^7.0.0": version: 7.0.1 resolution: "hosted-git-info@npm:7.0.1" @@ -6542,6 +6689,8 @@ __metadata: glob: 10.3.3 jest: 27.2.4 js-yaml: 4.0.0 + json-refs: 3.0.15 + json-schema-to-zod: 2.4.1 lodash: 4.17.21 typescript: 4.6.4 yargs: 17.7.2 @@ -7678,6 +7827,33 @@ __metadata: languageName: node linkType: hard +"json-refs@npm:3.0.15": + version: 3.0.15 + resolution: "json-refs@npm:3.0.15" + dependencies: + commander: ~4.1.1 + graphlib: ^2.1.8 + js-yaml: ^3.13.1 + lodash: ^4.17.15 + native-promise-only: ^0.8.1 + path-loader: ^1.0.10 + slash: ^3.0.0 + uri-js: ^4.2.2 + bin: + json-refs: ./bin/json-refs + checksum: ad77ac11eb0c6992ac870691eeb97a43663224ab32d41b02af500d253c49ea902a4c8792ebb8f1ec6df9c2369c99a62038a0ebe45ef7d3610f984ca6f32e4927 + languageName: node + linkType: hard + +"json-schema-to-zod@npm:2.4.1": + version: 2.4.1 + resolution: "json-schema-to-zod@npm:2.4.1" + bin: + json-schema-to-zod: dist/cjs/cli.js + checksum: acfb05c2fab8f7fd87238aa7da8e219a256d910571f82b9d9a548f44d5eb223f71c25f2bac2444776ccaa35e109c4f257a0a1a48c4f63d2dbd4ce31397a8d0e4 + languageName: node + linkType: hard + "json5@npm:^2.1.2": version: 2.1.3 resolution: "json5@npm:2.1.3" @@ -7803,7 +7979,7 @@ __metadata: languageName: node linkType: hard -"lodash@npm:4.17.21, lodash@npm:^4.17.19, lodash@npm:^4.7.0": +"lodash@npm:4.17.21, lodash@npm:^4.17.15, lodash@npm:^4.17.19, lodash@npm:^4.7.0": version: 4.17.21 resolution: "lodash@npm:4.17.21" checksum: eb835a2e51d381e561e508ce932ea50a8e5a68f4ebdd771ea240d3048244a8d13658acbd502cd4829768c56f2e16bdd4340b9ea141297d472517b83868e677f7 @@ -7947,6 +8123,13 @@ __metadata: languageName: node linkType: hard +"methods@npm:^1.1.2": + version: 1.1.2 + resolution: "methods@npm:1.1.2" + checksum: 0917ff4041fa8e2f2fda5425a955fe16ca411591fbd123c0d722fcf02b73971ed6f764d85f0a6f547ce49ee0221ce2c19a5fa692157931cecb422984f1dcd13a + languageName: node + linkType: hard + "micromatch@npm:^3.1.4": version: 3.1.10 resolution: "micromatch@npm:3.1.10" @@ -8004,6 +8187,15 @@ __metadata: languageName: node linkType: hard +"mime@npm:2.6.0": + version: 2.6.0 + resolution: "mime@npm:2.6.0" + bin: + mime: cli.js + checksum: 1497ba7b9f6960694268a557eae24b743fd2923da46ec392b042469f4b901721ba0adcf8b0d3c2677839d0e243b209d76e5edcbd09cfdeffa2dfb6bb4df4b862 + languageName: node + linkType: hard + "mimic-fn@npm:^2.1.0": version: 2.1.0 resolution: "mimic-fn@npm:2.1.0" @@ -8187,7 +8379,7 @@ __metadata: languageName: node linkType: hard -"ms@npm:^2.0.0": +"ms@npm:^2.0.0, ms@npm:^2.1.3": version: 2.1.3 resolution: "ms@npm:2.1.3" checksum: aa92de608021b242401676e35cfa5aa42dd70cbdc082b916da7fb925c542173e36bce97ea3e804923fe92c0ad991434e4a38327e15a1b5b5f945d66df615ae6d @@ -8213,6 +8405,13 @@ __metadata: languageName: node linkType: hard +"native-promise-only@npm:^0.8.1": + version: 0.8.1 + resolution: "native-promise-only@npm:0.8.1" + checksum: bb4d8416c47d1b2cef0d4eb2c7f3442a9ed04d3734287f4037dfb7ff25948612976928e5baed105081927d5337d3f657e3a42ad2e8cca38a6428a81b32cd6dc4 + languageName: node + linkType: hard + "natural-compare@npm:^1.4.0": version: 1.4.0 resolution: "natural-compare@npm:1.4.0" @@ -8481,6 +8680,13 @@ __metadata: languageName: node linkType: hard +"object-inspect@npm:^1.13.1": + version: 1.13.2 + resolution: "object-inspect@npm:1.13.2" + checksum: 9f850b3c045db60e0e97746e809ee4090d6ce62195af17dd1e9438ac761394a7d8ec4f7906559aea5424eaf61e35d3e53feded2ccd5f62fcc7d9670d3c8eb353 + languageName: node + linkType: hard + "object-keys@npm:^1.1.1": version: 1.1.1 resolution: "object-keys@npm:1.1.1" @@ -8667,6 +8873,16 @@ __metadata: languageName: node linkType: hard +"path-loader@npm:^1.0.10": + version: 1.0.12 + resolution: "path-loader@npm:1.0.12" + dependencies: + native-promise-only: ^0.8.1 + superagent: ^7.1.6 + checksum: 50ff3bb331fc997ca817396b86b79f2adae857e6cb226bd5ddaa97b4b1a3b903502ad3e0a59cd48847d0ed15b662a8bd5ceb21b2176380727d37a64c7b8dd3c1 + languageName: node + linkType: hard + "path-parse@npm:^1.0.6, path-parse@npm:^1.0.7": version: 1.0.7 resolution: "path-parse@npm:1.0.7" @@ -8850,13 +9066,22 @@ __metadata: languageName: node linkType: hard -"punycode@npm:^2.1.1": +"punycode@npm:^2.1.0, punycode@npm:^2.1.1": version: 2.3.1 resolution: "punycode@npm:2.3.1" checksum: bb0a0ceedca4c3c57a9b981b90601579058903c62be23c5e8e843d2c2d4148a3ecf029d5133486fb0e1822b098ba8bba09e89d6b21742d02fa26bda6441a6fb2 languageName: node linkType: hard +"qs@npm:^6.10.3, qs@npm:^6.11.0": + version: 6.13.0 + resolution: "qs@npm:6.13.0" + dependencies: + side-channel: ^1.0.6 + checksum: e9404dc0fc2849245107108ce9ec2766cde3be1b271de0bf1021d049dc5b98d1a2901e67b431ac5509f865420a7ed80b7acb3980099fe1c118a1c5d2e1432ad8 + languageName: node + linkType: hard + "querystring@npm:0.2.0": version: 0.2.0 resolution: "querystring@npm:0.2.0" @@ -9298,6 +9523,15 @@ __metadata: languageName: node linkType: hard +"semver@npm:^7.3.7": + version: 7.6.3 + resolution: "semver@npm:7.6.3" + bin: + semver: bin/semver.js + checksum: 4110ec5d015c9438f322257b1c51fe30276e5f766a3f64c09edd1d7ea7118ecbc3f379f3b69032bacf13116dc7abc4ad8ce0d7e2bd642e26b0d271b56b61a7d8 + languageName: node + linkType: hard + "set-blocking@npm:^2.0.0": version: 2.0.0 resolution: "set-blocking@npm:2.0.0" @@ -9318,6 +9552,20 @@ __metadata: languageName: node linkType: hard +"set-function-length@npm:^1.2.1": + version: 1.2.2 + resolution: "set-function-length@npm:1.2.2" + dependencies: + define-data-property: ^1.1.4 + es-errors: ^1.3.0 + function-bind: ^1.1.2 + get-intrinsic: ^1.2.4 + gopd: ^1.0.1 + has-property-descriptors: ^1.0.2 + checksum: a8248bdacdf84cb0fab4637774d9fb3c7a8e6089866d04c817583ff48e14149c87044ce683d7f50759a8c50fb87c7a7e173535b06169c87ef76f5fb276dfff72 + languageName: node + linkType: hard + "set-value@npm:^2.0.0, set-value@npm:^2.0.1": version: 2.0.1 resolution: "set-value@npm:2.0.1" @@ -9362,6 +9610,18 @@ __metadata: languageName: node linkType: hard +"side-channel@npm:^1.0.6": + version: 1.0.6 + resolution: "side-channel@npm:1.0.6" + dependencies: + call-bind: ^1.0.7 + es-errors: ^1.3.0 + get-intrinsic: ^1.2.4 + object-inspect: ^1.13.1 + checksum: bfc1afc1827d712271453e91b7cd3878ac0efd767495fd4e594c4c2afaa7963b7b510e249572bfd54b0527e66e4a12b61b80c061389e129755f34c493aad9b97 + languageName: node + linkType: hard + "signal-exit@npm:^3.0.0, signal-exit@npm:^3.0.7": version: 3.0.7 resolution: "signal-exit@npm:3.0.7" @@ -9700,6 +9960,25 @@ __metadata: languageName: node linkType: hard +"superagent@npm:^7.1.6": + version: 7.1.6 + resolution: "superagent@npm:7.1.6" + dependencies: + component-emitter: ^1.3.0 + cookiejar: ^2.1.3 + debug: ^4.3.4 + fast-safe-stringify: ^2.1.1 + form-data: ^4.0.0 + formidable: ^2.0.1 + methods: ^1.1.2 + mime: 2.6.0 + qs: ^6.10.3 + readable-stream: ^3.6.0 + semver: ^7.3.7 + checksum: b73316836003219f1a4886a6d77dd28551a6784c30e871009fb7bad699fae772b20370d39d2ccb5a543c9335ce12b43a76b959a3ca983f1d6365cb4b5682c08f + languageName: node + linkType: hard + "supports-color@npm:^5.3.0": version: 5.5.0 resolution: "supports-color@npm:5.5.0" @@ -10116,6 +10395,15 @@ __metadata: languageName: node linkType: hard +"uri-js@npm:^4.2.2": + version: 4.4.1 + resolution: "uri-js@npm:4.4.1" + dependencies: + punycode: ^2.1.0 + checksum: 7167432de6817fe8e9e0c9684f1d2de2bb688c94388f7569f7dbdb1587c9f4ca2a77962f134ec90be0cc4d004c939ff0d05acc9f34a0db39a3c797dada262633 + languageName: node + linkType: hard + "urix@npm:^0.1.0": version: 0.1.0 resolution: "urix@npm:0.1.0" From 5a35ac7dbf65d173bb6ac96c1381cc309d9312b9 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Wed, 23 Oct 2024 11:49:07 +0000 Subject: [PATCH 140/248] Move my-pages over to bff first attempt --- .../modules/documents/document.controller.ts | 6 +- .../my-pages/infra/portals-my-pages.ts | 10 +- apps/portals/my-pages/project.json | 45 +++++-- apps/portals/my-pages/proxy.config.json | 6 + apps/portals/my-pages/src/Main.tsx | 7 +- apps/portals/my-pages/src/app/App.tsx | 22 +++- apps/portals/my-pages/src/auth.ts | 79 ------------- apps/services/bff/infra/admin-portal.infra.ts | 10 +- .../bff/infra/my-pages-portal.infra.ts | 75 ++++++++++++ .../bff/infra/utils/createPortalEnv.ts | 21 ++-- .../bff/src/app/modules/user/user.service.ts | 2 - infra/src/uber-charts/islandis.ts | 5 + .../src/lib/clients/service-portal-scopes.ts | 1 + libs/react-spa/bff/src/lib/bff.hooks.ts | 111 +++++------------- .../assets/src/screens/Overview/Overview.tsx | 2 - .../DocumentActionBar/DocumentActionBarV2.tsx | 28 +++-- .../DocumentActions/DocumentActionsV3.tsx | 18 +-- .../DocumentRenderer/PdfDocument.tsx | 14 ++- .../documents/src/hooks/useDocumentList.ts | 6 +- .../documents/src/hooks/useDocumentListV3.ts | 5 +- .../documents/src/utils/downloadDocumentV2.ts | 85 +++----------- .../FinanceSchedule/FinanceSchedule.tsx | 2 +- .../screens/FinanceStatus/FinanceStatus.tsx | 2 +- libs/service-portal/graphql/src/lib/client.ts | 10 +- .../screens/HealthOverview/HealthOverview.tsx | 2 +- .../src/screens/BioChild/BioChild.tsx | 2 +- .../src/screens/ChildCustody/ChildCustody.tsx | 2 +- .../src/screens/Company/CompanyInfo.tsx | 2 +- .../src/screens/UserInfo/UserInfo.tsx | 2 +- .../UserInfoOverview/UserInfoOverview.tsx | 2 +- .../src/screens/UserProfile/UserProfile.tsx | 2 +- .../EducationalDetail/EducationalDetail.tsx | 8 +- .../HealthDirectorateDetail.tsx | 17 +-- .../src/screens/Sessions/Sessions.tsx | 2 +- .../src/screens/Parliamentary/index.tsx | 2 +- .../src/auth/UserMenu/UserDelegations.tsx | 4 +- .../components/src/auth/UserMenu/UserMenu.tsx | 4 +- 37 files changed, 294 insertions(+), 329 deletions(-) create mode 100644 apps/portals/my-pages/proxy.config.json delete mode 100644 apps/portals/my-pages/src/auth.ts create mode 100644 apps/services/bff/infra/my-pages-portal.infra.ts diff --git a/apps/download-service/src/app/modules/documents/document.controller.ts b/apps/download-service/src/app/modules/documents/document.controller.ts index ffdf2e657ad1..3f775d676675 100644 --- a/apps/download-service/src/app/modules/documents/document.controller.ts +++ b/apps/download-service/src/app/modules/documents/document.controller.ts @@ -67,9 +67,9 @@ export class DocumentController { rawDocumentDTO.fileName }.pdf`, ) - res.header('Pragma: no-cache') - res.header('Cache-Control: no-cache') - res.header('Cache-Control: nmax-age=0') + res.header('Pragma', 'no-cache') + res.header('Cache-Control', 'no-cache') + res.header('Cache-Control', 'nmax-age=0') return res.end(buffer) } diff --git a/apps/portals/my-pages/infra/portals-my-pages.ts b/apps/portals/my-pages/infra/portals-my-pages.ts index 52cdc8d58a3b..be6fb67b4780 100644 --- a/apps/portals/my-pages/infra/portals-my-pages.ts +++ b/apps/portals/my-pages/infra/portals-my-pages.ts @@ -1,5 +1,7 @@ import { ref, service, ServiceBuilder } from '../../../../infra/src/dsl/dsl' +const graphqlApiPath = '/minarsidur/bff/api/graphql' + export const serviceSetup = (services: { graphql: ServiceBuilder<'api'> }): ServiceBuilder<'service-portal'> => @@ -25,10 +27,10 @@ export const serviceSetup = (services: { }, SI_PUBLIC_ENVIRONMENT: ref((h) => h.env.type), SI_PUBLIC_GRAPHQL_API: { - prod: '/api/graphql', - staging: '/api/graphql', - dev: '/api/graphql', - local: ref((h) => `http://${h.svc(services.graphql)}/api/graphql`), + prod: graphqlApiPath, + staging: graphqlApiPath, + dev: graphqlApiPath, + local: ref((h) => `http://${h.svc(services.graphql)}${graphqlApiPath}`), }, }) .secrets({ diff --git a/apps/portals/my-pages/project.json b/apps/portals/my-pages/project.json index f11cb5433160..81eb0569664f 100644 --- a/apps/portals/my-pages/project.json +++ b/apps/portals/my-pages/project.json @@ -3,7 +3,9 @@ "$schema": "../../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "apps/portals/my-pages/src", "projectType": "application", - "tags": ["scope:portals-mypages"], + "tags": [ + "scope:portals-mypages" + ], "targets": { "build": { "executor": "@nx/webpack:webpack", @@ -19,7 +21,9 @@ "apps/portals/my-pages/src/mockServiceWorker.js", "apps/portals/my-pages/src/assets" ], - "styles": ["apps/portals/my-pages/src/styles.css"], + "styles": [ + "apps/portals/my-pages/src/styles.css" + ], "scripts": [], "webpackConfig": "apps/portals/my-pages/webpack.config.js", "maxWorkers": 2 @@ -43,7 +47,9 @@ ] } }, - "outputs": ["{options.outputPath}"], + "outputs": [ + "{options.outputPath}" + ], "dependsOn": [ { "target": "generateDevIndexHTML" @@ -57,12 +63,15 @@ "node scripts/dockerfile-assets/bash/extract-environment.js apps/portals/my-pages/src" ] }, - "outputs": ["{workspaceRoot}/apps/portals/my-pages/src/index.html"] + "outputs": [ + "{workspaceRoot}/apps/portals/my-pages/src/index.html" + ] }, "serve": { "executor": "@nx/webpack:dev-server", "options": { - "buildTarget": "service-portal:build" + "buildTarget": "service-portal:build", + "proxyConfig": "apps/portals/my-pages/proxy.config.json" }, "configurations": { "production": { @@ -83,7 +92,9 @@ "options": { "jestConfig": "apps/portals/my-pages/jest.config.ts" }, - "outputs": ["{workspaceRoot}/coverage/apps/portals/my-pages"] + "outputs": [ + "{workspaceRoot}/coverage/apps/portals/my-pages" + ] }, "extract-strings": { "executor": "nx:run-commands", @@ -101,14 +112,30 @@ "parallel": false } }, + "start-bff": { + "executor": "nx:run-commands", + "options": { + "commands": [ + "node -r esbuild-register src/cli/cli.ts run-local-env services-bff-portals-my-pages" + ], + "cwd": "infra" + } + }, "dev": { "executor": "nx:run-commands", "options": { "commands": [ - "yarn nx run services-user-profile:dev", + "yarn nx run service-portal:start-bff", "yarn start service-portal" - ], - "parallel": true + ] + } + }, + "mockmode": { + "executor": "nx:run-commands", + "options": { + "commands": [ + "API_MOCKS=true yarn start service-portal" + ] } }, "docker-static": { diff --git a/apps/portals/my-pages/proxy.config.json b/apps/portals/my-pages/proxy.config.json new file mode 100644 index 000000000000..1cd4b72192f6 --- /dev/null +++ b/apps/portals/my-pages/proxy.config.json @@ -0,0 +1,6 @@ +{ + "/minarsidur/bff/*": { + "target": "http://localhost:3010", + "secure": false + } +} diff --git a/apps/portals/my-pages/src/Main.tsx b/apps/portals/my-pages/src/Main.tsx index c03f0404169b..6a779b2cc6a9 100644 --- a/apps/portals/my-pages/src/Main.tsx +++ b/apps/portals/my-pages/src/Main.tsx @@ -1,13 +1,12 @@ import '@island.is/api/mocks' -import React, { StrictMode } from 'react' +import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' -import { userMonitoring } from '@island.is/user-monitoring' import { isRunningOnEnvironment } from '@island.is/shared/utils' +import { userMonitoring } from '@island.is/user-monitoring' -import './auth' -import { environment } from './environments' import { App } from './app/App' +import { environment } from './environments' if (!isRunningOnEnvironment('local')) { userMonitoring.initDdRum({ diff --git a/apps/portals/my-pages/src/app/App.tsx b/apps/portals/my-pages/src/app/App.tsx index c1cb0c1ec286..3446e7abe792 100644 --- a/apps/portals/my-pages/src/app/App.tsx +++ b/apps/portals/my-pages/src/app/App.tsx @@ -1,21 +1,35 @@ -import { AuthProvider } from '@island.is/auth/react' import { ApolloProvider } from '@apollo/client' import { client } from '@island.is/service-portal/graphql' import { LocaleProvider } from '@island.is/localization' import { defaultLanguage } from '@island.is/shared/constants' import { ServicePortalPaths } from '@island.is/service-portal/core' import { FeatureFlagProvider } from '@island.is/react/feature-flags' -import { ApplicationErrorBoundary, PortalRouter } from '@island.is/portals/core' +import { + ApplicationErrorBoundary, + PortalRouter, + isMockMode, +} from '@island.is/portals/core' import { modules } from '../lib/modules' import { createRoutes } from '../lib/routes' import { environment } from '../environments' import * as styles from './App.css' +import { BffProvider, createMockedInitialState } from '@island.is/react-spa/bff' +import { servicePortalScopes } from '@island.is/auth/scopes' + +const mockedInitialState = isMockMode + ? createMockedInitialState({ + scopes: servicePortalScopes, + }) + : undefined export const App = () => (
- + ( /> - +
diff --git a/apps/portals/my-pages/src/auth.ts b/apps/portals/my-pages/src/auth.ts deleted file mode 100644 index d3ea87433d36..000000000000 --- a/apps/portals/my-pages/src/auth.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { configure, configureMock } from '@island.is/auth/react' -import { - ApiScope, - ApplicationScope, - AuthScope, - DocumentsScope, - EndorsementsScope, - HmsScope, - NationalRegistryScope, - UserProfileScope, -} from '@island.is/auth/scopes' - -import { environment } from './environments' - -const SERVICE_PORTAL_SCOPES = [ - 'openid', - 'profile', - 'api_resource.scope', - ApplicationScope.read, - ApplicationScope.write, - UserProfileScope.read, - UserProfileScope.write, - AuthScope.actorDelegations, - AuthScope.delegations, - AuthScope.consents, - NationalRegistryScope.individuals, - DocumentsScope.main, - EndorsementsScope.main, - EndorsementsScope.admin, - ApiScope.intellectualProperties, - ApiScope.assets, - ApiScope.education, - ApiScope.educationLicense, - ApiScope.financeOverview, - ApiScope.financeSalary, - ApiScope.financeSchedule, - ApiScope.financeLoans, - ApiScope.internal, - ApiScope.internalProcuring, - ApiScope.meDetails, - ApiScope.lawAndOrder, - ApiScope.licenses, - ApiScope.licensesVerify, - ApiScope.company, - ApiScope.vehicles, - ApiScope.workMachines, - ApiScope.healthPayments, - ApiScope.healthMedicines, - ApiScope.healthAssistiveAndNutrition, - ApiScope.healthTherapies, - ApiScope.healthHealthcare, - ApiScope.healthRightsStatus, - ApiScope.healthDentists, - ApiScope.healthOrganDonation, - ApiScope.healthVaccinations, - ApiScope.signatureCollection, -] - -const userMocked = process.env.API_MOCKS === 'true' - -if (userMocked) { - configureMock({ - profile: { name: 'Mock', locale: 'is', nationalId: '0000000000' }, - scopes: SERVICE_PORTAL_SCOPES, - }) -} else { - configure({ - baseUrl: `${window.location.origin}/minarsidur`, - redirectPath: '/signin-oidc', - redirectPathSilent: '/silent/signin-oidc', - initiateLoginPath: '/login', - switchUserRedirectUrl: '/', - authority: environment.identityServer.authority, - client_id: '@island.is/web', - scope: SERVICE_PORTAL_SCOPES, - post_logout_redirect_uri: `${window.location.origin}`, - userStorePrefix: 'sp.', - }) -} diff --git a/apps/services/bff/infra/admin-portal.infra.ts b/apps/services/bff/infra/admin-portal.infra.ts index 9dfb97f7f726..5960d5c4110d 100644 --- a/apps/services/bff/infra/admin-portal.infra.ts +++ b/apps/services/bff/infra/admin-portal.infra.ts @@ -18,7 +18,13 @@ export const serviceSetup = ( .image(bffName) .redis() .serviceAccount(bffName) - .env(createPortalEnv(key, services)) + .env( + createPortalEnv({ + key, + services, + clientId: `@admin.island.is/bff-${key}`, + }), + ) .secrets({ // The secret should be a valid 32-byte base64 key. // Generate key example: `openssl rand -base64 32` @@ -64,6 +70,6 @@ export const serviceSetup = ( 'nginx.ingress.kubernetes.io/proxy-buffer-size': '8k', }, }, - paths: ['/stjornbord/bff'], + paths: [`/${key}/bff`], }, }) diff --git a/apps/services/bff/infra/my-pages-portal.infra.ts b/apps/services/bff/infra/my-pages-portal.infra.ts new file mode 100644 index 000000000000..c13d3e5711bf --- /dev/null +++ b/apps/services/bff/infra/my-pages-portal.infra.ts @@ -0,0 +1,75 @@ +import { ServiceBuilder, service } from '../../../../infra/src/dsl/dsl' +import { createPortalEnv } from './utils/createPortalEnv' + +const bffName = 'services-bff' +const clientName = 'portals-my-pages' +const serviceName = `${bffName}-${clientName}` +const key = 'minarsidur' + +export type BffInfraServices = { + api: ServiceBuilder<'api'> +} + +export const serviceSetup = ( + services: BffInfraServices, +): ServiceBuilder => + service(serviceName) + .namespace(clientName) + .image(bffName) + .redis() + .serviceAccount(bffName) + .env( + createPortalEnv({ + key, + services, + clientId: '@island.is/bff', + }), + ) + .secrets({ + // The secret should be a valid 32-byte base64 key. + // Generate key example: `openssl rand -base64 32` + BFF_TOKEN_SECRET_BASE64: `/k8s/${bffName}/${clientName}/BFF_TOKEN_SECRET_BASE64`, + IDENTITY_SERVER_CLIENT_SECRET: `/k8s/${bffName}/${clientName}/IDENTITY_SERVER_CLIENT_SECRET`, + }) + .readiness(`/${key}/bff/health/check`) + .liveness(`/${key}/bff/liveness`) + .replicaCount({ + default: 2, + min: 2, + max: 10, + }) + .resources({ + limits: { + cpu: '400m', + memory: '512Mi', + }, + requests: { + cpu: '100m', + memory: '256Mi', + }, + }) + .ingress({ + primary: { + host: { + dev: ['beta'], + staging: ['beta'], + prod: ['', 'www.island.is'], + }, + extraAnnotations: { + dev: { + 'nginx.ingress.kubernetes.io/proxy-buffering': 'on', + 'nginx.ingress.kubernetes.io/proxy-buffer-size': '8k', + }, + staging: { + 'nginx.ingress.kubernetes.io/enable-global-auth': 'false', + 'nginx.ingress.kubernetes.io/proxy-buffering': 'on', + 'nginx.ingress.kubernetes.io/proxy-buffer-size': '8k', + }, + prod: { + 'nginx.ingress.kubernetes.io/proxy-buffering': 'on', + 'nginx.ingress.kubernetes.io/proxy-buffer-size': '8k', + }, + }, + paths: [`/${key}/bff`], + }, + }) diff --git a/apps/services/bff/infra/utils/createPortalEnv.ts b/apps/services/bff/infra/utils/createPortalEnv.ts index acf37c5f2413..7ab77ec0d6ba 100644 --- a/apps/services/bff/infra/utils/createPortalEnv.ts +++ b/apps/services/bff/infra/utils/createPortalEnv.ts @@ -25,14 +25,21 @@ const getScopes = (key: PortalKeys) => { } } -export const createPortalEnv = ( - key: PortalKeys, - services: BffInfraServices, -) => { +type CreatePortalEnvArgs = { + key: PortalKeys + services: BffInfraServices + clientId: string +} + +export const createPortalEnv = ({ + key, + services, + clientId, +}: CreatePortalEnvArgs) => { return { // Idenity server IDENTITY_SERVER_CLIENT_SCOPES: json(getScopes(key)), - IDENTITY_SERVER_CLIENT_ID: `@admin.island.is/bff-${key}`, + IDENTITY_SERVER_CLIENT_ID: clientId, IDENTITY_SERVER_ISSUER_URL: { local: 'https://identity-server.dev01.devland.is', dev: 'https://identity-server.dev01.devland.is', @@ -49,7 +56,7 @@ export const createPortalEnv = ( BFF_CLIENT_KEY_PATH: `/${key}`, BFF_PAR_SUPPORT_ENABLED: 'false', BFF_ALLOWED_REDIRECT_URIS: { - local: json(['http://localhost:4200/stjornbord']), + local: json([`http://localhost:4200/${key}`]), dev: json(['https://featbff-beta.dev01.devland.is']), staging: json(['https://beta.staging01.devland.is']), prod: json(['https://island.is']), @@ -61,7 +68,7 @@ export const createPortalEnv = ( prod: 'https://island.is', }, BFF_LOGOUT_REDIRECT_URI: { - local: 'http://localhost:4200/stjornbord', + local: `http://localhost:4200/${key}`, dev: 'https://featbff-beta.dev01.devland.is', staging: 'https://beta.staging01.devland.is', prod: 'https://island.is', diff --git a/apps/services/bff/src/app/modules/user/user.service.ts b/apps/services/bff/src/app/modules/user/user.service.ts index e63c8d8b68bb..d2e1736a35c5 100644 --- a/apps/services/bff/src/app/modules/user/user.service.ts +++ b/apps/services/bff/src/app/modules/user/user.service.ts @@ -5,7 +5,6 @@ import { Request } from 'express' import { BffUser } from '@island.is/shared/types' import { SESSION_COOKIE_NAME } from '../../constants/cookies' -import { CryptoService } from '../../services/crypto.service' import { hasTimestampExpiredInMS } from '../../utils/has-timestamp-expired-in-ms' import { AuthService } from '../auth/auth.service' @@ -19,7 +18,6 @@ export class UserService { @Inject(LOGGER_PROVIDER) private logger: Logger, - private readonly cryptoService: CryptoService, private readonly cacheService: CacheService, private readonly idsService: IdsService, private readonly authService: AuthService, diff --git a/infra/src/uber-charts/islandis.ts b/infra/src/uber-charts/islandis.ts index ef54b85791f9..f41b54ea45d8 100644 --- a/infra/src/uber-charts/islandis.ts +++ b/infra/src/uber-charts/islandis.ts @@ -18,6 +18,7 @@ import { serviceSetup as adminPortalSetup } from '../../../apps/portals/admin/in // Bff's import { serviceSetup as bffAdminPortalServiceSetup } from '../../../apps/services/bff/infra/admin-portal.infra' +import { serviceSetup as bffServicePortalServiceSetup } from '../../../apps/services/bff/infra/my-pages-portal.infra' import { serviceSetup as consultationPortalSetup } from '../../../apps/consultation-portal/infra/samradsgatt' import { serviceSetup as xroadCollectorSetup } from '../../../apps/services/xroad-collector/infra/xroad-collector' @@ -118,6 +119,7 @@ const api = apiSetup({ }) const servicePortal = servicePortalSetup({ graphql: api }) const bffAdminPortalService = bffAdminPortalServiceSetup({ api }) +const bffServicePortalService = bffServicePortalServiceSetup({ api }) const appSystemForm = appSystemFormSetup({ api }) const web = webSetup({ api }) const searchIndexer = searchIndexerSetup() @@ -180,6 +182,7 @@ export const Services: EnvironmentServices = { contentfulApps, contentfulEntryTagger, bffAdminPortalService, + bffServicePortalService, ], staging: [ appSystemApi, @@ -214,6 +217,7 @@ export const Services: EnvironmentServices = { universityGatewayService, universityGatewayWorker, bffAdminPortalService, + bffServicePortalService, ], dev: [ appSystemApi, @@ -252,6 +256,7 @@ export const Services: EnvironmentServices = { universityGatewayService, universityGatewayWorker, bffAdminPortalService, + bffServicePortalService, ], } diff --git a/libs/auth/scopes/src/lib/clients/service-portal-scopes.ts b/libs/auth/scopes/src/lib/clients/service-portal-scopes.ts index a92a6a77914e..2ddd1818346c 100644 --- a/libs/auth/scopes/src/lib/clients/service-portal-scopes.ts +++ b/libs/auth/scopes/src/lib/clients/service-portal-scopes.ts @@ -30,6 +30,7 @@ export const servicePortalScopes = [ ApiScope.internal, ApiScope.internalProcuring, ApiScope.meDetails, + ApiScope.lawAndOrder, ApiScope.licenses, ApiScope.licensesVerify, ApiScope.company, diff --git a/libs/react-spa/bff/src/lib/bff.hooks.ts b/libs/react-spa/bff/src/lib/bff.hooks.ts index b1cf35fa4281..6420f22d94c5 100644 --- a/libs/react-spa/bff/src/lib/bff.hooks.ts +++ b/libs/react-spa/bff/src/lib/bff.hooks.ts @@ -1,111 +1,54 @@ -import { AuthContext } from '@island.is/auth/react' -import { BffUser, User } from '@island.is/shared/types' -import { useContext } from 'react' -import { BffContext, BffContextType } from './BffContext' import { createBroadcasterHook } from '@island.is/react-spa/shared' +import { BffUser } from '@island.is/shared/types' +import { isDefined } from '@island.is/shared/utils' +import * as kennitala from 'kennitala' +import { useContext, useMemo } from 'react' +import { BffContext, BffContextType } from './BffContext' /** - * Maps an object to a BffUser type. - */ -export const mapToBffUser = (input: User): BffUser => { - const { - profile: { - sid, - birthdate, - nationalId, - name, - idp, - actor, - subjectType, - delegationType, - locale, - }, - scopes, - } = input - - // Return a mapped BffUser object - return { - scopes: scopes || [], - profile: { - sid: sid || '', - birthdate, - nationalId, - name, - idp, - actor, - subjectType, - delegationType, - locale, - }, - } -} - -/** - * Dynamic hook to get the bff context. + * This hook is used to get the BFF authentication context. */ -export const useDynamicBffHook = (hookName: string): BffContextType => { +export const useBff = () => { const bffContext = useContext(BffContext) if (!bffContext) { - throw new Error(`${hookName} must be used within a BffProvider`) + throw new Error('useAuth must be used within a BffProvider') } return bffContext } /** - * This hook is used to get the BFF authentication context. - * It has backward compatibility with AuthContext. + * Dynamic hook to get specific key in the bff context. */ -export const useAuth = () => { - const bffContext = useContext(BffContext) - const authContext = useContext(AuthContext) +export const useDynamicBffHook = ( + returnField: Key, +): NonNullable => { + const bffContext = useBff() - if (bffContext) { - return bffContext + if (!isDefined(bffContext[returnField])) { + throw new Error(`The field ${returnField} does not exist in the BffContext`) } - if (authContext) { - return authContext - } - - const errorMsg = (providerStr: string) => - `useAuth must be used within a ${providerStr}` - - if (!authContext) { - throw new Error(errorMsg('AuthProvider')) - } - - throw new Error(errorMsg('BffProvider')) -} - -/** - * This hook is used to get user information. - * It will determine what context to use based on the context that is available. - * We will remove support for AuthContext when other clients transition over to BFF. - * If AuthContext is being used then we will map the user info to the BffUser type. - */ -export const useUserInfo = (): BffUser => { - const bffContext = useContext(BffContext) - const authContext = useContext(AuthContext) - - if (bffContext?.userInfo) { - return bffContext.userInfo - } else if (authContext?.userInfo) { - return mapToBffUser(authContext.userInfo) - } - - throw new Error('User info is not available. Is the user authenticated?') + return bffContext[returnField] as NonNullable } /** * This hook is used to get the bff url generator. * The bff url generator is used to generate urls for the Bff in a conveinent way. */ -export const useBffUrlGenerator = () => - useDynamicBffHook(useBffUrlGenerator.name).bffUrlGenerator +export const useBffUrlGenerator = () => useDynamicBffHook('bffUrlGenerator') +export const useUserInfo = () => useDynamicBffHook('userInfo') -export const useBff = () => useDynamicBffHook(useBff.name) +export const useUserBirthday = () => { + const userInfo = useUserInfo() + + return useMemo(() => { + const nationalId = userInfo?.profile.nationalId + + return nationalId ? kennitala.info(nationalId)?.birthday : undefined + }, [userInfo?.profile.nationalId]) +} export enum BffBroadcastEvents { NEW_SESSION = 'NEW_SESSION', diff --git a/libs/service-portal/assets/src/screens/Overview/Overview.tsx b/libs/service-portal/assets/src/screens/Overview/Overview.tsx index 5a57594efd9f..88ab6186457d 100644 --- a/libs/service-portal/assets/src/screens/Overview/Overview.tsx +++ b/libs/service-portal/assets/src/screens/Overview/Overview.tsx @@ -12,8 +12,6 @@ import { import { useLocale, useNamespaces } from '@island.is/localization' import { CardLoader, - EmptyState, - ErrorScreen, FootNote, formSubmit, IntroHeader, diff --git a/libs/service-portal/documents/src/components/DocumentActionBar/DocumentActionBarV2.tsx b/libs/service-portal/documents/src/components/DocumentActionBar/DocumentActionBarV2.tsx index c2807fdd2572..69c8b7b17e04 100644 --- a/libs/service-portal/documents/src/components/DocumentActionBar/DocumentActionBarV2.tsx +++ b/libs/service-portal/documents/src/components/DocumentActionBar/DocumentActionBarV2.tsx @@ -1,17 +1,17 @@ import { - Icon, Box, BoxProps, - LoadingDots, Button, + Icon, + LoadingDots, } from '@island.is/island-ui/core' -import { useUserInfo } from '@island.is/auth/react' -import { Tooltip, m } from '@island.is/service-portal/core' import { useLocale } from '@island.is/localization' -import { ActiveDocumentType2 } from '../../lib/types' -import { useDocumentContext } from '../../screens/Overview/DocumentContext' +import { useBffUrlGenerator } from '@island.is/react-spa/bff' +import { Tooltip, m } from '@island.is/service-portal/core' import { useDocumentList } from '../../hooks/useDocumentList' import { useMailAction } from '../../hooks/useMailActionV2' +import { ActiveDocumentType2 } from '../../lib/types' +import { useDocumentContext } from '../../screens/Overview/DocumentContext' import { downloadFile } from '../../utils/downloadDocumentV2' import * as styles from './DocumentActionBar.css' @@ -37,8 +37,7 @@ export const DocumentActionBar: React.FC = ({ const { activeDocument } = useDocumentContext() const { fetchObject, refetch } = useDocumentList() - const userInfo = useUserInfo() - + const bffUrlGenerator = useBffUrlGenerator() const { formatMessage } = useLocale() const isBookmarked = @@ -144,7 +143,11 @@ export const DocumentActionBar: React.FC = ({ icon="download" iconType={'outline'} onClick={() => - downloadFile(activeDocument, userInfo, 'download') + downloadFile({ + bffUrlGenerator, + doc: activeDocument, + query: 'download', + }) } size="medium" colorScheme="light" @@ -158,7 +161,12 @@ export const DocumentActionBar: React.FC = ({ circle icon="print" iconType={'outline'} - onClick={() => downloadFile(activeDocument, userInfo)} + onClick={() => + downloadFile({ + bffUrlGenerator, + doc: activeDocument, + }) + } size="medium" colorScheme="light" /> diff --git a/libs/service-portal/documents/src/components/DocumentActions/DocumentActionsV3.tsx b/libs/service-portal/documents/src/components/DocumentActions/DocumentActionsV3.tsx index fbe5092c62a3..40d2f39b12d3 100644 --- a/libs/service-portal/documents/src/components/DocumentActions/DocumentActionsV3.tsx +++ b/libs/service-portal/documents/src/components/DocumentActions/DocumentActionsV3.tsx @@ -1,12 +1,11 @@ import { AlertMessage, Box, Button } from '@island.is/island-ui/core' import { IconMapIcon } from '@island.is/island-ui/core/types' -import { sendForm } from '../../utils/downloadDocumentV2' -import { useUserInfo } from '@island.is/auth/react' +import { useBffUrlGenerator } from '@island.is/react-spa/bff' import { useDocumentContext } from '../../screens/Overview/DocumentContext' const DocumentActions = () => { const { activeDocument } = useDocumentContext() - const userInfo = useUserInfo() + const bffUrlGenerator = useBffUrlGenerator() const DEFAULT_ICON: IconMapIcon = 'document' const actions = activeDocument?.actions const alert = activeDocument?.alert @@ -49,13 +48,14 @@ const DocumentActions = () => { variant="utility" icon={(a.icon as IconMapIcon) ?? DEFAULT_ICON} iconType="outline" - onClick={() => - sendForm( - activeDocument.id, - a.data ?? activeDocument.downloadUrl, - userInfo, + onClick={() => { + window.open( + bffUrlGenerator('/api', { + url: a.data ?? activeDocument.downloadUrl, + }), + '_blank', ) - } + }} > {a.title} diff --git a/libs/service-portal/documents/src/components/DocumentRenderer/PdfDocument.tsx b/libs/service-portal/documents/src/components/DocumentRenderer/PdfDocument.tsx index c28333da0e53..b697a83f9bf8 100644 --- a/libs/service-portal/documents/src/components/DocumentRenderer/PdfDocument.tsx +++ b/libs/service-portal/documents/src/components/DocumentRenderer/PdfDocument.tsx @@ -1,12 +1,12 @@ -import { useRef, useState, useEffect } from 'react' -import { useLocale } from '@island.is/localization' import { Box, Button, PdfViewer, Text } from '@island.is/island-ui/core' +import { useLocale } from '@island.is/localization' +import { useBffUrlGenerator } from '@island.is/react-spa/bff' +import { Problem } from '@island.is/react-spa/shared' import { Modal, m } from '@island.is/service-portal/core' -import { useUserInfo } from '@island.is/auth/react' +import { useEffect, useRef, useState } from 'react' import { ActiveDocumentType2 } from '../../lib/types' import { downloadFile } from '../../utils/downloadDocumentV2' import { messages } from '../../utils/messages' -import { Problem } from '@island.is/react-spa/shared' import * as styles from './DocumentRenderer.css' type PdfDocumentProps = { @@ -23,7 +23,7 @@ export const PdfDocument: React.FC = ({ onClose, }) => { const [scalePDF, setScalePDF] = useState(initScale) - const userInfo = useUserInfo() + const bffUrlGenerator = useBffUrlGenerator() const ref = useRef(null) const { formatMessage } = useLocale() @@ -120,7 +120,9 @@ export const PdfDocument: React.FC = ({ > diff --git a/libs/service-portal/documents/src/hooks/useDocumentList.ts b/libs/service-portal/documents/src/hooks/useDocumentList.ts index 29f630344b8d..d93e4b349638 100644 --- a/libs/service-portal/documents/src/hooks/useDocumentList.ts +++ b/libs/service-portal/documents/src/hooks/useDocumentList.ts @@ -1,6 +1,6 @@ import { useEffect } from 'react' import { AuthDelegationType } from '@island.is/api/schema' -import { useUserInfo } from '@island.is/auth/react' +import { useUserBirthday, useUserInfo } from '@island.is/react-spa/bff' import { useDocumentContext } from '../screens/Overview/DocumentContext' import { useDocumentsV2Query } from '../screens/Overview/Overview.generated' import differenceInYears from 'date-fns/differenceInYears' @@ -23,10 +23,12 @@ export const useDocumentList = (props?: UseDocumentListProps) => { } = useDocumentContext() const userInfo = useUserInfo() + const dateOfBirth = useUserBirthday() + const isLegal = userInfo.profile.delegationType?.includes( AuthDelegationType.LegalGuardian, ) - const dateOfBirth = userInfo?.profile.dateOfBirth + let isOver15 = false if (dateOfBirth) { isOver15 = differenceInYears(new Date(), dateOfBirth) > 15 diff --git a/libs/service-portal/documents/src/hooks/useDocumentListV3.ts b/libs/service-portal/documents/src/hooks/useDocumentListV3.ts index 0ffe4e76bf20..47d5d30340ed 100644 --- a/libs/service-portal/documents/src/hooks/useDocumentListV3.ts +++ b/libs/service-portal/documents/src/hooks/useDocumentListV3.ts @@ -1,6 +1,6 @@ import { useEffect } from 'react' import { AuthDelegationType } from '@island.is/api/schema' -import { useUserInfo } from '@island.is/auth/react' +import { useUserBirthday, useUserInfo } from '@island.is/react-spa/bff' import { useDocumentContext } from '../screens/Overview/DocumentContext' import { useDocumentsV3Query } from '../screens/Overview/Overview.generated' import differenceInYears from 'date-fns/differenceInYears' @@ -23,10 +23,11 @@ export const useDocumentListV3 = (props?: UseDocumentListProps) => { } = useDocumentContext() const userInfo = useUserInfo() + const dateOfBirth = useUserBirthday() const isLegal = userInfo.profile.delegationType?.includes( AuthDelegationType.LegalGuardian, ) - const dateOfBirth = userInfo?.profile.dateOfBirth + let isOver15 = false if (dateOfBirth) { isOver15 = differenceInYears(new Date(), dateOfBirth) > 15 diff --git a/libs/service-portal/documents/src/utils/downloadDocumentV2.ts b/libs/service-portal/documents/src/utils/downloadDocumentV2.ts index ab402cc8aa3c..c1e7d775214c 100644 --- a/libs/service-portal/documents/src/utils/downloadDocumentV2.ts +++ b/libs/service-portal/documents/src/utils/downloadDocumentV2.ts @@ -1,51 +1,26 @@ -import { User } from '@island.is/auth/react' import { ActiveDocumentType2 } from '../lib/types' +import { useBffUrlGenerator } from '@island.is/react-spa/bff' -export const sendForm = async (id: string, url: string, userInfo: User) => { - // Create form elements - const form = document.createElement('form') - const documentIdInput = document.createElement('input') - const tokenInput = document.createElement('input') - - const token = userInfo?.access_token - - if (!token) return - - form.appendChild(documentIdInput) - form.appendChild(tokenInput) - - // Form values - form.method = 'post' - form.action = url - form.target = '_blank' - - // Document Id values - documentIdInput.type = 'hidden' - documentIdInput.name = 'documentId' - documentIdInput.value = id - - // National Id values - tokenInput.type = 'hidden' - tokenInput.name = '__accessToken' - tokenInput.value = token - - document.body.appendChild(form) - form.submit() - document.body.removeChild(form) +type DownloadFileArgs = { + bffUrlGenerator: ReturnType + doc: ActiveDocumentType2 + query?: string } -export const downloadFile = async ( - doc: ActiveDocumentType2, - userInfo: User, - query?: string, -) => { +export const downloadFile = async ({ + bffUrlGenerator, + doc, + query, +}: DownloadFileArgs) => { let html: string | undefined = undefined + if (doc?.document.type === 'HTML') { html = doc.document.value && doc.document.value.length > 0 ? doc?.document.value : undefined } + if (html) { setTimeout(() => { const win = window.open('', '_blank') @@ -53,37 +28,13 @@ export const downloadFile = async ( win?.focus() }, 250) } else { - // Create form elements - const form = document.createElement('form') - const documentIdInput = document.createElement('input') - const tokenInput = document.createElement('input') - - const token = userInfo?.access_token - - if (!token) return - - form.appendChild(documentIdInput) - form.appendChild(tokenInput) - const url = query ? `${doc?.downloadUrl}?action=${query}` : doc?.downloadUrl - // Form values - form.method = 'post' - form.action = doc?.downloadUrl ? url : '' - form.target = '_blank' - - // Document Id values - documentIdInput.type = 'hidden' - documentIdInput.name = 'documentId' - documentIdInput.value = doc?.id ?? '' - - // National Id values - tokenInput.type = 'hidden' - tokenInput.name = '__accessToken' - tokenInput.value = token - - document.body.appendChild(form) - form.submit() - document.body.removeChild(form) + window.open( + bffUrlGenerator('/api', { + url, + }), + '_blank', + ) } } diff --git a/libs/service-portal/finance/src/screens/FinanceSchedule/FinanceSchedule.tsx b/libs/service-portal/finance/src/screens/FinanceSchedule/FinanceSchedule.tsx index 47e0219b7ab9..27527883b6d1 100644 --- a/libs/service-portal/finance/src/screens/FinanceSchedule/FinanceSchedule.tsx +++ b/libs/service-portal/finance/src/screens/FinanceSchedule/FinanceSchedule.tsx @@ -18,7 +18,7 @@ import { import { checkDelegation } from '@island.is/shared/utils' import FinanceScheduleTable from '../../components/FinanceScheduleTable/FinanceScheduleTable' -import { useUserInfo } from '@island.is/auth/react' +import { useUserInfo } from '@island.is/react-spa/bff' import { m as messages } from '../../lib/messages' import FinanceIntro from '../../components/FinanceIntro' import { useGetPaymentScheduleQuery } from './FinanceSchedule.generated' diff --git a/libs/service-portal/finance/src/screens/FinanceStatus/FinanceStatus.tsx b/libs/service-portal/finance/src/screens/FinanceStatus/FinanceStatus.tsx index 7aebba04c5c3..b7fe7d13c21b 100644 --- a/libs/service-portal/finance/src/screens/FinanceStatus/FinanceStatus.tsx +++ b/libs/service-portal/finance/src/screens/FinanceStatus/FinanceStatus.tsx @@ -35,7 +35,7 @@ import { FinanceStatusOrganizationType, } from './FinanceStatusData.types' import * as styles from './Table.css' -import { useUserInfo } from '@island.is/auth/react' +import { useUserInfo } from '@island.is/react-spa/bff' import FinanceIntro from '../../components/FinanceIntro' import { useGetDebtStatusQuery, diff --git a/libs/service-portal/graphql/src/lib/client.ts b/libs/service-portal/graphql/src/lib/client.ts index 3ac76f40d454..32cf0670b7cc 100644 --- a/libs/service-portal/graphql/src/lib/client.ts +++ b/libs/service-portal/graphql/src/lib/client.ts @@ -8,15 +8,11 @@ import { import { RetryLink } from '@apollo/client/link/retry' import { onError } from '@apollo/client/link/error' -import { authLink } from '@island.is/auth/react' -import { getStaticEnv } from '@island.is/shared/utils' - -const uri = - getStaticEnv('SI_PUBLIC_GRAPHQL_API') ?? 'http://localhost:4444/api/graphql' const httpLink = new HttpLink({ - uri: ({ operationName }) => `${uri}?op=${operationName}`, + uri: ({ operationName }) => `/minarsidur/bff/api/graphql?op=${operationName}`, fetch, + credentials: 'include', }) const retryLink = new RetryLink() @@ -33,7 +29,7 @@ const errorLink = onError(({ graphQLErrors, networkError }) => { }) export const client = new ApolloClient({ - link: ApolloLink.from([retryLink, errorLink, authLink, httpLink]), + link: ApolloLink.from([retryLink, errorLink, httpLink]), cache: new InMemoryCache({ typePolicies: { UserProfile: { diff --git a/libs/service-portal/health/src/screens/HealthOverview/HealthOverview.tsx b/libs/service-portal/health/src/screens/HealthOverview/HealthOverview.tsx index f1c458d7ce6f..39fc0a655a2e 100644 --- a/libs/service-portal/health/src/screens/HealthOverview/HealthOverview.tsx +++ b/libs/service-portal/health/src/screens/HealthOverview/HealthOverview.tsx @@ -1,4 +1,4 @@ -import { useUserInfo } from '@island.is/auth/react' +import { useUserInfo } from '@island.is/react-spa/bff' import { AlertMessage, Box, diff --git a/libs/service-portal/information/src/screens/BioChild/BioChild.tsx b/libs/service-portal/information/src/screens/BioChild/BioChild.tsx index 0b5153e8870c..1757711a7650 100644 --- a/libs/service-portal/information/src/screens/BioChild/BioChild.tsx +++ b/libs/service-portal/information/src/screens/BioChild/BioChild.tsx @@ -1,5 +1,5 @@ import { useParams } from 'react-router-dom' -import { useUserInfo } from '@island.is/auth/react' +import { useUserInfo } from '@island.is/react-spa/bff' import { useLocale, useNamespaces } from '@island.is/localization' import { formatNationalId, diff --git a/libs/service-portal/information/src/screens/ChildCustody/ChildCustody.tsx b/libs/service-portal/information/src/screens/ChildCustody/ChildCustody.tsx index 4660a6b6c34d..d995016ed782 100644 --- a/libs/service-portal/information/src/screens/ChildCustody/ChildCustody.tsx +++ b/libs/service-portal/information/src/screens/ChildCustody/ChildCustody.tsx @@ -1,4 +1,4 @@ -import { useUserInfo } from '@island.is/auth/react' +import { useUserInfo } from '@island.is/react-spa/bff' import { defineMessage } from 'react-intl' import { Box, diff --git a/libs/service-portal/information/src/screens/Company/CompanyInfo.tsx b/libs/service-portal/information/src/screens/Company/CompanyInfo.tsx index daf86ffc8cfa..87384a49f045 100644 --- a/libs/service-portal/information/src/screens/Company/CompanyInfo.tsx +++ b/libs/service-portal/information/src/screens/Company/CompanyInfo.tsx @@ -13,7 +13,7 @@ import { UserInfoLine, } from '@island.is/service-portal/core' import { dateFormat } from '@island.is/shared/constants' -import { useUserInfo } from '@island.is/auth/react' +import { useUserInfo } from '@island.is/react-spa/bff' import { mCompany } from '../../lib/messages' import { useCompanyRegistryCompanyQuery } from './Company.generated' diff --git a/libs/service-portal/information/src/screens/UserInfo/UserInfo.tsx b/libs/service-portal/information/src/screens/UserInfo/UserInfo.tsx index 467fcb1bdf32..0dea2ff8c2e0 100644 --- a/libs/service-portal/information/src/screens/UserInfo/UserInfo.tsx +++ b/libs/service-portal/information/src/screens/UserInfo/UserInfo.tsx @@ -14,7 +14,7 @@ import { InfoLine, InfoLineStack, } from '@island.is/service-portal/core' -import { useUserInfo } from '@island.is/auth/react' +import { useUserInfo } from '@island.is/react-spa/bff' import { natRegGenderMessageDescriptorRecord, diff --git a/libs/service-portal/information/src/screens/UserInfoOverview/UserInfoOverview.tsx b/libs/service-portal/information/src/screens/UserInfoOverview/UserInfoOverview.tsx index 7d3878a15bf9..a4d848a24b7d 100644 --- a/libs/service-portal/information/src/screens/UserInfoOverview/UserInfoOverview.tsx +++ b/libs/service-portal/information/src/screens/UserInfoOverview/UserInfoOverview.tsx @@ -7,7 +7,7 @@ import { m, THJODSKRA_SLUG, } from '@island.is/service-portal/core' -import { useUserInfo } from '@island.is/auth/react' +import { useUserInfo } from '@island.is/react-spa/bff' import { FamilyMemberCard } from '../../components/FamilyMemberCard/FamilyMemberCard' import { spmm } from '../../lib/messages' diff --git a/libs/service-portal/information/src/screens/UserProfile/UserProfile.tsx b/libs/service-portal/information/src/screens/UserProfile/UserProfile.tsx index 2dc492564b5d..04e0baed7fe0 100644 --- a/libs/service-portal/information/src/screens/UserProfile/UserProfile.tsx +++ b/libs/service-portal/information/src/screens/UserProfile/UserProfile.tsx @@ -2,7 +2,7 @@ import { ISLANDIS_SLUG, IntroHeader, m } from '@island.is/service-portal/core' import ProfileForm from '../../components/PersonalInformation/Forms/ProfileForm/ProfileForm' import { useUserProfile } from '@island.is/service-portal/graphql' import { useLocale } from '@island.is/localization' -import { useUserInfo } from '@island.is/auth/react' +import { useUserInfo } from '@island.is/react-spa/bff' import { msg } from '../../lib/messages' const UserProfile = () => { diff --git a/libs/service-portal/occupational-licenses/src/screens/v1/EducationalDetail/EducationalDetail.tsx b/libs/service-portal/occupational-licenses/src/screens/v1/EducationalDetail/EducationalDetail.tsx index 51fb99fd8dac..753898e05fef 100644 --- a/libs/service-portal/occupational-licenses/src/screens/v1/EducationalDetail/EducationalDetail.tsx +++ b/libs/service-portal/occupational-licenses/src/screens/v1/EducationalDetail/EducationalDetail.tsx @@ -9,7 +9,7 @@ import { formSubmit, } from '@island.is/service-portal/core' import { useLocale, useNamespaces } from '@island.is/localization' -import { useUserInfo } from '@island.is/auth/react' +import { useUserBirthday, useUserInfo } from '@island.is/react-spa/bff' import { LicenseDetail } from '../../../components/LicenseDetail' import { useEffect, useState } from 'react' import { m } from '@island.is/service-portal/core' @@ -26,7 +26,7 @@ export const EducationDetail = () => { useNamespaces('sp.occupational-licenses') const user = useUserInfo() - const birthday = user.profile.dateOfBirth + const dateOfBirth = useUserBirthday() const { formatDateFns, formatMessage } = useLocale() @@ -100,7 +100,9 @@ export const EducationDetail = () => { } isOldEducationLicense={isOldEducationLicense} name={user.profile.name} - dateOfBirth={birthday ? formatDateFns(birthday, 'dd.MM.yyyy') : undefined} + dateOfBirth={ + dateOfBirth ? formatDateFns(dateOfBirth, 'dd.MM.yyyy') : undefined + } profession={programme} licenseType={programme} publisher={license.type} diff --git a/libs/service-portal/occupational-licenses/src/screens/v1/HealthDirectorateDetail/HealthDirectorateDetail.tsx b/libs/service-portal/occupational-licenses/src/screens/v1/HealthDirectorateDetail/HealthDirectorateDetail.tsx index 051a4af284de..992066607e8e 100644 --- a/libs/service-portal/occupational-licenses/src/screens/v1/HealthDirectorateDetail/HealthDirectorateDetail.tsx +++ b/libs/service-portal/occupational-licenses/src/screens/v1/HealthDirectorateDetail/HealthDirectorateDetail.tsx @@ -1,18 +1,17 @@ -import { useParams } from 'react-router-dom' -import { useGetHealthDirectorateLicenseByIdQuery } from './HealthDirectorateDetail.generated' import { Box } from '@island.is/island-ui/core' +import { useLocale, useNamespaces } from '@island.is/localization' +import { useUserBirthday, useUserInfo } from '@island.is/react-spa/bff' import { CardLoader, EmptyState, ErrorScreen, HEALTH_DIRECTORATE_SLUG, + m, } from '@island.is/service-portal/core' -import { useLocale, useNamespaces } from '@island.is/localization' -import { useUserInfo } from '@island.is/auth/react' +import { useParams } from 'react-router-dom' import { LicenseDetail } from '../../../components/LicenseDetail' import { olMessage as om } from '../../../lib/messages' -import { m } from '@island.is/service-portal/core' -import { OccupationalLicenseV2LicenseType } from '@island.is/service-portal/graphql' +import { useGetHealthDirectorateLicenseByIdQuery } from './HealthDirectorateDetail.generated' type UseParams = { id: string @@ -22,7 +21,7 @@ export const EducationDetail = () => { const { id } = useParams() as UseParams useNamespaces('sp.occupational-licenses') const user = useUserInfo() - const birthday = user.profile.dateOfBirth + const dateOfBirth = useUserBirthday() const { formatDateFns, formatMessage } = useLocale() const { data, loading, error } = useGetHealthDirectorateLicenseByIdQuery({ @@ -61,7 +60,9 @@ export const EducationDetail = () => { ? license.number : undefined } - dateOfBirth={birthday ? formatDateFns(birthday, 'dd.MM.yyyy') : undefined} + dateOfBirth={ + dateOfBirth ? formatDateFns(dateOfBirth, 'dd.MM.yyyy') : undefined + } profession={license.profession} licenseType={license.type} dateOfIssue={ diff --git a/libs/service-portal/sessions/src/screens/Sessions/Sessions.tsx b/libs/service-portal/sessions/src/screens/Sessions/Sessions.tsx index 243599f51862..190747e5f618 100644 --- a/libs/service-portal/sessions/src/screens/Sessions/Sessions.tsx +++ b/libs/service-portal/sessions/src/screens/Sessions/Sessions.tsx @@ -1,4 +1,4 @@ -import { useUserInfo } from '@island.is/auth/react' +import { useUserInfo } from '@island.is/react-spa/bff' import { Problem } from '@island.is/react-spa/shared' import * as kennitala from 'kennitala' import React, { useState } from 'react' diff --git a/libs/service-portal/signature-collection/src/screens/Parliamentary/index.tsx b/libs/service-portal/signature-collection/src/screens/Parliamentary/index.tsx index ac82acb3ea19..3f8600402f5e 100644 --- a/libs/service-portal/signature-collection/src/screens/Parliamentary/index.tsx +++ b/libs/service-portal/signature-collection/src/screens/Parliamentary/index.tsx @@ -9,7 +9,7 @@ import { m } from '../../lib/messages' import OwnerView from './OwnerView' import SigneeView from '../shared/SigneeView' import { useGetCurrentCollection, useIsOwner } from '../../hooks' -import { useUserInfo } from '@island.is/auth/react' +import { useUserInfo } from '@island.is/react-spa/bff' import { AuthDelegationType } from '../../types/schema' const SignatureListsParliamentary = () => { diff --git a/libs/shared/components/src/auth/UserMenu/UserDelegations.tsx b/libs/shared/components/src/auth/UserMenu/UserDelegations.tsx index b4db29d4d10f..707a82fa022f 100644 --- a/libs/shared/components/src/auth/UserMenu/UserDelegations.tsx +++ b/libs/shared/components/src/auth/UserMenu/UserDelegations.tsx @@ -1,6 +1,6 @@ import { Box, Stack } from '@island.is/island-ui/core' import { useLocale } from '@island.is/localization' -import { useAuth, useUserInfo } from '@island.is/react-spa/bff' +import { useBff, useUserInfo } from '@island.is/react-spa/bff' import { userMessages } from '@island.is/shared/translations' import { UserDropdownItem } from './UserDropdownItem' import { UserTopicCard } from './UserTopicCard' @@ -16,7 +16,7 @@ export const UserDelegations = ({ }: UserDelegationsProps) => { const user = useUserInfo() const { formatMessage } = useLocale() - const { switchUser } = useAuth() + const { switchUser } = useBff() const actor = user.profile.actor return ( diff --git a/libs/shared/components/src/auth/UserMenu/UserMenu.tsx b/libs/shared/components/src/auth/UserMenu/UserMenu.tsx index 875b16169a25..a2ffae0c76fb 100644 --- a/libs/shared/components/src/auth/UserMenu/UserMenu.tsx +++ b/libs/shared/components/src/auth/UserMenu/UserMenu.tsx @@ -1,5 +1,5 @@ import { Box, Hidden } from '@island.is/island-ui/core' -import { useAuth } from '@island.is/react-spa/bff' +import { useBff } from '@island.is/react-spa/bff' import { useEffect, useState } from 'react' import { UserButton } from './UserButton' import { UserDropdown } from './UserDropdown' @@ -29,7 +29,7 @@ export const UserMenu = ({ const [dropdownState, setDropdownState] = useState<'closed' | 'open'>( 'closed', ) - const { signOut, switchUser, userInfo: user } = useAuth() + const { signOut, switchUser, userInfo: user } = useBff() const handleClick = () => { setDropdownState(dropdownState === 'open' ? 'closed' : 'open') From 6fedbee6ff7b16caef59043b2aeac03c5108aa20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B3n=20Levy?= Date: Wed, 23 Oct 2024 14:29:50 +0000 Subject: [PATCH 141/248] chore: remove nx-command impl (#16532) * chore: move nx runcommand cli to a new PR * chore: commit save point * chore: commit save point --- infra/src/cli/cli.ts | 27 -- infra/src/common/logging.ts | 25 -- infra/src/common/nx-command.ts | 65 ----- .../dsl/value-files-generators/local-setup.ts | 247 +++++++++--------- infra/src/generated/nx-project-schema.ts | 220 ---------------- infra/src/types/index.ts | 1 - infra/src/types/nx-project.ts | 10 - 7 files changed, 128 insertions(+), 467 deletions(-) delete mode 100644 infra/src/common/logging.ts delete mode 100644 infra/src/common/nx-command.ts delete mode 100644 infra/src/generated/nx-project-schema.ts delete mode 100644 infra/src/types/index.ts delete mode 100644 infra/src/types/nx-project.ts diff --git a/infra/src/cli/cli.ts b/infra/src/cli/cli.ts index 93a089ab1384..bfce0358ae0a 100644 --- a/infra/src/cli/cli.ts +++ b/infra/src/cli/cli.ts @@ -9,33 +9,6 @@ import { renderLocalServices, runLocalServices } from './render-local-mocks' const cli = yargs(process.argv.slice(2)) .scriptName('yarn cli') - .command( - 'nx ', - 'Run an NX command from the monorepo', - (yargs) => { - return yargs.positional('nxCommand', { - describe: 'The NX command to run (e.g., run my-app:build)', - type: 'string', - array: true, - }) - }, - async (argv) => { - const commandArr = argv.nxCommand as string[] - const command = commandArr.join(' ') - - try { - const result = await nxCommand({ command }) - if (result.stdout) { - console.log(result.stdout) // Raw output, e.g., JSON - } - if (result.stderr) { - logger.error(`NX Warning/Error:\n${result.stderr}`) - } - } catch (error) { - logger.error(`Error running NX command: ${error}`) - } - }, - ) .command( 'render-env', 'Render a chart for environment', diff --git a/infra/src/common/logging.ts b/infra/src/common/logging.ts deleted file mode 100644 index 6b4d1ec1b776..000000000000 --- a/infra/src/common/logging.ts +++ /dev/null @@ -1,25 +0,0 @@ -import winston from 'winston' - -const logLevelEnv = process.env.LOG_LEVEL?.toLowerCase() || 'info' -const logger = winston.createLogger({ - level: logLevelEnv, - format: winston.format.combine( - winston.format.colorize(), - winston.format.printf(({ level, message, timestamp, ...meta }) => { - const logMessage = - typeof message === 'object' ? JSON.stringify(message, null, 2) : message - // Stringify meta if it's an object and not empty - const metaString = - meta && Object.keys(meta).length ? JSON.stringify(meta, null, 2) : '' - - return `[${level}]: ${logMessage} ${metaString}` - }), - ), - transports: [ - new winston.transports.Console({ - level: logLevelEnv, - }), - ], -}) - -export { logger } diff --git a/infra/src/common/nx-command.ts b/infra/src/common/nx-command.ts deleted file mode 100644 index 791ff6a9edfc..000000000000 --- a/infra/src/common/nx-command.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { exec } from 'child_process' -import { promisify } from 'util' -import { join } from 'path' -import { rootDir } from '../dsl/consts' -import { z } from 'zod' // Import Zod for runtime validation - -const execPromise = promisify(exec) - -export const nxCommand = async (options: { - command: string - parseJson?: boolean - schema?: z.ZodSchema // Accept any Zod schema for runtime validation -}): Promise => { - const { command, parseJson = false, schema } = options - - try { - const { stdout, stderr } = await execPromise(`yarn nx ${command}`, { - cwd: rootDir, - }) - - if (stderr) { - console.warn(stderr) - } - - // If parseJson is true, validate the JSON output using the provided schema - if (parseJson && schema) { - return safelyParseAndValidateJson(stdout, schema) - } - - // If not parsing JSON, return raw stdout - return stdout as unknown as T - } catch (error) { - handleNxCommandError(error) - throw error - } -} - -/** - * Safely parses JSON and validates it using the provided Zod schema. - */ -const safelyParseAndValidateJson = ( - jsonString: string, - schema: z.ZodSchema, -): T => { - try { - const parsedJson = JSON.parse(jsonString) - return schema.parse(parsedJson) // Validate with the provided schema - } catch (error) { - console.error(`Failed to parse/validate JSON from NX output: ${error}`) - throw new Error( - 'Invalid JSON format or validation error in NX command output.', - ) - } -} - -/** - * Handles errors thrown during the execution of the NX command. - */ -const handleNxCommandError = (error: unknown): void => { - if (error instanceof Error) { - console.error(`Failed to run NX command: ${error.message}`) - } else { - console.error('An unknown error occurred while running the NX command.') - } -} diff --git a/infra/src/dsl/value-files-generators/local-setup.ts b/infra/src/dsl/value-files-generators/local-setup.ts index ecb09d8534be..4073157cebe8 100644 --- a/infra/src/dsl/value-files-generators/local-setup.ts +++ b/infra/src/dsl/value-files-generators/local-setup.ts @@ -5,50 +5,50 @@ import { } from '../types/output-types' import { Localhost } from '../localhost-runtime' import { shouldIncludeEnv } from '../../cli/render-env-vars' -import { writeFile } from 'fs/promises' +import { readFile, writeFile } from 'fs/promises' +import { globSync } from 'glob' import { join } from 'path' import { rootDir } from '../consts' -import { logger } from '../../common/logging' -import { nxCommand } from '../../common/nx-command' -import { z } from 'zod' -import { type ProjectInfo, nxProjectSchema } from '../../types/nx-project' +import { logger } from '../../common' -/** - * Maps a service name to its corresponding NX project name and path. - * Uses nxCommand to retrieve and validate the project metadata using the nxProjectSchema. - */ -export const mapServiceToNXname = async ( - serviceName: string, -): Promise => { - try { - if (serviceName.startsWith('services-bff-')) { - serviceName = 'services-bff' - } - - const validatedProjectMeta = await nxCommand({ - command: `show project ${serviceName} --json --output-style static`, - parseJson: true, - schema: nxProjectSchema, // Pass the actual Zod schema for runtime validation - }) +const mapServiceToNXname = async (serviceName: string) => { + const projectRootPath = join(__dirname, '..', '..', '..', '..') + const projects = globSync(['apps/*/project.json', 'apps/*/*/project.json'], { + cwd: projectRootPath, + }) - if (!validatedProjectMeta.name || !validatedProjectMeta.sourceRoot) { - throw new Error( - `Project metadata is missing required fields: name or sourceRoot.`, - ) - } + // This is a hack to make sure we are running `services-bff` project with the desired infra config. + // We have multiple infra files under the `services-bff` project, e.g. `services-bff-admin-portal`, `services-bff-my-pages-portal`, etc. + // For the project to run correctly, we need to run the `services-bff` project. + if (serviceName.startsWith('services-bff-')) { + serviceName = 'services-bff' + } - return { - serviceName: validatedProjectMeta.name, - projectPath: validatedProjectMeta.sourceRoot, - } - } catch (error) { - logger.error('Error in mapServiceToNXname:', error) + const nxName = ( + await Promise.all( + projects.map(async (path) => { + const project: { + name: string + targets: { [name: string]: any } + } = JSON.parse( + await readFile(join(projectRootPath, path), { + encoding: 'utf-8', + }), + ) + return typeof project.targets[`service-${serviceName}`] !== 'undefined' + ? project.name + : null + }), + ) + ).filter((name) => name !== null) as string[] - if (error instanceof Error) { - throw new Error(`Unexpected error: ${error.message}`) - } - throw new Error('An unknown error occurred.') - } + if (nxName.length > 1) + throw new Error( + `More then one NX projects found with service name ${serviceName} - ${nxName.join( + ',', + )}`, + ) + return nxName.length === 1 ? nxName[0] : serviceName } /** @@ -70,8 +70,8 @@ export const getLocalrunValueFile = async ( ): Promise => { logger.debug('getLocalrunValueFile', { runtime, services }) + logger.debug('Process services', { services }) const dockerComposeServices = {} as Services - for (const [name, service] of Object.entries(services)) { const portConfig = runtime.ports[name] ? { PORT: runtime.ports[name].toString() } @@ -79,23 +79,21 @@ export const getLocalrunValueFile = async ( const serviceNXName = await mapServiceToNXname(name) logger.debug('Process service', { name, service, serviceNXName }) - - if (serviceNXName) { - dockerComposeServices[name] = { - env: { - ...Object.entries(service.env) - .filter(shouldIncludeEnv) - .reduce((acc, [k, v]) => ({ ...acc, [k]: v }), {}), - PROD_MODE: 'true', - ...portConfig, - }, - commands: [ - `cd "${rootDir}"`, - `. ./.env.${serviceNXName.serviceName}`, // `source` is bashism - `echo "Starting ${name} in $PWD"`, - `yarn nx serve ${serviceNXName.serviceName}`, - ], - } + dockerComposeServices[name] = { + env: Object.assign( + {}, + Object.entries(service.env) + .filter(shouldIncludeEnv) + .reduce((acc, [k, v]) => ({ ...acc, [k]: v }), {}), + { PROD_MODE: 'true' }, + portConfig, + ) as Record, + commands: [ + `cd "${rootDir}"`, + `. ./.env.${serviceNXName}`, // `source` is bashism + `echo "Starting ${name} in $PWD"`, + `yarn nx serve ${serviceNXName}`, + ], } } @@ -104,83 +102,91 @@ export const getLocalrunValueFile = async ( dockerComposeServices, [`${firstService}.env`]: dockerComposeServices[firstService]?.env, }) - await Promise.all( - Object.entries(dockerComposeServices).map(async ([name, svc]) => { - const result = await mapServiceToNXname(name) - if (result === null) { - throw new Error('No NX project found for the given service name.') - } - const { serviceName } = result - logger.debug(`Writing env to file for ${name}`, { name, serviceName }) - if (options.dryRun) return - await writeFile( - join(rootDir, `.env.${serviceName}`), - Object.entries(svc.env) - .filter(([key, value]) => shouldIncludeEnv(key) && !!value) - .map(([key, value]) => { - const escapedValue = value - .replace(/'/g, "'\\''") - .replace(/[\n\r]/g, '') - return `export ${key}='${escapedValue}'` - }) - .join('\n'), - { encoding: 'utf-8' }, - ) - }), + Object.entries(dockerComposeServices).map( + async ([name, svc]: [string, LocalrunService]) => { + const serviceNXName = await mapServiceToNXname(name) + logger.debug(`Writing env to file for ${name}`, { name, serviceNXName }) + if (options.dryRun) return + await writeFile( + join(rootDir, `.env.${serviceNXName}`), + Object.entries(svc.env) + .filter(([name, value]) => shouldIncludeEnv(name) && !!value) + .map(([name, value]) => { + // Basic shell sanitation + const escapedValue = value + .replace(/'/g, "'\\''") + .replace(/[\n\r]/g, '') + const localizedValue = escapedValue + // .replace( + // /^(https?:\/\/)[^/]+(?=$|\/)/g, + // '$1localhost', + // ) + const exportedKeyValue = `export ${name}='${localizedValue}'` + logger.debug('Env rewrite debug', { + escapedValue, + localizedValue, + exportedKeyValue, + }) + + return exportedKeyValue + }) + .join('\n'), + { encoding: 'utf-8' }, + ) + }, + ), ) - const mocksConfigs = Object.entries(runtime.mocks).reduce( - (acc, [name, target]) => ({ - ports: [...acc.ports, runtime.ports[name]], - configs: [ - ...acc.configs, - { - protocol: 'http', - name: name, - port: runtime.ports[name], - stubs: [ - { - predicates: [{ equals: {} }], - responses: [ - { - proxy: { - to: target.replace('localhost', 'host.docker.internal'), - mode: 'proxyAlways', - predicateGenerators: [ - { - matches: { - method: true, - path: true, - query: true, - body: true, + (acc, [name, target]) => { + return { + ports: [...acc.ports, runtime.ports[name]], + configs: [ + ...acc.configs, + { + protocol: 'http', + name: name, + port: runtime.ports[name], + stubs: [ + { + predicates: [{ equals: {} }], + responses: [ + { + proxy: { + to: target.replace('localhost', 'host.docker.internal'), + mode: 'proxyAlways', + predicateGenerators: [ + { + matches: { + method: true, + path: true, + query: true, + body: true, + }, }, - }, - ], + ], + }, }, - }, - ], - }, - ], - }, - ], - }), + ], + }, + ], + }, + ], + } + }, { ports: [] as number[], configs: [] as any[] }, ) - const defaultMountebankConfig = 'mountebank-imposter-config.json' logger.debug('Writing default mountebank config to file', { defaultMountebankConfig, mocksConfigs, }) - - if (!options.dryRun) { + if (!options.dryRun) await writeFile( defaultMountebankConfig, JSON.stringify({ imposters: mocksConfigs.configs }), { encoding: 'utf-8' }, ) - } const mocksObj = { containerer: 'docker', @@ -202,16 +208,19 @@ export const getLocalrunValueFile = async ( mocksObj.image, mocksObj.command, ] - + const mocksStr = mocks.join(' ') logger.debug(`Docker command for mocks:`, { mocks }) const renderedServices: Services = {} + logger.debug('Debugging dockerComposeServices', { + dockerComposeServices, + }) for (const [name, service] of Object.entries(dockerComposeServices)) { renderedServices[name] = { commands: service.commands, env: service.env } + logger.debug(`Docker command for ${name}:`, { command: service.commands }) } - return { services: renderedServices, - mocks: mocks.join(' '), + mocks: mocksStr, } } diff --git a/infra/src/generated/nx-project-schema.ts b/infra/src/generated/nx-project-schema.ts deleted file mode 100644 index bbd06e64aa60..000000000000 --- a/infra/src/generated/nx-project-schema.ts +++ /dev/null @@ -1,220 +0,0 @@ -// This file is auto-generated. Do not edit directly. - -import { z } from 'zod' - -export const nxProjectSchema = z.object({ - name: z - .string() - .describe("Project's name. Optional if specified in workspace.json") - .optional(), - root: z - .string() - .describe("Project's location relative to the root of the workspace") - .optional(), - sourceRoot: z - .string() - .describe( - "The location of project's sources relative to the root of the workspace", - ) - .optional(), - projectType: z - .enum(['library', 'application']) - .describe('Type of project supported') - .optional(), - generators: z - .record(z.any()) - .describe('List of default values used by generators') - .optional(), - namedInputs: z - .record(z.any()) - .describe('Named inputs used by inputs defined in targets') - .optional(), - targets: z - .record( - z.object({ - executor: z - .string() - .describe('The function that Nx will invoke when you run this target') - .optional(), - options: z.record(z.any()).optional(), - outputs: z.array(z.string()).optional(), - defaultConfiguration: z - .string() - .describe( - 'The name of a configuration to use as the default if a configuration is not provided', - ) - .optional(), - configurations: z - .record(z.record(z.any())) - .describe( - 'provides extra sets of values that will be merged into the options map', - ) - .optional(), - inputs: z.any().optional(), - dependsOn: z - .array( - z.any().superRefine((x, ctx) => { - const schemas = [ - z.string(), - z - .object({ - projects: z - .any() - .superRefine((x, ctx) => { - const schemas = [ - z.string().describe('A project name'), - z - .array(z.string()) - .describe('An array of project names'), - ] - const errors = schemas.reduce( - (errors, schema) => - ((result) => - result.error - ? [...errors, result.error] - : errors)(schema.safeParse(x)), - [], - ) - if (schemas.length - errors.length !== 1) { - ctx.addIssue({ - path: ctx.path, - code: 'invalid_union', - unionErrors: errors, - message: 'Invalid input: Should pass single schema', - }) - } - }) - .optional(), - dependencies: z.boolean().optional(), - target: z - .string() - .describe('The name of the target.') - .optional(), - params: z - .enum(['ignore', 'forward']) - .describe('Configuration for params handling.') - .default('ignore'), - }) - .strict() - .and( - z.any().superRefine((x, ctx) => { - const schemas = [ - z.any(), - z.any(), - z - .any() - .refine( - (value) => - !z.union([z.any(), z.any()]).safeParse(value) - .success, - 'Invalid input: Should NOT be valid against schema', - ), - ] - const errors = schemas.reduce( - (errors, schema) => - ((result) => - result.error ? [...errors, result.error] : errors)( - schema.safeParse(x), - ), - [], - ) - if (schemas.length - errors.length !== 1) { - ctx.addIssue({ - path: ctx.path, - code: 'invalid_union', - unionErrors: errors, - message: 'Invalid input: Should pass single schema', - }) - } - }), - ), - ] - const errors = schemas.reduce( - (errors, schema) => - ((result) => - result.error ? [...errors, result.error] : errors)( - schema.safeParse(x), - ), - [], - ) - if (schemas.length - errors.length !== 1) { - ctx.addIssue({ - path: ctx.path, - code: 'invalid_union', - unionErrors: errors, - message: 'Invalid input: Should pass single schema', - }) - } - }), - ) - .optional(), - command: z - .string() - .describe('A shorthand for using the nx:run-commands executor') - .optional(), - cache: z - .boolean() - .describe('Specifies if the given target should be cacheable') - .optional(), - parallelism: z - .boolean() - .describe( - 'Whether this target can be run in parallel with other tasks', - ) - .default(true), - metadata: z - .object({ - description: z - .string() - .describe('A description of the target') - .optional(), - }) - .catchall(z.any()) - .describe('Metadata about the target') - .optional(), - syncGenerators: z - .array(z.string()) - .describe( - 'List of generators to run before the target to ensure the workspace is up to date', - ) - .optional(), - }), - ) - .describe( - 'Configures all the targets which define what tasks you can run against the project', - ) - .optional(), - tags: z.array(z.string()).optional(), - implicitDependencies: z.array(z.string()).optional(), - metadata: z - .object({ - description: z - .string() - .describe('A description of the project.') - .optional(), - }) - .catchall(z.any()) - .describe('Metadata about the project.') - .optional(), - release: z - .object({ - version: z - .object({ - generator: z - .string() - .describe( - 'The version generator to use. Defaults to @nx/js:release-version.', - ) - .optional(), - generatorOptions: z - .record(z.any()) - .describe('Options for the version generator.') - .optional(), - }) - .describe('Configuration for the nx release version command.') - .optional(), - }) - .describe('Configuration for the nx release commands.') - .optional(), -}) -export type NxProjectSchema = z.infer diff --git a/infra/src/types/index.ts b/infra/src/types/index.ts deleted file mode 100644 index d572bc7ee94e..000000000000 --- a/infra/src/types/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './nx-project' diff --git a/infra/src/types/nx-project.ts b/infra/src/types/nx-project.ts deleted file mode 100644 index 3ffb3016e61d..000000000000 --- a/infra/src/types/nx-project.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { nxProjectSchema } from '../generated/nx-project-schema' -import { z } from 'zod' - -export interface ProjectInfo { - serviceName: string - projectPath: string -} - -export type NxProjectSchema = z.infer -export { nxProjectSchema } From 6bbe4188f18a168725e8a7a206f8e82188d5d593 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Wed, 23 Oct 2024 15:17:45 +0000 Subject: [PATCH 142/248] Update infra setup --- apps/services/bff/infra/admin-portal.infra.ts | 3 +- .../bff/infra/utils/createPortalEnv.ts | 92 ------------------- infra/src/dsl/bff.ts | 45 +++++---- infra/src/dsl/feature-values.spec.ts | 1 + infra/src/dsl/types/input-types.ts | 1 + 5 files changed, 33 insertions(+), 109 deletions(-) delete mode 100644 apps/services/bff/infra/utils/createPortalEnv.ts diff --git a/apps/services/bff/infra/admin-portal.infra.ts b/apps/services/bff/infra/admin-portal.infra.ts index f4399700f16a..870a65fa35b6 100644 --- a/apps/services/bff/infra/admin-portal.infra.ts +++ b/apps/services/bff/infra/admin-portal.infra.ts @@ -24,7 +24,8 @@ export const serviceSetup = ( }, }) .bff({ - key: 'stjornbord', + key, + clientId: `@admin.island.is/bff-${key}`, clientName, services, }) diff --git a/apps/services/bff/infra/utils/createPortalEnv.ts b/apps/services/bff/infra/utils/createPortalEnv.ts deleted file mode 100644 index b3b2ad0a8a9e..000000000000 --- a/apps/services/bff/infra/utils/createPortalEnv.ts +++ /dev/null @@ -1,92 +0,0 @@ -/* eslint-disable @nx/enforce-module-boundaries */ -// FIXME: this file can be removed since the DSL is handling this now -import { json, ref } from '../../../../../infra/src/dsl/dsl' -import { - adminPortalScopes, - servicePortalScopes, -} from '../../../../../libs/auth/scopes/src/index' -import { FIVE_SECONDS_IN_MS } from '../../src/app/constants/time' -import { BffInfraServices } from '../../../../../infra/src/dsl/types/input-types' - -const ONE_HOUR_IN_MS = 60 * 60 * 1000 -const ONE_WEEK_IN_MS = ONE_HOUR_IN_MS * 24 * 7 - -type PortalKeys = 'stjornbord' | 'minarsidur' - -const getScopes = (key: PortalKeys) => { - switch (key) { - case 'minarsidur': - return servicePortalScopes - - case 'stjornbord': - return adminPortalScopes - - default: - throw new Error('Invalid BFF client') - } -} - -export const createPortalEnv = ( - key: PortalKeys, - services: BffInfraServices, -) => { - return { - // Idenity server - IDENTITY_SERVER_CLIENT_SCOPES: json(getScopes(key)), - IDENTITY_SERVER_CLIENT_ID: `@admin.island.is/bff-${key}`, - IDENTITY_SERVER_ISSUER_URL: { - local: 'https://identity-server.dev01.devland.is', - dev: 'https://identity-server.dev01.devland.is', - staging: 'https://identity-server.staging01.devland.is', - prod: 'https://innskra.island.is', - }, - // BFF - BFF_NAME: { - local: key, - dev: key, - staging: key, - prod: key, - }, - BFF_CLIENT_KEY_PATH: `/${key}`, - BFF_PAR_SUPPORT_ENABLED: 'false', - BFF_ALLOWED_REDIRECT_URIS: { - local: json(['http://localhost:4200/stjornbord']), - dev: json(['https://featbff-beta.dev01.devland.is']), - staging: json(['https://beta.staging01.devland.is']), - prod: json(['https://island.is']), - }, - BFF_CLIENT_BASE_URL: { - local: 'http://localhost:4200', - dev: 'https://featbff-beta.dev01.devland.is', - staging: 'https://beta.staging01.devland.is', - prod: 'https://island.is', - }, - BFF_LOGOUT_REDIRECT_URI: { - local: 'http://localhost:4200/stjornbord', - dev: 'https://featbff-beta.dev01.devland.is', - staging: 'https://beta.staging01.devland.is', - prod: 'https://island.is', - }, - BFF_CALLBACKS_BASE_PATH: { - local: `http://localhost:3010/${key}/bff/callbacks`, - dev: `https://featbff-beta.dev01.devland.is/${key}/bff/callbacks`, - staging: `https://beta.staging01.devland.is/${key}/bff/callbacks`, - prod: `https://island.is/${key}/bff/callbacks`, - }, - BFF_PROXY_API_ENDPOINT: ref((h) => `http://${h.svc(services.api)}`), - BFF_ALLOWED_EXTERNAL_API_URLS: { - local: json(['http://localhost:3377/download/v1']), - dev: json(['https://api.dev01.devland.is']), - staging: json(['https://api.staging01.devland.is']), - prod: json(['https://api.island.is']), - }, - /** - * The TTL should be aligned with the lifespan of the Ids client refresh token. - * We also subtract 5 seconds from the TTL to handle latency and clock drift. - */ - BFF_CACHE_USER_PROFILE_TTL_MS: ( - ONE_HOUR_IN_MS - FIVE_SECONDS_IN_MS - ).toString(), - BFF_LOGIN_ATTEMPT_TTL_MS: ONE_WEEK_IN_MS.toString(), - } -} diff --git a/infra/src/dsl/bff.ts b/infra/src/dsl/bff.ts index 37c510ec82ac..86f1ec8b3d3d 100644 --- a/infra/src/dsl/bff.ts +++ b/infra/src/dsl/bff.ts @@ -17,9 +17,7 @@ export const getScopes = (key: PortalKeys) => { } } -export const bffConfig = (info: BffInfo) => { - const { key, services, clientName } = info - +export const bffConfig = ({ key, services, clientName, clientId }: BffInfo) => { const getBaseUrl = (ctx: Context) => ctx.featureDeploymentName ? `${ctx.featureDeploymentName}.${ctx.env.domain}` @@ -30,7 +28,7 @@ export const bffConfig = (info: BffInfo) => { return { env: { IDENTITY_SERVER_CLIENT_SCOPES: json(getScopes(key)), - IDENTITY_SERVER_CLIENT_ID: `@admin.island.is/bff-${key}`, + IDENTITY_SERVER_CLIENT_ID: clientId, IDENTITY_SERVER_ISSUER_URL: { dev: 'https://identity-server.dev01.devland.is', staging: 'https://identity-server.staging01.devland.is', @@ -45,20 +43,35 @@ export const bffConfig = (info: BffInfo) => { BFF_CLIENT_KEY_PATH: `/${key}`, BFF_PAR_SUPPORT_ENABLED: 'false', BFF_CLIENT_BASE_URL: { - dev: ref((h) => h.svc(`https://${getBaseUrl(h)}`)), - staging: ref((h) => h.svc(`https://${getBaseUrl(h)}`)), + local: 'http://localhost:4200', + dev: ref((ctx) => ctx.svc(`https://${getBaseUrl(ctx)}`)), + staging: ref((ctx) => ctx.svc(`https://${getBaseUrl(ctx)}`)), + prod: 'https://island.is', + }, + BFF_ALLOWED_REDIRECT_URIS: { + local: json(['http://localhost:4200/stjornbord']), + dev: ref((ctx) => json([`https://${getBaseUrl(ctx)}`])), + staging: ref((ctx) => json([`https://${getBaseUrl(ctx)}`])), prod: 'https://island.is', - local: ref((h) => h.svc('http://localhost:4200')), }, - BFF_ALLOWED_REDIRECT_URIS: ref((ctx) => - json([`https://${getBaseUrl(ctx)}`]), - ), - // BFF_CLIENT_BASE_URL: ref((ctx) => `https://${getBaseUrl(ctx)}`), - BFF_LOGOUT_REDIRECT_URI: ref((ctx) => `https://${getBaseUrl(ctx)}`), - BFF_CALLBACKS_BASE_PATH: ref( - (ctx) => `https://${getBaseUrl(ctx)}/${key}/bff/callbacks`, - ), - BFF_PROXY_API_ENDPOINT: ref((ctx) => `http://${ctx.svc(services.api)}`), + BFF_LOGOUT_REDIRECT_URI: { + local: `http://localhost:4200/${key}`, + dev: ref((ctx) => `https://${getBaseUrl(ctx)}`), + staging: ref((ctx) => `https://${getBaseUrl(ctx)}`), + prod: 'https://island.is', + }, + BFF_CALLBACKS_BASE_PATH: { + local: `http://localhost:3010/${key}/bff/callbacks`, + dev: ref((c) => `https://${getBaseUrl(c)}/${key}/bff/callbacks`), + staging: ref((c) => `https://${getBaseUrl(c)}/${key}/bff/callbacks`), + prod: ref((c) => `https://${getBaseUrl(c)}/${key}/bff/callbacks`), + }, + BFF_PROXY_API_ENDPOINT: { + local: 'http://localhost:4444/api/graphql', + dev: ref((ctx) => `http://${ctx.svc(services.api)}`), + staging: ref((ctx) => `http://${ctx.svc(services.api)}`), + prod: ref((ctx) => `http://${ctx.svc(services.api)}`), + }, BFF_CACHE_USER_PROFILE_TTL_MS: (60 * 60 * 1000 - 5000).toString(), BFF_LOGIN_ATTEMPT_TTL_MS: (60 * 60 * 1000 * 24 * 7).toString(), }, diff --git a/infra/src/dsl/feature-values.spec.ts b/infra/src/dsl/feature-values.spec.ts index 2f3b5f8bd8c1..bee2ec7a0568 100644 --- a/infra/src/dsl/feature-values.spec.ts +++ b/infra/src/dsl/feature-values.spec.ts @@ -79,6 +79,7 @@ describe('Feature-deployment support', () => { const bff = service('services-bff-portals-admin') .bff({ key: 'stjornbord', + clientId: '@admin.island.is/bff-stjornbord', clientName: 'portals-admin', services: { api: apiService }, }) diff --git a/infra/src/dsl/types/input-types.ts b/infra/src/dsl/types/input-types.ts index 5afc4616e0ae..f80707e71197 100644 --- a/infra/src/dsl/types/input-types.ts +++ b/infra/src/dsl/types/input-types.ts @@ -19,6 +19,7 @@ export type AccessModes = 'ReadWrite' | 'ReadOnly' export type BffInfo = { key: PortalKeys + clientId: string clientName: string services: BffInfraServices env?: EnvironmentVariables From 06be6dcb634189f68ec36c54aa2315151bb6d572 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Wed, 23 Oct 2024 15:35:29 +0000 Subject: [PATCH 143/248] fix tests --- infra/src/dsl/portal-env.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/infra/src/dsl/portal-env.spec.ts b/infra/src/dsl/portal-env.spec.ts index a022b6dc8425..9359b83822c8 100644 --- a/infra/src/dsl/portal-env.spec.ts +++ b/infra/src/dsl/portal-env.spec.ts @@ -53,6 +53,7 @@ describe('BFF PortalEnv serialization', () => { }) .bff({ key: 'stjornbord', + clientId: `@admin.island.is/bff-stjornbord`, clientName, services, }) From c9d881be8c80eb151fe0446053efe104350ef385 Mon Sep 17 00:00:00 2001 From: andes-it Date: Wed, 23 Oct 2024 15:36:49 +0000 Subject: [PATCH 144/248] chore: charts update dirty files --- charts/islandis/values.prod.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/charts/islandis/values.prod.yaml b/charts/islandis/values.prod.yaml index 89ae071711fe..396ed268fc12 100644 --- a/charts/islandis/values.prod.yaml +++ b/charts/islandis/values.prod.yaml @@ -2151,7 +2151,7 @@ services-bff-portals-admin: enabled: true env: BFF_ALLOWED_EXTERNAL_API_URLS: '["https://api.island.is"]' - BFF_ALLOWED_REDIRECT_URIS: '["https://island.is"]' + BFF_ALLOWED_REDIRECT_URIS: 'https://island.is' BFF_CACHE_USER_PROFILE_TTL_MS: '3595000' BFF_CALLBACKS_BASE_PATH: 'https://island.is/stjornbord/bff/callbacks' BFF_CLIENT_BASE_URL: 'https://island.is' From 2649efdc711061d7cd8d37af89c470782d16c960 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Wed, 23 Oct 2024 19:35:50 +0000 Subject: [PATCH 145/248] update my pages infra --- .../bff/infra/my-pages-portal.infra.ts | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/apps/services/bff/infra/my-pages-portal.infra.ts b/apps/services/bff/infra/my-pages-portal.infra.ts index c13d3e5711bf..cc3d3e0ce409 100644 --- a/apps/services/bff/infra/my-pages-portal.infra.ts +++ b/apps/services/bff/infra/my-pages-portal.infra.ts @@ -1,5 +1,4 @@ -import { ServiceBuilder, service } from '../../../../infra/src/dsl/dsl' -import { createPortalEnv } from './utils/createPortalEnv' +import { ServiceBuilder, json, service } from '../../../../infra/src/dsl/dsl' const bffName = 'services-bff' const clientName = 'portals-my-pages' @@ -18,21 +17,23 @@ export const serviceSetup = ( .image(bffName) .redis() .serviceAccount(bffName) - .env( - createPortalEnv({ - key, - services, - clientId: '@island.is/bff', - }), - ) - .secrets({ - // The secret should be a valid 32-byte base64 key. - // Generate key example: `openssl rand -base64 32` - BFF_TOKEN_SECRET_BASE64: `/k8s/${bffName}/${clientName}/BFF_TOKEN_SECRET_BASE64`, - IDENTITY_SERVER_CLIENT_SECRET: `/k8s/${bffName}/${clientName}/IDENTITY_SERVER_CLIENT_SECRET`, + .env({ + BFF_ALLOWED_EXTERNAL_API_URLS: { + local: json(['http://localhost:3377/download/v1']), + dev: json(['https://api.dev01.devland.is']), + staging: json(['https://api.staging01.devland.is']), + prod: json(['https://api.island.is']), + }, + }) + .bff({ + key, + clientId: '@island.is/bff', + clientName, + services, }) .readiness(`/${key}/bff/health/check`) .liveness(`/${key}/bff/liveness`) + .replicaCount({ default: 2, min: 2, From a4107b96b04cc0f1aa54179457dc15d16497b13e Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Wed, 23 Oct 2024 19:39:26 +0000 Subject: [PATCH 146/248] fix env in infra --- infra/src/dsl/bff.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infra/src/dsl/bff.ts b/infra/src/dsl/bff.ts index 86f1ec8b3d3d..43bc4f56dfdd 100644 --- a/infra/src/dsl/bff.ts +++ b/infra/src/dsl/bff.ts @@ -49,7 +49,7 @@ export const bffConfig = ({ key, services, clientName, clientId }: BffInfo) => { prod: 'https://island.is', }, BFF_ALLOWED_REDIRECT_URIS: { - local: json(['http://localhost:4200/stjornbord']), + local: json([`http://localhost:4200/${key}`]), dev: ref((ctx) => json([`https://${getBaseUrl(ctx)}`])), staging: ref((ctx) => json([`https://${getBaseUrl(ctx)}`])), prod: 'https://island.is', From 31bf5d3ca0e706ef269441dd32d276dfcd0c5120 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Wed, 23 Oct 2024 19:40:07 +0000 Subject: [PATCH 147/248] fix infra url --- infra/src/dsl/bff.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infra/src/dsl/bff.ts b/infra/src/dsl/bff.ts index 86f1ec8b3d3d..43bc4f56dfdd 100644 --- a/infra/src/dsl/bff.ts +++ b/infra/src/dsl/bff.ts @@ -49,7 +49,7 @@ export const bffConfig = ({ key, services, clientName, clientId }: BffInfo) => { prod: 'https://island.is', }, BFF_ALLOWED_REDIRECT_URIS: { - local: json(['http://localhost:4200/stjornbord']), + local: json([`http://localhost:4200/${key}`]), dev: ref((ctx) => json([`https://${getBaseUrl(ctx)}`])), staging: ref((ctx) => json([`https://${getBaseUrl(ctx)}`])), prod: 'https://island.is', From 60b68c0bc52f6d36a1994a149ce142fba28c728a Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Wed, 23 Oct 2024 19:48:59 +0000 Subject: [PATCH 148/248] Removed un used import --- infra/src/dsl/portal-env.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/infra/src/dsl/portal-env.spec.ts b/infra/src/dsl/portal-env.spec.ts index 9359b83822c8..c04861287b18 100644 --- a/infra/src/dsl/portal-env.spec.ts +++ b/infra/src/dsl/portal-env.spec.ts @@ -4,7 +4,6 @@ import { SerializeSuccess, HelmService } from './types/output-types' import { EnvironmentConfig } from './types/charts' import { renderers } from './upstream-dependencies' import { generateOutputOne } from './processing/rendering-pipeline' -import { createPortalEnv } from '../../../apps/services/bff/infra/utils/createPortalEnv' import { json } from './dsl' import { adminPortalScopes } from '../../../libs/auth/scopes/src/index' From 35576b27f33a111e0329d54b361f7a78ef262134 Mon Sep 17 00:00:00 2001 From: andes-it Date: Wed, 23 Oct 2024 19:53:02 +0000 Subject: [PATCH 149/248] chore: charts update dirty files --- charts/islandis/values.dev.yaml | 85 +++++++++++++++++++++++++++- charts/islandis/values.prod.yaml | 88 ++++++++++++++++++++++++++++- charts/islandis/values.staging.yaml | 86 +++++++++++++++++++++++++++- 3 files changed, 256 insertions(+), 3 deletions(-) diff --git a/charts/islandis/values.dev.yaml b/charts/islandis/values.dev.yaml index 53bae0add330..b7e6fa6d2807 100644 --- a/charts/islandis/values.dev.yaml +++ b/charts/islandis/values.dev.yaml @@ -1790,6 +1790,7 @@ namespaces: - 'services-sessions' - 'contentful-apps' - 'services-university-gateway' + - 'portals-my-pages' portals-admin: enabled: true env: @@ -2089,7 +2090,7 @@ service-portal: NODE_OPTIONS: '--max-old-space-size=230' SERVERSIDE_FEATURES_ON: '' SI_PUBLIC_ENVIRONMENT: 'dev' - SI_PUBLIC_GRAPHQL_API: '/api/graphql' + SI_PUBLIC_GRAPHQL_API: '/minarsidur/bff/api/graphql' SI_PUBLIC_IDENTITY_SERVER_ISSUER_URL: 'https://identity-server.dev01.devland.is' grantNamespaces: - 'nginx-ingress-internal' @@ -2359,6 +2360,88 @@ services-bff-portals-admin: eks.amazonaws.com/role-arn: 'arn:aws:iam::013313053092:role/services-bff' create: true name: 'services-bff' +services-bff-portals-my-pages: + enabled: true + env: + BFF_ALLOWED_EXTERNAL_API_URLS: '["https://api.dev01.devland.is"]' + BFF_ALLOWED_REDIRECT_URIS: '["https://beta.dev01.devland.is"]' + BFF_CACHE_USER_PROFILE_TTL_MS: '3595000' + BFF_CALLBACKS_BASE_PATH: 'https://beta.dev01.devland.is/minarsidur/bff/callbacks' + BFF_CLIENT_BASE_URL: 'https://beta.dev01.devland.is' + BFF_CLIENT_KEY_PATH: '/minarsidur' + BFF_LOGIN_ATTEMPT_TTL_MS: '604800000' + BFF_LOGOUT_REDIRECT_URI: 'https://beta.dev01.devland.is' + BFF_NAME: 'minarsidur' + BFF_PAR_SUPPORT_ENABLED: 'false' + BFF_PROXY_API_ENDPOINT: 'http://web-api.islandis.svc.cluster.local' + IDENTITY_SERVER_CLIENT_ID: '@island.is/bff' + IDENTITY_SERVER_CLIENT_SCOPES: '["api_resource.scope","@island.is/applications:read","@island.is/applications:write","@island.is/user-profile:read","@island.is/user-profile:write","@island.is/auth/actor-delegations","@island.is/auth/delegations:write","@island.is/auth/consents","@skra.is/individuals","@island.is/documents","@island.is/endorsements","@admin.island.is/petitions","@island.is/assets/ip","@island.is/assets","@island.is/education","@island.is/education-license","@island.is/finance:overview","@island.is/finance/salary","@island.is/finance/schedule:read","@island.is/finance/loans","@island.is/internal","@island.is/internal:procuring","@island.is/me:details","@island.is/law-and-order","@island.is/licenses","@island.is/licenses:verify","@island.is/company","@island.is/vehicles","@island.is/work-machines","@island.is/health/payments","@island.is/health/medicines","@island.is/health/assistive-devices-and-nutrition","@island.is/health/therapies","@island.is/health/healthcare","@island.is/health/rights-status","@island.is/health/dentists","@island.is/health/organ-donation","@island.is/health/vaccinations","@island.is/signature-collection"]' + IDENTITY_SERVER_ISSUER_URL: 'https://identity-server.dev01.devland.is' + LOG_LEVEL: 'info' + NODE_OPTIONS: '--max-old-space-size=460 -r dd-trace/init' + REDIS_URL_NODE_01: '["clustercfg.general-redis-cluster-group.5fzau3.euw1.cache.amazonaws.com:6379"]' + SERVERSIDE_FEATURES_ON: '' + grantNamespaces: [] + grantNamespacesEnabled: false + healthCheck: + liveness: + initialDelaySeconds: 3 + path: '/minarsidur/bff/liveness' + timeoutSeconds: 3 + readiness: + initialDelaySeconds: 3 + path: '/minarsidur/bff/health/check' + timeoutSeconds: 3 + hpa: + scaling: + metric: + cpuAverageUtilization: 90 + nginxRequestsIrate: 5 + replicas: + max: 10 + min: 2 + image: + repository: '821090935708.dkr.ecr.eu-west-1.amazonaws.com/services-bff' + ingress: + primary-alb: + annotations: + kubernetes.io/ingress.class: 'nginx-external-alb' + nginx.ingress.kubernetes.io/proxy-buffer-size: '8k' + nginx.ingress.kubernetes.io/proxy-buffering: 'on' + nginx.ingress.kubernetes.io/service-upstream: 'true' + hosts: + - host: 'beta.dev01.devland.is' + paths: + - '/minarsidur/bff' + namespace: 'portals-my-pages' + podDisruptionBudget: + maxUnavailable: 1 + podSecurityContext: + fsGroup: 65534 + pvcs: [] + replicaCount: + default: 2 + max: 10 + min: 2 + resources: + limits: + cpu: '400m' + memory: '512Mi' + requests: + cpu: '100m' + memory: '256Mi' + secrets: + BFF_TOKEN_SECRET_BASE64: '/k8s/services-bff/portals-my-pages/BFF_TOKEN_SECRET_BASE64' + CONFIGCAT_SDK_KEY: '/k8s/configcat/CONFIGCAT_SDK_KEY' + IDENTITY_SERVER_CLIENT_SECRET: '/k8s/services-bff/portals-my-pages/IDENTITY_SERVER_CLIENT_SECRET' + securityContext: + allowPrivilegeEscalation: false + privileged: false + serviceAccount: + annotations: + eks.amazonaws.com/role-arn: 'arn:aws:iam::013313053092:role/services-bff' + create: true + name: 'services-bff' services-documents: enabled: true env: diff --git a/charts/islandis/values.prod.yaml b/charts/islandis/values.prod.yaml index 396ed268fc12..3ceddcb151ae 100644 --- a/charts/islandis/values.prod.yaml +++ b/charts/islandis/values.prod.yaml @@ -1655,6 +1655,7 @@ namespaces: - 'services-university-gateway' - 'contentful-apps' - 'contentful-entry-tagger' + - 'portals-my-pages' portals-admin: enabled: true env: @@ -1956,7 +1957,7 @@ service-portal: NODE_OPTIONS: '--max-old-space-size=230' SERVERSIDE_FEATURES_ON: 'driving-license-use-v1-endpoint-for-v2-comms' SI_PUBLIC_ENVIRONMENT: 'prod' - SI_PUBLIC_GRAPHQL_API: '/api/graphql' + SI_PUBLIC_GRAPHQL_API: '/minarsidur/bff/api/graphql' SI_PUBLIC_IDENTITY_SERVER_ISSUER_URL: 'https://innskra.island.is' grantNamespaces: - 'nginx-ingress-internal' @@ -2232,6 +2233,91 @@ services-bff-portals-admin: eks.amazonaws.com/role-arn: 'arn:aws:iam::251502586493:role/services-bff' create: true name: 'services-bff' +services-bff-portals-my-pages: + enabled: true + env: + BFF_ALLOWED_EXTERNAL_API_URLS: '["https://api.island.is"]' + BFF_ALLOWED_REDIRECT_URIS: 'https://island.is' + BFF_CACHE_USER_PROFILE_TTL_MS: '3595000' + BFF_CALLBACKS_BASE_PATH: 'https://island.is/minarsidur/bff/callbacks' + BFF_CLIENT_BASE_URL: 'https://island.is' + BFF_CLIENT_KEY_PATH: '/minarsidur' + BFF_LOGIN_ATTEMPT_TTL_MS: '604800000' + BFF_LOGOUT_REDIRECT_URI: 'https://island.is' + BFF_NAME: 'minarsidur' + BFF_PAR_SUPPORT_ENABLED: 'false' + BFF_PROXY_API_ENDPOINT: 'http://web-api.islandis.svc.cluster.local' + IDENTITY_SERVER_CLIENT_ID: '@island.is/bff' + IDENTITY_SERVER_CLIENT_SCOPES: '["api_resource.scope","@island.is/applications:read","@island.is/applications:write","@island.is/user-profile:read","@island.is/user-profile:write","@island.is/auth/actor-delegations","@island.is/auth/delegations:write","@island.is/auth/consents","@skra.is/individuals","@island.is/documents","@island.is/endorsements","@admin.island.is/petitions","@island.is/assets/ip","@island.is/assets","@island.is/education","@island.is/education-license","@island.is/finance:overview","@island.is/finance/salary","@island.is/finance/schedule:read","@island.is/finance/loans","@island.is/internal","@island.is/internal:procuring","@island.is/me:details","@island.is/law-and-order","@island.is/licenses","@island.is/licenses:verify","@island.is/company","@island.is/vehicles","@island.is/work-machines","@island.is/health/payments","@island.is/health/medicines","@island.is/health/assistive-devices-and-nutrition","@island.is/health/therapies","@island.is/health/healthcare","@island.is/health/rights-status","@island.is/health/dentists","@island.is/health/organ-donation","@island.is/health/vaccinations","@island.is/signature-collection"]' + IDENTITY_SERVER_ISSUER_URL: 'https://innskra.island.is' + LOG_LEVEL: 'info' + NODE_OPTIONS: '--max-old-space-size=460 -r dd-trace/init' + REDIS_URL_NODE_01: '["clustercfg.general-redis-cluster-group.whakos.euw1.cache.amazonaws.com:6379"]' + SERVERSIDE_FEATURES_ON: 'driving-license-use-v1-endpoint-for-v2-comms' + grantNamespaces: [] + grantNamespacesEnabled: false + healthCheck: + liveness: + initialDelaySeconds: 3 + path: '/minarsidur/bff/liveness' + timeoutSeconds: 3 + readiness: + initialDelaySeconds: 3 + path: '/minarsidur/bff/health/check' + timeoutSeconds: 3 + hpa: + scaling: + metric: + cpuAverageUtilization: 90 + nginxRequestsIrate: 5 + replicas: + max: 10 + min: 2 + image: + repository: '821090935708.dkr.ecr.eu-west-1.amazonaws.com/services-bff' + ingress: + primary-alb: + annotations: + kubernetes.io/ingress.class: 'nginx-external-alb' + nginx.ingress.kubernetes.io/proxy-buffer-size: '8k' + nginx.ingress.kubernetes.io/proxy-buffering: 'on' + nginx.ingress.kubernetes.io/service-upstream: 'true' + hosts: + - host: 'island.is' + paths: + - '/minarsidur/bff' + - host: 'www.island.is' + paths: + - '/minarsidur/bff' + namespace: 'portals-my-pages' + podDisruptionBudget: + maxUnavailable: 1 + podSecurityContext: + fsGroup: 65534 + pvcs: [] + replicaCount: + default: 2 + max: 10 + min: 2 + resources: + limits: + cpu: '400m' + memory: '512Mi' + requests: + cpu: '100m' + memory: '256Mi' + secrets: + BFF_TOKEN_SECRET_BASE64: '/k8s/services-bff/portals-my-pages/BFF_TOKEN_SECRET_BASE64' + CONFIGCAT_SDK_KEY: '/k8s/configcat/CONFIGCAT_SDK_KEY' + IDENTITY_SERVER_CLIENT_SECRET: '/k8s/services-bff/portals-my-pages/IDENTITY_SERVER_CLIENT_SECRET' + securityContext: + allowPrivilegeEscalation: false + privileged: false + serviceAccount: + annotations: + eks.amazonaws.com/role-arn: 'arn:aws:iam::251502586493:role/services-bff' + create: true + name: 'services-bff' services-documents: enabled: true env: diff --git a/charts/islandis/values.staging.yaml b/charts/islandis/values.staging.yaml index a4f8a226ca4d..5c408ac3e1d3 100644 --- a/charts/islandis/values.staging.yaml +++ b/charts/islandis/values.staging.yaml @@ -1528,6 +1528,7 @@ namespaces: - 'license-api' - 'services-sessions' - 'services-university-gateway' + - 'portals-my-pages' portals-admin: enabled: true env: @@ -1827,7 +1828,7 @@ service-portal: NODE_OPTIONS: '--max-old-space-size=230' SERVERSIDE_FEATURES_ON: '' SI_PUBLIC_ENVIRONMENT: 'staging' - SI_PUBLIC_GRAPHQL_API: '/api/graphql' + SI_PUBLIC_GRAPHQL_API: '/minarsidur/bff/api/graphql' SI_PUBLIC_IDENTITY_SERVER_ISSUER_URL: 'https://identity-server.staging01.devland.is' grantNamespaces: - 'nginx-ingress-internal' @@ -2098,6 +2099,89 @@ services-bff-portals-admin: eks.amazonaws.com/role-arn: 'arn:aws:iam::261174024191:role/services-bff' create: true name: 'services-bff' +services-bff-portals-my-pages: + enabled: true + env: + BFF_ALLOWED_EXTERNAL_API_URLS: '["https://api.staging01.devland.is"]' + BFF_ALLOWED_REDIRECT_URIS: '["https://beta.staging01.devland.is"]' + BFF_CACHE_USER_PROFILE_TTL_MS: '3595000' + BFF_CALLBACKS_BASE_PATH: 'https://beta.staging01.devland.is/minarsidur/bff/callbacks' + BFF_CLIENT_BASE_URL: 'https://beta.staging01.devland.is' + BFF_CLIENT_KEY_PATH: '/minarsidur' + BFF_LOGIN_ATTEMPT_TTL_MS: '604800000' + BFF_LOGOUT_REDIRECT_URI: 'https://beta.staging01.devland.is' + BFF_NAME: 'minarsidur' + BFF_PAR_SUPPORT_ENABLED: 'false' + BFF_PROXY_API_ENDPOINT: 'http://web-api.islandis.svc.cluster.local' + IDENTITY_SERVER_CLIENT_ID: '@island.is/bff' + IDENTITY_SERVER_CLIENT_SCOPES: '["api_resource.scope","@island.is/applications:read","@island.is/applications:write","@island.is/user-profile:read","@island.is/user-profile:write","@island.is/auth/actor-delegations","@island.is/auth/delegations:write","@island.is/auth/consents","@skra.is/individuals","@island.is/documents","@island.is/endorsements","@admin.island.is/petitions","@island.is/assets/ip","@island.is/assets","@island.is/education","@island.is/education-license","@island.is/finance:overview","@island.is/finance/salary","@island.is/finance/schedule:read","@island.is/finance/loans","@island.is/internal","@island.is/internal:procuring","@island.is/me:details","@island.is/law-and-order","@island.is/licenses","@island.is/licenses:verify","@island.is/company","@island.is/vehicles","@island.is/work-machines","@island.is/health/payments","@island.is/health/medicines","@island.is/health/assistive-devices-and-nutrition","@island.is/health/therapies","@island.is/health/healthcare","@island.is/health/rights-status","@island.is/health/dentists","@island.is/health/organ-donation","@island.is/health/vaccinations","@island.is/signature-collection"]' + IDENTITY_SERVER_ISSUER_URL: 'https://identity-server.staging01.devland.is' + LOG_LEVEL: 'info' + NODE_OPTIONS: '--max-old-space-size=460 -r dd-trace/init' + REDIS_URL_NODE_01: '["clustercfg.general-redis-cluster-group.ab9ckb.euw1.cache.amazonaws.com:6379"]' + SERVERSIDE_FEATURES_ON: '' + grantNamespaces: [] + grantNamespacesEnabled: false + healthCheck: + liveness: + initialDelaySeconds: 3 + path: '/minarsidur/bff/liveness' + timeoutSeconds: 3 + readiness: + initialDelaySeconds: 3 + path: '/minarsidur/bff/health/check' + timeoutSeconds: 3 + hpa: + scaling: + metric: + cpuAverageUtilization: 90 + nginxRequestsIrate: 5 + replicas: + max: 10 + min: 2 + image: + repository: '821090935708.dkr.ecr.eu-west-1.amazonaws.com/services-bff' + ingress: + primary-alb: + annotations: + kubernetes.io/ingress.class: 'nginx-external-alb' + nginx.ingress.kubernetes.io/enable-global-auth: 'false' + nginx.ingress.kubernetes.io/proxy-buffer-size: '8k' + nginx.ingress.kubernetes.io/proxy-buffering: 'on' + nginx.ingress.kubernetes.io/service-upstream: 'true' + hosts: + - host: 'beta.staging01.devland.is' + paths: + - '/minarsidur/bff' + namespace: 'portals-my-pages' + podDisruptionBudget: + maxUnavailable: 1 + podSecurityContext: + fsGroup: 65534 + pvcs: [] + replicaCount: + default: 2 + max: 10 + min: 2 + resources: + limits: + cpu: '400m' + memory: '512Mi' + requests: + cpu: '100m' + memory: '256Mi' + secrets: + BFF_TOKEN_SECRET_BASE64: '/k8s/services-bff/portals-my-pages/BFF_TOKEN_SECRET_BASE64' + CONFIGCAT_SDK_KEY: '/k8s/configcat/CONFIGCAT_SDK_KEY' + IDENTITY_SERVER_CLIENT_SECRET: '/k8s/services-bff/portals-my-pages/IDENTITY_SERVER_CLIENT_SECRET' + securityContext: + allowPrivilegeEscalation: false + privileged: false + serviceAccount: + annotations: + eks.amazonaws.com/role-arn: 'arn:aws:iam::261174024191:role/services-bff' + create: true + name: 'services-bff' services-documents: enabled: true env: From f4e6b63d5e15318df21772d932baa64bd180cbb7 Mon Sep 17 00:00:00 2001 From: andes-it Date: Wed, 23 Oct 2024 20:10:25 +0000 Subject: [PATCH 150/248] chore: nx format:write update dirty files --- apps/portals/my-pages/project.json | 24 ++++++------------------ 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/apps/portals/my-pages/project.json b/apps/portals/my-pages/project.json index 81eb0569664f..a93625bf4d17 100644 --- a/apps/portals/my-pages/project.json +++ b/apps/portals/my-pages/project.json @@ -3,9 +3,7 @@ "$schema": "../../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "apps/portals/my-pages/src", "projectType": "application", - "tags": [ - "scope:portals-mypages" - ], + "tags": ["scope:portals-mypages"], "targets": { "build": { "executor": "@nx/webpack:webpack", @@ -21,9 +19,7 @@ "apps/portals/my-pages/src/mockServiceWorker.js", "apps/portals/my-pages/src/assets" ], - "styles": [ - "apps/portals/my-pages/src/styles.css" - ], + "styles": ["apps/portals/my-pages/src/styles.css"], "scripts": [], "webpackConfig": "apps/portals/my-pages/webpack.config.js", "maxWorkers": 2 @@ -47,9 +43,7 @@ ] } }, - "outputs": [ - "{options.outputPath}" - ], + "outputs": ["{options.outputPath}"], "dependsOn": [ { "target": "generateDevIndexHTML" @@ -63,9 +57,7 @@ "node scripts/dockerfile-assets/bash/extract-environment.js apps/portals/my-pages/src" ] }, - "outputs": [ - "{workspaceRoot}/apps/portals/my-pages/src/index.html" - ] + "outputs": ["{workspaceRoot}/apps/portals/my-pages/src/index.html"] }, "serve": { "executor": "@nx/webpack:dev-server", @@ -92,9 +84,7 @@ "options": { "jestConfig": "apps/portals/my-pages/jest.config.ts" }, - "outputs": [ - "{workspaceRoot}/coverage/apps/portals/my-pages" - ] + "outputs": ["{workspaceRoot}/coverage/apps/portals/my-pages"] }, "extract-strings": { "executor": "nx:run-commands", @@ -133,9 +123,7 @@ "mockmode": { "executor": "nx:run-commands", "options": { - "commands": [ - "API_MOCKS=true yarn start service-portal" - ] + "commands": ["API_MOCKS=true yarn start service-portal"] } }, "docker-static": { From 1c7ff0600b67e8cd751193d8121cd0a1bf05c7f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B3n=20Levy?= Date: Wed, 23 Oct 2024 23:23:42 +0000 Subject: [PATCH 151/248] fix: revert secret type changes --- infra/package.json | 3 +-- infra/src/dsl/types/input-types.ts | 12 ++++++------ 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/infra/package.json b/infra/package.json index 7e28e370691d..bf6322a2da85 100644 --- a/infra/package.json +++ b/infra/package.json @@ -7,8 +7,7 @@ "charts": "node -r esbuild-register src/cli/generate-chart-values.ts", "update": "yarn update:packagejson", "update:packagejson": "node -r esbuild-register scripts/update-package-json.ts", - "cli": "node -r esbuild-register src/cli/cli.ts", - "generate-nx-schema": "node -r esbuild-register ./scripts/generate-nx-schema.ts" + "cli": "node -r esbuild-register src/cli/cli.ts" }, "license": "MIT", "devDependencies": { diff --git a/infra/src/dsl/types/input-types.ts b/infra/src/dsl/types/input-types.ts index f80707e71197..763437dae923 100644 --- a/infra/src/dsl/types/input-types.ts +++ b/infra/src/dsl/types/input-types.ts @@ -58,9 +58,7 @@ export type HealthProbe = { timeoutSeconds: number } -export type Secrets = { - [name: string]: string | ValueType -} +export type Secrets = { [name: string]: string } export type EnvironmentVariableValue = | Optional< @@ -156,9 +154,11 @@ export interface Ingress { } paths: string[] public?: boolean - extraAnnotations?: Partial<{ - [env in OpsEnv]: { [annotation: string]: string | null } - }> + extraAnnotations?: Partial< + { + [env in OpsEnv]: { [annotation: string]: string | null } + } + > } export interface IngressForEnv { From 360f7c551cfc062e9325aa86b991a8c41eec50a5 Mon Sep 17 00:00:00 2001 From: andes-it Date: Wed, 23 Oct 2024 23:31:26 +0000 Subject: [PATCH 152/248] chore: nx format:write update dirty files --- infra/src/dsl/types/input-types.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/infra/src/dsl/types/input-types.ts b/infra/src/dsl/types/input-types.ts index 763437dae923..04b75ad4f20e 100644 --- a/infra/src/dsl/types/input-types.ts +++ b/infra/src/dsl/types/input-types.ts @@ -154,11 +154,9 @@ export interface Ingress { } paths: string[] public?: boolean - extraAnnotations?: Partial< - { - [env in OpsEnv]: { [annotation: string]: string | null } - } - > + extraAnnotations?: Partial<{ + [env in OpsEnv]: { [annotation: string]: string | null } + }> } export interface IngressForEnv { From 70f6a5c462244c95cc4cd28daf2dbc07c050f7ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B3n=20Levy?= Date: Wed, 23 Oct 2024 23:47:32 +0000 Subject: [PATCH 153/248] chore: cleanup --- infra/scripts/generate-nx-schema.ts | 66 ----------------------------- 1 file changed, 66 deletions(-) delete mode 100644 infra/scripts/generate-nx-schema.ts diff --git a/infra/scripts/generate-nx-schema.ts b/infra/scripts/generate-nx-schema.ts deleted file mode 100644 index 26a21515110d..000000000000 --- a/infra/scripts/generate-nx-schema.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { writeFileSync } from 'fs' -import { join } from 'path' -import fetch from 'node-fetch' -import jsonSchemaToZod, { Options } from 'json-schema-to-zod' - -const nxVersion = process.argv[2] || 'master' - -const schemaUrl = `https://raw.githubusercontent.com/nrwl/nx/refs/heads/${nxVersion}/packages/nx/schemas/project-schema.json` -const outputFilePath = join(__dirname, '../src/generated/nx-project-schema.ts') - -const downloadSchema = async (): Promise> => { - try { - const response = await fetch(schemaUrl) - if (!response.ok) { - throw new Error(`Failed to fetch schema: ${response.statusText}`) - } - const schemaData = await response.json() - return schemaData - } catch (error) { - console.error('Error downloading schema:', error) - throw error - } -} - -const transformSchemaToZod = async ( - jsonSchema: Record, -): Promise => { - try { - const options: Options = { - name: 'nxProjectSchema', - module: 'esm', - depth: 0, - type: true, - } - - const code = jsonSchemaToZod(jsonSchema, options) - - return code - } catch (error) { - console.error('Error transforming schema to Zod:', error) - throw error - } -} - -const writeSchemaToFile = (zodSchema: string): void => { - const fileContent = `// This file is auto-generated. Do not edit directly.\n\n${zodSchema}` - writeFileSync(outputFilePath, fileContent, { encoding: 'utf-8' }) - console.log(`Zod schema has been written to ${outputFilePath}`) -} - -const main = async (): Promise => { - try { - console.log(`Downloading schema for NX version: ${nxVersion}...`) - const jsonSchema = await downloadSchema() - - console.log('Transforming schema to Zod...') - const zodSchema = await transformSchemaToZod(jsonSchema) - - console.log('Writing Zod schema to file...') - writeSchemaToFile(zodSchema) - } catch (error) { - console.error('An error occurred:', error) - } -} - -main() From a0ef8df18b865e10d071e219462be8a25dccf10b Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Wed, 23 Oct 2024 19:48:59 +0000 Subject: [PATCH 154/248] Removed un used import --- infra/src/dsl/portal-env.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/infra/src/dsl/portal-env.spec.ts b/infra/src/dsl/portal-env.spec.ts index 9359b83822c8..c04861287b18 100644 --- a/infra/src/dsl/portal-env.spec.ts +++ b/infra/src/dsl/portal-env.spec.ts @@ -4,7 +4,6 @@ import { SerializeSuccess, HelmService } from './types/output-types' import { EnvironmentConfig } from './types/charts' import { renderers } from './upstream-dependencies' import { generateOutputOne } from './processing/rendering-pipeline' -import { createPortalEnv } from '../../../apps/services/bff/infra/utils/createPortalEnv' import { json } from './dsl' import { adminPortalScopes } from '../../../libs/auth/scopes/src/index' From 9fcf39bc709196b4d9e54c3e7a02ad96fe2782d9 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Thu, 24 Oct 2024 10:02:39 +0000 Subject: [PATCH 155/248] Update after self review --- apps/portals/my-pages/project.json | 3 ++- apps/services/bff/infra/my-pages-portal.infra.ts | 1 - libs/react-spa/bff/src/lib/bff.hooks.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/portals/my-pages/project.json b/apps/portals/my-pages/project.json index a93625bf4d17..4d56d107327b 100644 --- a/apps/portals/my-pages/project.json +++ b/apps/portals/my-pages/project.json @@ -117,7 +117,8 @@ "commands": [ "yarn nx run service-portal:start-bff", "yarn start service-portal" - ] + ], + "parallel": true } }, "mockmode": { diff --git a/apps/services/bff/infra/my-pages-portal.infra.ts b/apps/services/bff/infra/my-pages-portal.infra.ts index cc3d3e0ce409..63a46e395a39 100644 --- a/apps/services/bff/infra/my-pages-portal.infra.ts +++ b/apps/services/bff/infra/my-pages-portal.infra.ts @@ -33,7 +33,6 @@ export const serviceSetup = ( }) .readiness(`/${key}/bff/health/check`) .liveness(`/${key}/bff/liveness`) - .replicaCount({ default: 2, min: 2, diff --git a/libs/react-spa/bff/src/lib/bff.hooks.ts b/libs/react-spa/bff/src/lib/bff.hooks.ts index 6420f22d94c5..bb4a4f025e25 100644 --- a/libs/react-spa/bff/src/lib/bff.hooks.ts +++ b/libs/react-spa/bff/src/lib/bff.hooks.ts @@ -12,7 +12,7 @@ export const useBff = () => { const bffContext = useContext(BffContext) if (!bffContext) { - throw new Error('useAuth must be used within a BffProvider') + throw new Error('useBff must be used within a BffProvider') } return bffContext From 22cd36735719d90c2ce4e3a748b8450b0a914115 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Mon, 28 Oct 2024 08:38:52 +0000 Subject: [PATCH 156/248] fix feature deployment url --- infra/src/dsl/bff.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infra/src/dsl/bff.ts b/infra/src/dsl/bff.ts index 43bc4f56dfdd..c4899ba4438c 100644 --- a/infra/src/dsl/bff.ts +++ b/infra/src/dsl/bff.ts @@ -20,7 +20,7 @@ export const getScopes = (key: PortalKeys) => { export const bffConfig = ({ key, services, clientName, clientId }: BffInfo) => { const getBaseUrl = (ctx: Context) => ctx.featureDeploymentName - ? `${ctx.featureDeploymentName}.${ctx.env.domain}` + ? `${ctx.featureDeploymentName}-beta.${ctx.env.domain}` : ctx.env.type === 'prod' ? ctx.env.domain : `beta.${ctx.env.domain}` From 32b074645862fa3f030b66c22b2408c4f70414c8 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Mon, 28 Oct 2024 09:04:34 +0000 Subject: [PATCH 157/248] fix tests --- infra/src/dsl/feature-values.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/infra/src/dsl/feature-values.spec.ts b/infra/src/dsl/feature-values.spec.ts index bee2ec7a0568..a5050c02a138 100644 --- a/infra/src/dsl/feature-values.spec.ts +++ b/infra/src/dsl/feature-values.spec.ts @@ -124,10 +124,10 @@ describe('Feature-deployment support', () => { BFF_NAME: 'stjornbord', BFF_CLIENT_KEY_PATH: `/stjornbord`, BFF_PAR_SUPPORT_ENABLED: 'false', - BFF_ALLOWED_REDIRECT_URIS: json(['https://feature-A.dev01.devland.is']), - BFF_CLIENT_BASE_URL: 'https://feature-A.dev01.devland.is', - BFF_LOGOUT_REDIRECT_URI: 'https://feature-A.dev01.devland.is', - BFF_CALLBACKS_BASE_PATH: `https://feature-A.dev01.devland.is/stjornbord/bff/callbacks`, + BFF_ALLOWED_REDIRECT_URIS: json(['https://feature-A-beta.dev01.devland.is']), + BFF_CLIENT_BASE_URL: 'https://feature-A-beta.dev01.devland.is', + BFF_LOGOUT_REDIRECT_URI: 'https://feature-A-beta.dev01.devland.is', + BFF_CALLBACKS_BASE_PATH: `https://feature-A-beta.dev01.devland.is/stjornbord/bff/callbacks`, BFF_PROXY_API_ENDPOINT: 'http://web-api', BFF_ALLOWED_EXTERNAL_API_URLS: json(['https://api.dev01.devland.is']), BFF_CACHE_USER_PROFILE_TTL_MS: '3595000', From eb91818decf3381d19372263b610e9a06bdcdc18 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Mon, 28 Oct 2024 09:31:09 +0000 Subject: [PATCH 158/248] fix missing logger --- infra/src/feature-env.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/infra/src/feature-env.ts b/infra/src/feature-env.ts index 8cd111b241d2..678f02fdf490 100644 --- a/infra/src/feature-env.ts +++ b/infra/src/feature-env.ts @@ -18,6 +18,7 @@ import { renderHelmValueFileContent, } from './dsl/exports/helm' import { ServiceBuilder } from './dsl/dsl' +import { logger } from './logging' type ChartName = 'islandis' | 'identity-server' From c0d875f365745199cbd20125560f8afed9c5cbce Mon Sep 17 00:00:00 2001 From: andes-it Date: Mon, 28 Oct 2024 09:35:31 +0000 Subject: [PATCH 159/248] chore: nx format:write update dirty files --- infra/src/dsl/feature-values.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/infra/src/dsl/feature-values.spec.ts b/infra/src/dsl/feature-values.spec.ts index a5050c02a138..0c0d49ccc103 100644 --- a/infra/src/dsl/feature-values.spec.ts +++ b/infra/src/dsl/feature-values.spec.ts @@ -124,7 +124,9 @@ describe('Feature-deployment support', () => { BFF_NAME: 'stjornbord', BFF_CLIENT_KEY_PATH: `/stjornbord`, BFF_PAR_SUPPORT_ENABLED: 'false', - BFF_ALLOWED_REDIRECT_URIS: json(['https://feature-A-beta.dev01.devland.is']), + BFF_ALLOWED_REDIRECT_URIS: json([ + 'https://feature-A-beta.dev01.devland.is', + ]), BFF_CLIENT_BASE_URL: 'https://feature-A-beta.dev01.devland.is', BFF_LOGOUT_REDIRECT_URI: 'https://feature-A-beta.dev01.devland.is', BFF_CALLBACKS_BASE_PATH: `https://feature-A-beta.dev01.devland.is/stjornbord/bff/callbacks`, From ef2ad3c7bff3a43e6711c54d6a7c90da838f84e6 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Mon, 28 Oct 2024 13:51:16 +0000 Subject: [PATCH 160/248] update api graphql bff config env var --- infra/src/dsl/bff.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/infra/src/dsl/bff.ts b/infra/src/dsl/bff.ts index c4899ba4438c..45986500f7ef 100644 --- a/infra/src/dsl/bff.ts +++ b/infra/src/dsl/bff.ts @@ -68,9 +68,9 @@ export const bffConfig = ({ key, services, clientName, clientId }: BffInfo) => { }, BFF_PROXY_API_ENDPOINT: { local: 'http://localhost:4444/api/graphql', - dev: ref((ctx) => `http://${ctx.svc(services.api)}`), - staging: ref((ctx) => `http://${ctx.svc(services.api)}`), - prod: ref((ctx) => `http://${ctx.svc(services.api)}`), + dev: ref((ctx) => `http://${ctx.svc(services.api)}/api/graphql`), + staging: ref((ctx) => `http://${ctx.svc(services.api)}/api/graphql`), + prod: ref((ctx) => `http://${ctx.svc(services.api)}/api/graphql`), }, BFF_CACHE_USER_PROFILE_TTL_MS: (60 * 60 * 1000 - 5000).toString(), BFF_LOGIN_ATTEMPT_TTL_MS: (60 * 60 * 1000 * 24 * 7).toString(), From ff142926503dff152e10f8c1b39d79451a311250 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Mon, 28 Oct 2024 13:52:58 +0000 Subject: [PATCH 161/248] update api graphql bff config env var --- infra/src/dsl/bff.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/infra/src/dsl/bff.ts b/infra/src/dsl/bff.ts index c4899ba4438c..45986500f7ef 100644 --- a/infra/src/dsl/bff.ts +++ b/infra/src/dsl/bff.ts @@ -68,9 +68,9 @@ export const bffConfig = ({ key, services, clientName, clientId }: BffInfo) => { }, BFF_PROXY_API_ENDPOINT: { local: 'http://localhost:4444/api/graphql', - dev: ref((ctx) => `http://${ctx.svc(services.api)}`), - staging: ref((ctx) => `http://${ctx.svc(services.api)}`), - prod: ref((ctx) => `http://${ctx.svc(services.api)}`), + dev: ref((ctx) => `http://${ctx.svc(services.api)}/api/graphql`), + staging: ref((ctx) => `http://${ctx.svc(services.api)}/api/graphql`), + prod: ref((ctx) => `http://${ctx.svc(services.api)}/api/graphql`), }, BFF_CACHE_USER_PROFILE_TTL_MS: (60 * 60 * 1000 - 5000).toString(), BFF_LOGIN_ATTEMPT_TTL_MS: (60 * 60 * 1000 * 24 * 7).toString(), From b8402bd16c2eb16fd0fd575aa9395bd37a90ba31 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Mon, 28 Oct 2024 14:02:34 +0000 Subject: [PATCH 162/248] fix tests --- infra/src/dsl/feature-values.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infra/src/dsl/feature-values.spec.ts b/infra/src/dsl/feature-values.spec.ts index 0c0d49ccc103..b112c76bacd8 100644 --- a/infra/src/dsl/feature-values.spec.ts +++ b/infra/src/dsl/feature-values.spec.ts @@ -130,7 +130,7 @@ describe('Feature-deployment support', () => { BFF_CLIENT_BASE_URL: 'https://feature-A-beta.dev01.devland.is', BFF_LOGOUT_REDIRECT_URI: 'https://feature-A-beta.dev01.devland.is', BFF_CALLBACKS_BASE_PATH: `https://feature-A-beta.dev01.devland.is/stjornbord/bff/callbacks`, - BFF_PROXY_API_ENDPOINT: 'http://web-api', + BFF_PROXY_API_ENDPOINT: 'http://web-api/api/graphql', BFF_ALLOWED_EXTERNAL_API_URLS: json(['https://api.dev01.devland.is']), BFF_CACHE_USER_PROFILE_TTL_MS: '3595000', BFF_LOGIN_ATTEMPT_TTL_MS: '604800000', From 3d7e7e4922765835a0134c96949a589343456dac Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Mon, 28 Oct 2024 14:20:16 +0000 Subject: [PATCH 163/248] fix tests --- infra/src/dsl/portal-env.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infra/src/dsl/portal-env.spec.ts b/infra/src/dsl/portal-env.spec.ts index c04861287b18..86dbe7c15f38 100644 --- a/infra/src/dsl/portal-env.spec.ts +++ b/infra/src/dsl/portal-env.spec.ts @@ -163,7 +163,7 @@ describe('BFF PortalEnv serialization', () => { BFF_CLIENT_BASE_URL: 'https://beta.dev01.devland.is', BFF_LOGOUT_REDIRECT_URI: 'https://beta.dev01.devland.is', BFF_CALLBACKS_BASE_PATH: `https://beta.dev01.devland.is/${key}/bff/callbacks`, - BFF_PROXY_API_ENDPOINT: 'http://web-api.islandis.svc.cluster.local', + BFF_PROXY_API_ENDPOINT: 'http://web-api.islandis.svc.cluster.local/api/graphql', BFF_ALLOWED_EXTERNAL_API_URLS: json(['https://api.dev01.devland.is']), BFF_CACHE_USER_PROFILE_TTL_MS: ( ONE_HOUR_IN_MS - FIVE_SECONDS_IN_MS From 244c79b8d484c40f03b6877e073f285f7595dfad Mon Sep 17 00:00:00 2001 From: andes-it Date: Mon, 28 Oct 2024 14:21:13 +0000 Subject: [PATCH 164/248] chore: charts update dirty files --- charts/islandis/values.dev.yaml | 2 +- charts/islandis/values.prod.yaml | 2 +- charts/islandis/values.staging.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/charts/islandis/values.dev.yaml b/charts/islandis/values.dev.yaml index 18f861da2a38..09aaa0d03885 100644 --- a/charts/islandis/values.dev.yaml +++ b/charts/islandis/values.dev.yaml @@ -2291,7 +2291,7 @@ services-bff-portals-admin: BFF_LOGOUT_REDIRECT_URI: 'https://beta.dev01.devland.is' BFF_NAME: 'stjornbord' BFF_PAR_SUPPORT_ENABLED: 'false' - BFF_PROXY_API_ENDPOINT: 'http://web-api.islandis.svc.cluster.local' + BFF_PROXY_API_ENDPOINT: 'http://web-api.islandis.svc.cluster.local/api/graphql' IDENTITY_SERVER_CLIENT_ID: '@admin.island.is/bff-stjornbord' IDENTITY_SERVER_CLIENT_SCOPES: '["@admin.island.is/delegations","@admin.island.is/ads","@admin.island.is/regulations","@admin.island.is/regulations:manage","@admin.island.is/icelandic-names-registry","@admin.island.is/application-system:admin","@admin.island.is/application-system:institution","@admin.island.is/document-provider","@admin.island.is/auth","@admin.island.is/auth:admin","@admin.island.is/petitions","@admin.island.is/service-desk","@admin.island.is/ads:explicit","@admin.island.is/signature-collection:manage","@admin.island.is/signature-collection:process","@admin.island.is/form-system","@admin.island.is/form-system:admin","@admin.island.is/delegation-system","@admin.island.is/delegation-system:admin"]' IDENTITY_SERVER_ISSUER_URL: 'https://identity-server.dev01.devland.is' diff --git a/charts/islandis/values.prod.yaml b/charts/islandis/values.prod.yaml index 95ab8b59ba19..0b327347a15a 100644 --- a/charts/islandis/values.prod.yaml +++ b/charts/islandis/values.prod.yaml @@ -2161,7 +2161,7 @@ services-bff-portals-admin: BFF_LOGOUT_REDIRECT_URI: 'https://island.is' BFF_NAME: 'stjornbord' BFF_PAR_SUPPORT_ENABLED: 'false' - BFF_PROXY_API_ENDPOINT: 'http://web-api.islandis.svc.cluster.local' + BFF_PROXY_API_ENDPOINT: 'http://web-api.islandis.svc.cluster.local/api/graphql' IDENTITY_SERVER_CLIENT_ID: '@admin.island.is/bff-stjornbord' IDENTITY_SERVER_CLIENT_SCOPES: '["@admin.island.is/delegations","@admin.island.is/ads","@admin.island.is/regulations","@admin.island.is/regulations:manage","@admin.island.is/icelandic-names-registry","@admin.island.is/application-system:admin","@admin.island.is/application-system:institution","@admin.island.is/document-provider","@admin.island.is/auth","@admin.island.is/auth:admin","@admin.island.is/petitions","@admin.island.is/service-desk","@admin.island.is/ads:explicit","@admin.island.is/signature-collection:manage","@admin.island.is/signature-collection:process","@admin.island.is/form-system","@admin.island.is/form-system:admin","@admin.island.is/delegation-system","@admin.island.is/delegation-system:admin"]' IDENTITY_SERVER_ISSUER_URL: 'https://innskra.island.is' diff --git a/charts/islandis/values.staging.yaml b/charts/islandis/values.staging.yaml index 167c9a98a664..424a6ceb29b8 100644 --- a/charts/islandis/values.staging.yaml +++ b/charts/islandis/values.staging.yaml @@ -2029,7 +2029,7 @@ services-bff-portals-admin: BFF_LOGOUT_REDIRECT_URI: 'https://beta.staging01.devland.is' BFF_NAME: 'stjornbord' BFF_PAR_SUPPORT_ENABLED: 'false' - BFF_PROXY_API_ENDPOINT: 'http://web-api.islandis.svc.cluster.local' + BFF_PROXY_API_ENDPOINT: 'http://web-api.islandis.svc.cluster.local/api/graphql' IDENTITY_SERVER_CLIENT_ID: '@admin.island.is/bff-stjornbord' IDENTITY_SERVER_CLIENT_SCOPES: '["@admin.island.is/delegations","@admin.island.is/ads","@admin.island.is/regulations","@admin.island.is/regulations:manage","@admin.island.is/icelandic-names-registry","@admin.island.is/application-system:admin","@admin.island.is/application-system:institution","@admin.island.is/document-provider","@admin.island.is/auth","@admin.island.is/auth:admin","@admin.island.is/petitions","@admin.island.is/service-desk","@admin.island.is/ads:explicit","@admin.island.is/signature-collection:manage","@admin.island.is/signature-collection:process","@admin.island.is/form-system","@admin.island.is/form-system:admin","@admin.island.is/delegation-system","@admin.island.is/delegation-system:admin"]' IDENTITY_SERVER_ISSUER_URL: 'https://identity-server.staging01.devland.is' From 37613bfa8ab9b31d2336ce7fc7510de4723fa7d9 Mon Sep 17 00:00:00 2001 From: andes-it Date: Mon, 28 Oct 2024 14:31:14 +0000 Subject: [PATCH 165/248] chore: nx format:write update dirty files --- infra/src/dsl/portal-env.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/infra/src/dsl/portal-env.spec.ts b/infra/src/dsl/portal-env.spec.ts index 86dbe7c15f38..7dd4ddbae51b 100644 --- a/infra/src/dsl/portal-env.spec.ts +++ b/infra/src/dsl/portal-env.spec.ts @@ -163,7 +163,8 @@ describe('BFF PortalEnv serialization', () => { BFF_CLIENT_BASE_URL: 'https://beta.dev01.devland.is', BFF_LOGOUT_REDIRECT_URI: 'https://beta.dev01.devland.is', BFF_CALLBACKS_BASE_PATH: `https://beta.dev01.devland.is/${key}/bff/callbacks`, - BFF_PROXY_API_ENDPOINT: 'http://web-api.islandis.svc.cluster.local/api/graphql', + BFF_PROXY_API_ENDPOINT: + 'http://web-api.islandis.svc.cluster.local/api/graphql', BFF_ALLOWED_EXTERNAL_API_URLS: json(['https://api.dev01.devland.is']), BFF_CACHE_USER_PROFILE_TTL_MS: ( ONE_HOUR_IN_MS - FIVE_SECONDS_IN_MS From 8e58bb6027b023b850f7cb0c19ad7b49ead7a323 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Tue, 29 Oct 2024 10:09:53 +0000 Subject: [PATCH 166/248] grantnamespaces --- apps/api/infra/api.ts | 45 ++++++++++--------- apps/services/bff/infra/admin-portal.infra.ts | 3 +- 2 files changed, 25 insertions(+), 23 deletions(-) diff --git a/apps/api/infra/api.ts b/apps/api/infra/api.ts index bf069dfb98ce..586dfec4a2b0 100644 --- a/apps/api/infra/api.ts +++ b/apps/api/infra/api.ts @@ -1,55 +1,55 @@ import { json, ref, service, ServiceBuilder } from '../../../infra/src/dsl/dsl' import { AdrAndMachine, + AircraftRegistry, Base, ChargeFjsV2, - EnergyFunds, Client, CriminalRecord, + DirectorateOfImmigration, Disability, + DistrictCommissionersLicenses, + DistrictCommissionersPCard, DrivingLicense, DrivingLicenseBook, Education, + EnergyFunds, Finance, Firearm, FishingLicense, + Frigg, + HealthDirectorateOrganDonation, + HealthDirectorateVaccination, HealthInsurance, + HousingBenefitCalculator, + Hunting, + IcelandicGovernmentInstitutionVacancies, + Inna, + IntellectualProperties, JudicialAdministration, + JudicialSystemServicePortal, Labor, MunicipalitiesFinancialAid, NationalRegistry, NationalRegistryB2C, + OccupationalLicenses, + OfficialJournalOfIceland, + OfficialJournalOfIcelandApplication, Passports, Payment, PaymentSchedule, Properties, RskCompanyInfo, - TransportAuthority, - Vehicles, - VehiclesMileage, - VehicleServiceFjsV1, - WorkMachines, - IcelandicGovernmentInstitutionVacancies, RskProcuring, - AircraftRegistry, - HousingBenefitCalculator, - OccupationalLicenses, ShipRegistry, - DistrictCommissionersPCard, - DistrictCommissionersLicenses, - DirectorateOfImmigration, - Hunting, SignatureCollection, SocialInsuranceAdministration, - IntellectualProperties, - Inna, + TransportAuthority, UniversityCareers, - OfficialJournalOfIceland, - OfficialJournalOfIcelandApplication, - JudicialSystemServicePortal, - Frigg, - HealthDirectorateOrganDonation, - HealthDirectorateVaccination, + Vehicles, + VehicleServiceFjsV1, + VehiclesMileage, + WorkMachines, } from '../../../infra/src/dsl/xroad' export const serviceSetup = (services: { @@ -469,5 +469,6 @@ export const serviceSetup = (services: { 'api-catalogue', 'application-system', 'consultation-portal', + 'services-bff-portals-admin', ) } diff --git a/apps/services/bff/infra/admin-portal.infra.ts b/apps/services/bff/infra/admin-portal.infra.ts index 870a65fa35b6..f04db09065bd 100644 --- a/apps/services/bff/infra/admin-portal.infra.ts +++ b/apps/services/bff/infra/admin-portal.infra.ts @@ -1,5 +1,5 @@ /* eslint-disable @nx/enforce-module-boundaries */ -import { ServiceBuilder, service, json } from '../../../../infra/src/dsl/dsl' +import { ServiceBuilder, json, service } from '../../../../infra/src/dsl/dsl' import { BffInfraServices } from '../../../../infra/src/dsl/types/input-types' const bffName = 'services-bff' @@ -71,3 +71,4 @@ export const serviceSetup = ( paths: ['/stjornbord/bff'], }, }) + .grantNamespaces('identity-server') From 133e006db50af02a7b64d5ab22364687df577669 Mon Sep 17 00:00:00 2001 From: andes-it Date: Tue, 29 Oct 2024 10:12:38 +0000 Subject: [PATCH 167/248] chore: charts update dirty files --- charts/islandis/values.dev.yaml | 11 +++++++---- charts/islandis/values.prod.yaml | 11 +++++++---- charts/islandis/values.staging.yaml | 11 +++++++---- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/charts/islandis/values.dev.yaml b/charts/islandis/values.dev.yaml index 09aaa0d03885..2bdf419d25eb 100644 --- a/charts/islandis/values.dev.yaml +++ b/charts/islandis/values.dev.yaml @@ -417,6 +417,7 @@ api: - 'api-catalogue' - 'application-system' - 'consultation-portal' + - 'services-bff-portals-admin' grantNamespacesEnabled: true healthCheck: liveness: @@ -1800,8 +1801,9 @@ portals-admin: SERVERSIDE_FEATURES_ON: '' SI_PUBLIC_ENVIRONMENT: 'dev' SI_PUBLIC_IDENTITY_SERVER_ISSUER_URL: 'https://identity-server.dev01.devland.is' - grantNamespaces: [] - grantNamespacesEnabled: false + grantNamespaces: + - 'identity-server' + grantNamespacesEnabled: true healthCheck: liveness: initialDelaySeconds: 3 @@ -2299,8 +2301,9 @@ services-bff-portals-admin: NODE_OPTIONS: '--max-old-space-size=460 -r dd-trace/init' REDIS_URL_NODE_01: '["clustercfg.general-redis-cluster-group.5fzau3.euw1.cache.amazonaws.com:6379"]' SERVERSIDE_FEATURES_ON: '' - grantNamespaces: [] - grantNamespacesEnabled: false + grantNamespaces: + - 'identity-server' + grantNamespacesEnabled: true healthCheck: liveness: initialDelaySeconds: 3 diff --git a/charts/islandis/values.prod.yaml b/charts/islandis/values.prod.yaml index 0b327347a15a..6ec65349c872 100644 --- a/charts/islandis/values.prod.yaml +++ b/charts/islandis/values.prod.yaml @@ -405,6 +405,7 @@ api: - 'api-catalogue' - 'application-system' - 'consultation-portal' + - 'services-bff-portals-admin' grantNamespacesEnabled: true healthCheck: liveness: @@ -1665,8 +1666,9 @@ portals-admin: SERVERSIDE_FEATURES_ON: 'driving-license-use-v1-endpoint-for-v2-comms' SI_PUBLIC_ENVIRONMENT: 'prod' SI_PUBLIC_IDENTITY_SERVER_ISSUER_URL: 'https://innskra.island.is' - grantNamespaces: [] - grantNamespacesEnabled: false + grantNamespaces: + - 'identity-server' + grantNamespacesEnabled: true healthCheck: liveness: initialDelaySeconds: 3 @@ -2169,8 +2171,9 @@ services-bff-portals-admin: NODE_OPTIONS: '--max-old-space-size=460 -r dd-trace/init' REDIS_URL_NODE_01: '["clustercfg.general-redis-cluster-group.whakos.euw1.cache.amazonaws.com:6379"]' SERVERSIDE_FEATURES_ON: 'driving-license-use-v1-endpoint-for-v2-comms' - grantNamespaces: [] - grantNamespacesEnabled: false + grantNamespaces: + - 'identity-server' + grantNamespacesEnabled: true healthCheck: liveness: initialDelaySeconds: 3 diff --git a/charts/islandis/values.staging.yaml b/charts/islandis/values.staging.yaml index 424a6ceb29b8..e84bd2289ed9 100644 --- a/charts/islandis/values.staging.yaml +++ b/charts/islandis/values.staging.yaml @@ -417,6 +417,7 @@ api: - 'api-catalogue' - 'application-system' - 'consultation-portal' + - 'services-bff-portals-admin' grantNamespacesEnabled: true healthCheck: liveness: @@ -1538,8 +1539,9 @@ portals-admin: SERVERSIDE_FEATURES_ON: '' SI_PUBLIC_ENVIRONMENT: 'staging' SI_PUBLIC_IDENTITY_SERVER_ISSUER_URL: 'https://identity-server.staging01.devland.is' - grantNamespaces: [] - grantNamespacesEnabled: false + grantNamespaces: + - 'identity-server' + grantNamespacesEnabled: true healthCheck: liveness: initialDelaySeconds: 3 @@ -2037,8 +2039,9 @@ services-bff-portals-admin: NODE_OPTIONS: '--max-old-space-size=460 -r dd-trace/init' REDIS_URL_NODE_01: '["clustercfg.general-redis-cluster-group.ab9ckb.euw1.cache.amazonaws.com:6379"]' SERVERSIDE_FEATURES_ON: '' - grantNamespaces: [] - grantNamespacesEnabled: false + grantNamespaces: + - 'identity-server' + grantNamespacesEnabled: true healthCheck: liveness: initialDelaySeconds: 3 From 6432c0c027dbac7bdb99411fb4743b32e77de5ab Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Tue, 29 Oct 2024 10:26:54 +0000 Subject: [PATCH 168/248] grantnamespace identity server --- apps/services/bff/infra/my-pages-portal.infra.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/services/bff/infra/my-pages-portal.infra.ts b/apps/services/bff/infra/my-pages-portal.infra.ts index 63a46e395a39..ad1e39796b15 100644 --- a/apps/services/bff/infra/my-pages-portal.infra.ts +++ b/apps/services/bff/infra/my-pages-portal.infra.ts @@ -73,3 +73,4 @@ export const serviceSetup = ( paths: [`/${key}/bff`], }, }) + .grantNamespaces('identity-server') From 2218c31c34ceb239ed68718a0636611a537babcf Mon Sep 17 00:00:00 2001 From: andes-it Date: Tue, 29 Oct 2024 10:27:27 +0000 Subject: [PATCH 169/248] chore: charts update dirty files --- charts/islandis/values.dev.yaml | 7 ++++--- charts/islandis/values.prod.yaml | 7 ++++--- charts/islandis/values.staging.yaml | 7 ++++--- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/charts/islandis/values.dev.yaml b/charts/islandis/values.dev.yaml index 37d4cd3e5198..27c51489d92e 100644 --- a/charts/islandis/values.dev.yaml +++ b/charts/islandis/values.dev.yaml @@ -2377,7 +2377,7 @@ services-bff-portals-my-pages: BFF_LOGOUT_REDIRECT_URI: 'https://beta.dev01.devland.is' BFF_NAME: 'minarsidur' BFF_PAR_SUPPORT_ENABLED: 'false' - BFF_PROXY_API_ENDPOINT: 'http://web-api.islandis.svc.cluster.local' + BFF_PROXY_API_ENDPOINT: 'http://web-api.islandis.svc.cluster.local/api/graphql' IDENTITY_SERVER_CLIENT_ID: '@island.is/bff' IDENTITY_SERVER_CLIENT_SCOPES: '["api_resource.scope","@island.is/applications:read","@island.is/applications:write","@island.is/user-profile:read","@island.is/user-profile:write","@island.is/auth/actor-delegations","@island.is/auth/delegations:write","@island.is/auth/consents","@skra.is/individuals","@island.is/documents","@island.is/endorsements","@admin.island.is/petitions","@island.is/assets/ip","@island.is/assets","@island.is/education","@island.is/education-license","@island.is/finance:overview","@island.is/finance/salary","@island.is/finance/schedule:read","@island.is/finance/loans","@island.is/internal","@island.is/internal:procuring","@island.is/me:details","@island.is/law-and-order","@island.is/licenses","@island.is/licenses:verify","@island.is/company","@island.is/vehicles","@island.is/work-machines","@island.is/health/payments","@island.is/health/medicines","@island.is/health/assistive-devices-and-nutrition","@island.is/health/therapies","@island.is/health/healthcare","@island.is/health/rights-status","@island.is/health/dentists","@island.is/health/organ-donation","@island.is/health/vaccinations","@island.is/signature-collection"]' IDENTITY_SERVER_ISSUER_URL: 'https://identity-server.dev01.devland.is' @@ -2385,8 +2385,9 @@ services-bff-portals-my-pages: NODE_OPTIONS: '--max-old-space-size=460 -r dd-trace/init' REDIS_URL_NODE_01: '["clustercfg.general-redis-cluster-group.5fzau3.euw1.cache.amazonaws.com:6379"]' SERVERSIDE_FEATURES_ON: '' - grantNamespaces: [] - grantNamespacesEnabled: false + grantNamespaces: + - 'identity-server' + grantNamespacesEnabled: true healthCheck: liveness: initialDelaySeconds: 3 diff --git a/charts/islandis/values.prod.yaml b/charts/islandis/values.prod.yaml index f1e606f53d6b..7db0ae61db1f 100644 --- a/charts/islandis/values.prod.yaml +++ b/charts/islandis/values.prod.yaml @@ -2250,7 +2250,7 @@ services-bff-portals-my-pages: BFF_LOGOUT_REDIRECT_URI: 'https://island.is' BFF_NAME: 'minarsidur' BFF_PAR_SUPPORT_ENABLED: 'false' - BFF_PROXY_API_ENDPOINT: 'http://web-api.islandis.svc.cluster.local' + BFF_PROXY_API_ENDPOINT: 'http://web-api.islandis.svc.cluster.local/api/graphql' IDENTITY_SERVER_CLIENT_ID: '@island.is/bff' IDENTITY_SERVER_CLIENT_SCOPES: '["api_resource.scope","@island.is/applications:read","@island.is/applications:write","@island.is/user-profile:read","@island.is/user-profile:write","@island.is/auth/actor-delegations","@island.is/auth/delegations:write","@island.is/auth/consents","@skra.is/individuals","@island.is/documents","@island.is/endorsements","@admin.island.is/petitions","@island.is/assets/ip","@island.is/assets","@island.is/education","@island.is/education-license","@island.is/finance:overview","@island.is/finance/salary","@island.is/finance/schedule:read","@island.is/finance/loans","@island.is/internal","@island.is/internal:procuring","@island.is/me:details","@island.is/law-and-order","@island.is/licenses","@island.is/licenses:verify","@island.is/company","@island.is/vehicles","@island.is/work-machines","@island.is/health/payments","@island.is/health/medicines","@island.is/health/assistive-devices-and-nutrition","@island.is/health/therapies","@island.is/health/healthcare","@island.is/health/rights-status","@island.is/health/dentists","@island.is/health/organ-donation","@island.is/health/vaccinations","@island.is/signature-collection"]' IDENTITY_SERVER_ISSUER_URL: 'https://innskra.island.is' @@ -2258,8 +2258,9 @@ services-bff-portals-my-pages: NODE_OPTIONS: '--max-old-space-size=460 -r dd-trace/init' REDIS_URL_NODE_01: '["clustercfg.general-redis-cluster-group.whakos.euw1.cache.amazonaws.com:6379"]' SERVERSIDE_FEATURES_ON: 'driving-license-use-v1-endpoint-for-v2-comms' - grantNamespaces: [] - grantNamespacesEnabled: false + grantNamespaces: + - 'identity-server' + grantNamespacesEnabled: true healthCheck: liveness: initialDelaySeconds: 3 diff --git a/charts/islandis/values.staging.yaml b/charts/islandis/values.staging.yaml index 93a0a88a34d7..e75ff1c53c36 100644 --- a/charts/islandis/values.staging.yaml +++ b/charts/islandis/values.staging.yaml @@ -2116,7 +2116,7 @@ services-bff-portals-my-pages: BFF_LOGOUT_REDIRECT_URI: 'https://beta.staging01.devland.is' BFF_NAME: 'minarsidur' BFF_PAR_SUPPORT_ENABLED: 'false' - BFF_PROXY_API_ENDPOINT: 'http://web-api.islandis.svc.cluster.local' + BFF_PROXY_API_ENDPOINT: 'http://web-api.islandis.svc.cluster.local/api/graphql' IDENTITY_SERVER_CLIENT_ID: '@island.is/bff' IDENTITY_SERVER_CLIENT_SCOPES: '["api_resource.scope","@island.is/applications:read","@island.is/applications:write","@island.is/user-profile:read","@island.is/user-profile:write","@island.is/auth/actor-delegations","@island.is/auth/delegations:write","@island.is/auth/consents","@skra.is/individuals","@island.is/documents","@island.is/endorsements","@admin.island.is/petitions","@island.is/assets/ip","@island.is/assets","@island.is/education","@island.is/education-license","@island.is/finance:overview","@island.is/finance/salary","@island.is/finance/schedule:read","@island.is/finance/loans","@island.is/internal","@island.is/internal:procuring","@island.is/me:details","@island.is/law-and-order","@island.is/licenses","@island.is/licenses:verify","@island.is/company","@island.is/vehicles","@island.is/work-machines","@island.is/health/payments","@island.is/health/medicines","@island.is/health/assistive-devices-and-nutrition","@island.is/health/therapies","@island.is/health/healthcare","@island.is/health/rights-status","@island.is/health/dentists","@island.is/health/organ-donation","@island.is/health/vaccinations","@island.is/signature-collection"]' IDENTITY_SERVER_ISSUER_URL: 'https://identity-server.staging01.devland.is' @@ -2124,8 +2124,9 @@ services-bff-portals-my-pages: NODE_OPTIONS: '--max-old-space-size=460 -r dd-trace/init' REDIS_URL_NODE_01: '["clustercfg.general-redis-cluster-group.ab9ckb.euw1.cache.amazonaws.com:6379"]' SERVERSIDE_FEATURES_ON: '' - grantNamespaces: [] - grantNamespacesEnabled: false + grantNamespaces: + - 'identity-server' + grantNamespacesEnabled: true healthCheck: liveness: initialDelaySeconds: 3 From f19f501f3e1404474bcbe4ae7add57c16f332da3 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Tue, 29 Oct 2024 14:08:56 +0000 Subject: [PATCH 170/248] disable global auth on dev --- apps/services/bff/infra/admin-portal.infra.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/services/bff/infra/admin-portal.infra.ts b/apps/services/bff/infra/admin-portal.infra.ts index 824ac816ecf4..702e5a5fb815 100644 --- a/apps/services/bff/infra/admin-portal.infra.ts +++ b/apps/services/bff/infra/admin-portal.infra.ts @@ -55,6 +55,7 @@ export const serviceSetup = ( }, extraAnnotations: { dev: { + 'nginx.ingress.kubernetes.io/enable-global-auth': 'false', 'nginx.ingress.kubernetes.io/proxy-buffering': 'on', 'nginx.ingress.kubernetes.io/proxy-buffer-size': '8k', }, From 5ccc148059c7ef929ce1530de38e5d06616114ce Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Tue, 29 Oct 2024 14:11:29 +0000 Subject: [PATCH 171/248] disable global auth on dev --- apps/services/bff/infra/my-pages-portal.infra.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/services/bff/infra/my-pages-portal.infra.ts b/apps/services/bff/infra/my-pages-portal.infra.ts index ad1e39796b15..82a63b663180 100644 --- a/apps/services/bff/infra/my-pages-portal.infra.ts +++ b/apps/services/bff/infra/my-pages-portal.infra.ts @@ -57,11 +57,11 @@ export const serviceSetup = ( }, extraAnnotations: { dev: { + 'nginx.ingress.kubernetes.io/enable-global-auth': 'false', 'nginx.ingress.kubernetes.io/proxy-buffering': 'on', 'nginx.ingress.kubernetes.io/proxy-buffer-size': '8k', }, staging: { - 'nginx.ingress.kubernetes.io/enable-global-auth': 'false', 'nginx.ingress.kubernetes.io/proxy-buffering': 'on', 'nginx.ingress.kubernetes.io/proxy-buffer-size': '8k', }, From e23c727c99775ecb51230e3cb965189c0ce7a4ca Mon Sep 17 00:00:00 2001 From: andes-it Date: Tue, 29 Oct 2024 14:16:06 +0000 Subject: [PATCH 172/248] chore: charts update dirty files --- charts/islandis/values.dev.yaml | 2 ++ charts/islandis/values.staging.yaml | 1 - 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/charts/islandis/values.dev.yaml b/charts/islandis/values.dev.yaml index 27c51489d92e..c577e1861576 100644 --- a/charts/islandis/values.dev.yaml +++ b/charts/islandis/values.dev.yaml @@ -2328,6 +2328,7 @@ services-bff-portals-admin: primary-alb: annotations: kubernetes.io/ingress.class: 'nginx-external-alb' + nginx.ingress.kubernetes.io/enable-global-auth: 'false' nginx.ingress.kubernetes.io/proxy-buffer-size: '8k' nginx.ingress.kubernetes.io/proxy-buffering: 'on' nginx.ingress.kubernetes.io/service-upstream: 'true' @@ -2411,6 +2412,7 @@ services-bff-portals-my-pages: primary-alb: annotations: kubernetes.io/ingress.class: 'nginx-external-alb' + nginx.ingress.kubernetes.io/enable-global-auth: 'false' nginx.ingress.kubernetes.io/proxy-buffer-size: '8k' nginx.ingress.kubernetes.io/proxy-buffering: 'on' nginx.ingress.kubernetes.io/service-upstream: 'true' diff --git a/charts/islandis/values.staging.yaml b/charts/islandis/values.staging.yaml index e75ff1c53c36..03005a33357c 100644 --- a/charts/islandis/values.staging.yaml +++ b/charts/islandis/values.staging.yaml @@ -2150,7 +2150,6 @@ services-bff-portals-my-pages: primary-alb: annotations: kubernetes.io/ingress.class: 'nginx-external-alb' - nginx.ingress.kubernetes.io/enable-global-auth: 'false' nginx.ingress.kubernetes.io/proxy-buffer-size: '8k' nginx.ingress.kubernetes.io/proxy-buffering: 'on' nginx.ingress.kubernetes.io/service-upstream: 'true' From 02b2c52b2029d08a2897b7628b0bbc4d7ae773eb Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Tue, 29 Oct 2024 15:05:36 +0000 Subject: [PATCH 173/248] Fix UserMenu test --- apps/portals/my-pages/src/app/App.tsx | 14 ++-- libs/react-spa/bff/src/index.ts | 3 +- .../src/auth/UserMenu/UserMenu.spec.tsx | 76 +++++++++++-------- libs/shared/utils/src/lib/isDelegation.ts | 2 +- 4 files changed, 56 insertions(+), 39 deletions(-) diff --git a/apps/portals/my-pages/src/app/App.tsx b/apps/portals/my-pages/src/app/App.tsx index 3446e7abe792..8c35cf394c51 100644 --- a/apps/portals/my-pages/src/app/App.tsx +++ b/apps/portals/my-pages/src/app/App.tsx @@ -1,20 +1,20 @@ import { ApolloProvider } from '@apollo/client' -import { client } from '@island.is/service-portal/graphql' +import { servicePortalScopes } from '@island.is/auth/scopes' import { LocaleProvider } from '@island.is/localization' -import { defaultLanguage } from '@island.is/shared/constants' -import { ServicePortalPaths } from '@island.is/service-portal/core' -import { FeatureFlagProvider } from '@island.is/react/feature-flags' import { ApplicationErrorBoundary, PortalRouter, isMockMode, } from '@island.is/portals/core' +import { BffProvider, createMockedInitialState } from '@island.is/react-spa/bff' +import { FeatureFlagProvider } from '@island.is/react/feature-flags' +import { ServicePortalPaths } from '@island.is/service-portal/core' +import { client } from '@island.is/service-portal/graphql' +import { defaultLanguage } from '@island.is/shared/constants' +import { environment } from '../environments' import { modules } from '../lib/modules' import { createRoutes } from '../lib/routes' -import { environment } from '../environments' import * as styles from './App.css' -import { BffProvider, createMockedInitialState } from '@island.is/react-spa/bff' -import { servicePortalScopes } from '@island.is/auth/scopes' const mockedInitialState = isMockMode ? createMockedInitialState({ diff --git a/libs/react-spa/bff/src/index.ts b/libs/react-spa/bff/src/index.ts index d535a9f34f45..db4a4d586734 100644 --- a/libs/react-spa/bff/src/index.ts +++ b/libs/react-spa/bff/src/index.ts @@ -1,4 +1,5 @@ +export * from './lib/BffContext' export * from './lib/BffProvider' export * from './lib/bff.hooks' -export * from './lib/bff.utils' export * from './lib/bff.mocks' +export * from './lib/bff.utils' diff --git a/libs/shared/components/src/auth/UserMenu/UserMenu.spec.tsx b/libs/shared/components/src/auth/UserMenu/UserMenu.spec.tsx index b632a98831b1..172df3c59438 100644 --- a/libs/shared/components/src/auth/UserMenu/UserMenu.spec.tsx +++ b/libs/shared/components/src/auth/UserMenu/UserMenu.spec.tsx @@ -1,27 +1,34 @@ -import React, { FC, ReactNode } from 'react' -import { BrowserRouter } from 'react-router-dom' +import { MockedProvider } from '@apollo/client/testing' +import { LocaleContext, LocaleProvider } from '@island.is/localization' +import { + BffContext, + BffContextType, + createMockedInitialState, +} from '@island.is/react-spa/bff' +import { BffUser } from '@island.is/shared/types' +import '@testing-library/jest-dom' import { - render, - screen, - fireEvent, act, - getByText, + fireEvent, getByRole, + getByText, + render, + screen, } from '@testing-library/react' -import '@testing-library/jest-dom' -import { MockedProvider } from '@apollo/client/testing' -import { LocaleProvider, LocaleContext } from '@island.is/localization' -import { MockedAuthProvider, MockUser } from '@island.is/auth/react' +import React, { FC, ReactNode } from 'react' +import { BrowserRouter } from 'react-router-dom' +import { ActorDelegationsQuery, GetUserProfileQuery } from '../../../gen/schema' import { UserMenu } from './UserMenu' import { ACTOR_DELEGATIONS } from './actorDelegations.graphql' -import { ActorDelegationsQuery } from '../../../gen/schema' import { USER_PROFILE } from './userProfile.graphql' -import { GetUserProfileQuery } from '../../../gen/schema' const delegation = { name: 'Phil', nationalId: '1111111111', } + +const mockedUser = createMockedInitialState().userInfo + const mocks = [ { request: { @@ -74,12 +81,23 @@ describe('UserMenu', () => { const renderAuthenticated = ( ui: ReactNode, - { user }: { user?: MockUser } = {}, + user: { + profile?: Partial + scopes?: Partial + } | null = null, ) => render( - + {ui} - , + , { wrapper, }, @@ -101,7 +119,9 @@ describe('UserMenu', () => { it('shows user menu when authenticated', async () => { // Act renderAuthenticated(, { - user: { profile: { name: 'John' } }, + profile: { + name: 'John', + }, }) // Assert @@ -112,11 +132,9 @@ describe('UserMenu', () => { it('shows delegation name when authenticated with delegations', async () => { // Act renderAuthenticated(, { - user: { - profile: { - name: 'John', - actor: { name: 'Anna', nationalId: '2222222222' }, - }, + profile: { + name: 'John', + actor: { name: 'Anna', nationalId: '2222222222' }, }, }) @@ -128,7 +146,7 @@ describe('UserMenu', () => { it('can open and close user menu', async () => { // Arrange - renderAuthenticated(, { user: { profile: { name: 'John' } } }) + renderAuthenticated(, { profile: { name: 'John' } }) // Act const dialog = await openMenu() @@ -144,7 +162,7 @@ describe('UserMenu', () => { }) it('can log out user', async () => { // Arrange - renderAuthenticated(, { user: {} }) + renderAuthenticated(, mockedUser) await openMenu() // Act @@ -164,7 +182,7 @@ describe('UserMenu', () => { {({ lang }) => Current: {lang}} , - { user: {} }, + mockedUser, ) const dialog = await openMenu() const languageSelector = dialog.querySelector('#language-switcher')! @@ -191,7 +209,7 @@ describe('UserMenu', () => { {({ lang }) => Current: {lang}} , - { user: {} }, + mockedUser, ) const languageSelector = screen.getByTestId('language-switcher-button') expect(languageSelector).not.toBeNull() @@ -205,9 +223,7 @@ describe('UserMenu', () => { it('can switch between delegations', async () => { // Arrange - renderAuthenticated(, { - user: {}, - }) + renderAuthenticated(, mockedUser) const dialog = await openMenu() const delegationButton = getByRole(dialog, 'button', { name: 'Skipta um notanda', @@ -224,7 +240,7 @@ describe('UserMenu', () => { it('hides language switcher', async () => { // Arrange - renderAuthenticated(, { user: {} }) + renderAuthenticated(, mockedUser) // Assert const languageSelector = await screen.queryByTestId( @@ -236,7 +252,7 @@ describe('UserMenu', () => { it('user button shows icon only in mobile and not name', async () => { // Act renderAuthenticated(, { - user: { profile: { name: 'John' } }, + profile: { name: 'John' }, }) // Assert diff --git a/libs/shared/utils/src/lib/isDelegation.ts b/libs/shared/utils/src/lib/isDelegation.ts index fb36417a6f5d..877fa079b49f 100644 --- a/libs/shared/utils/src/lib/isDelegation.ts +++ b/libs/shared/utils/src/lib/isDelegation.ts @@ -1,5 +1,5 @@ import { BffUser, User } from '@island.is/shared/types' export const checkDelegation = (user: User | BffUser) => { - return Boolean(user?.profile.actor) + return Boolean(user?.profile?.actor) } From 1a829fc7478558be431b3766ddeda5418960dee3 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Tue, 29 Oct 2024 15:33:24 +0000 Subject: [PATCH 174/248] fix portal core tests --- .../hooks/useSingleNavigationItem.spec.tsx | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/libs/portals/core/src/hooks/useSingleNavigationItem.spec.tsx b/libs/portals/core/src/hooks/useSingleNavigationItem.spec.tsx index 2734c258ba63..b523f2c53b03 100644 --- a/libs/portals/core/src/hooks/useSingleNavigationItem.spec.tsx +++ b/libs/portals/core/src/hooks/useSingleNavigationItem.spec.tsx @@ -1,24 +1,24 @@ -import { BrowserRouter } from 'react-router-dom' -import { ReactNode, FC } from 'react' import { ApolloClient, ApolloProvider, InMemoryCache } from '@apollo/client' +import { BffProvider, createMockedInitialState } from '@island.is/react-spa/bff' +import { FeatureFlagClient } from '@island.is/react/feature-flags' +import { defaultLanguage } from '@island.is/shared/constants' +import { BffUser } from '@island.is/shared/types' import { renderHook } from '@testing-library/react' +import { FC, ReactNode } from 'react' import { IntlProvider } from 'react-intl' -import { MockedAuthProvider } from '@island.is/auth/react' -import { defaultLanguage } from '@island.is/shared/constants' +import { BrowserRouter } from 'react-router-dom' import { testCases } from '../../test/useSingleNavigationItem-test-cases' +import { PortalContext, PortalMeta } from '../components/PortalProvider' import { PortalModule, PortalNavigationItem, PortalRoute, } from '../types/portalCore' -import { FeatureFlagClient } from '@island.is/react/feature-flags' -import { createMockUser } from '@island.is/auth/react' -import { useSingleNavigationItem } from './useSingleNavigationItem' import { prepareRouterData } from '../utils/router/prepareRouterData' -import { PortalContext, PortalMeta } from '../components/PortalProvider' +import { useSingleNavigationItem } from './useSingleNavigationItem' -const user = { profile: { name: 'Peter' } } -const userInfo = createMockUser(user) +const user = { profile: { name: 'Peter' } } as BffUser +const mockedInitialState = createMockedInitialState() const MockedPortalProvider: FC< React.PropsWithChildren<{ @@ -53,7 +53,7 @@ describe('useSingleNavigationItem hook', () => { beforeAll(async () => { const routerData = await prepareRouterData({ - userInfo, + userInfo: mockedInitialState.userInfo, featureFlagClient: {} as FeatureFlagClient, modules: testModules, }) @@ -70,7 +70,10 @@ describe('useSingleNavigationItem hook', () => { // Ignoring error because we don't need translations for tests }} > - + { {children} - +
) From 2c024b15298d2a1631c4ecaf9e4eac8bbc652825 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B3n=20Levy?= Date: Wed, 30 Oct 2024 13:59:54 +0000 Subject: [PATCH 175/248] test: update bff tests --- infra/src/dsl/portal-env.spec.ts | 301 +++++++++++++++++-------------- 1 file changed, 162 insertions(+), 139 deletions(-) diff --git a/infra/src/dsl/portal-env.spec.ts b/infra/src/dsl/portal-env.spec.ts index 7dd4ddbae51b..1b2bbd4b50b5 100644 --- a/infra/src/dsl/portal-env.spec.ts +++ b/infra/src/dsl/portal-env.spec.ts @@ -1,21 +1,56 @@ import { ServiceBuilder, service } from './dsl' import { Kubernetes } from './kubernetes-runtime' import { SerializeSuccess, HelmService } from './types/output-types' +import { PortalKeys } from './types/input-types' import { EnvironmentConfig } from './types/charts' import { renderers } from './upstream-dependencies' import { generateOutputOne } from './processing/rendering-pipeline' import { json } from './dsl' -import { adminPortalScopes } from '../../../libs/auth/scopes/src/index' +import { + adminPortalScopes, + servicePortalScopes, +} from '../../../libs/auth/scopes/src/index' import { FIVE_SECONDS_IN_MS } from '../../../apps/services/bff/src/app/constants/time' +type ScopeType = typeof servicePortalScopes | typeof adminPortalScopes + +interface ServiceConfig { + bffName: string + clientName: string + serviceName: string + key: PortalKeys + scopes: ScopeType +} + +const opts: Record = { + servicePortal: { + bffName: 'services-bff', + clientName: 'portals-admin', + serviceName: 'services-bff-portals-admin', + key: 'stjornbord', + scopes: servicePortalScopes, + }, + portalsAdmin: { + bffName: 'services-bff', + clientName: 'portals-my-pages', + serviceName: 'services-bff-portals-my-pages', + key: 'minarsidur', + scopes: adminPortalScopes, + }, +} + +const servicesOpts = [ + { name: 'servicePortal', config: opts.servicePortal }, + { name: 'portalsAdmin', config: opts.portalsAdmin }, +] const ONE_HOUR_IN_MS = 60 * 60 * 1000 const ONE_WEEK_IN_MS = ONE_HOUR_IN_MS * 24 * 7 -const bffName = 'services-bff' -const clientName = 'portals-admin' -const serviceName = `${bffName}-${clientName}` -const key = 'stjornbord' +// const bffName = 'services-bff' +// const clientName = 'portals-admin' +// const serviceName = `${bffName}-${clientName}` +// const key = 'stjornbord' const Dev: EnvironmentConfig = { auroraHost: 'a', @@ -35,9 +70,9 @@ const services = { api: service('api'), } -describe('BFF PortalEnv serialization', () => { - const services = { api: service('api') } - const sut = service(serviceName) +const createService = (config: ServiceConfig) => { + const { serviceName, clientName, bffName, key } = config + return service(serviceName) .namespace(clientName) .image(bffName) .redis() @@ -51,8 +86,8 @@ describe('BFF PortalEnv serialization', () => { }, }) .bff({ - key: 'stjornbord', - clientId: `@admin.island.is/bff-stjornbord`, + key, + clientId: `@admin.island.is/bff-${key}`, clientName, services, }) @@ -100,140 +135,128 @@ describe('BFF PortalEnv serialization', () => { paths: [`/${key}/bff`], }, }) - let result: SerializeSuccess - beforeEach(async () => { - result = (await generateOutputOne({ - outputFormat: renderers.helm, - service: sut, - runtime: new Kubernetes(Dev), - env: Dev, - })) as SerializeSuccess - }) - - it('basic props', () => { - expect(result.serviceDef[0].enabled).toBe(true) - expect(result.serviceDef[0].namespace).toBe(clientName) - }) - - it('image and repo', () => { - expect(result.serviceDef[0].image.repository).toBe( - `821090935708.dkr.ecr.eu-west-1.amazonaws.com/${bffName}`, - ) - }) - - it('command and args', () => { - expect(result.serviceDef[0].command).toStrictEqual(['node']) - expect(result.serviceDef[0].args).toStrictEqual(['main.js']) - }) - it('network policies', () => { - expect(result.serviceDef[0].grantNamespaces).toStrictEqual([]) - expect(result.serviceDef[0].grantNamespacesEnabled).toBe(false) - }) - - it('resources', () => { - expect(result.serviceDef[0].resources).toStrictEqual({ - limits: { - cpu: '400m', - memory: '512Mi', - }, - requests: { - cpu: '100m', - memory: '256Mi', - }, +} + +describe.each(servicesOpts)( + '$name BFF PortalEnv serialization', + ({ name, config }) => { + const sut = createService(config) + const { serviceName, clientName, bffName, key, scopes } = config + let result: SerializeSuccess + + beforeEach(async () => { + result = (await generateOutputOne({ + outputFormat: renderers.helm, + service: sut, + runtime: new Kubernetes(Dev), + env: Dev, + })) as SerializeSuccess }) - }) - it('replica count', () => { - expect(result.serviceDef[0].replicaCount).toStrictEqual({ - min: 2, - max: 3, - default: 2, + it('basic props', () => { + expect(result.serviceDef[0].enabled).toBe(true) + expect(result.serviceDef[0].namespace).toBe(clientName) }) - }) - - it('environment variables', () => { - expect(result.serviceDef[0].env).toEqual({ - IDENTITY_SERVER_CLIENT_SCOPES: json(adminPortalScopes), - IDENTITY_SERVER_CLIENT_ID: `@admin.island.is/bff-${key}`, - IDENTITY_SERVER_ISSUER_URL: 'https://identity-server.dev01.devland.is', - // BFF - BFF_NAME: 'stjornbord', - BFF_CLIENT_KEY_PATH: `/${key}`, - BFF_PAR_SUPPORT_ENABLED: 'false', - BFF_ALLOWED_REDIRECT_URIS: json(['https://beta.dev01.devland.is']), - BFF_CLIENT_BASE_URL: 'https://beta.dev01.devland.is', - BFF_LOGOUT_REDIRECT_URI: 'https://beta.dev01.devland.is', - BFF_CALLBACKS_BASE_PATH: `https://beta.dev01.devland.is/${key}/bff/callbacks`, - BFF_PROXY_API_ENDPOINT: - 'http://web-api.islandis.svc.cluster.local/api/graphql', - BFF_ALLOWED_EXTERNAL_API_URLS: json(['https://api.dev01.devland.is']), - BFF_CACHE_USER_PROFILE_TTL_MS: ( - ONE_HOUR_IN_MS - FIVE_SECONDS_IN_MS - ).toString(), - BFF_LOGIN_ATTEMPT_TTL_MS: ONE_WEEK_IN_MS.toString(), - NODE_OPTIONS: '--max-old-space-size=460 -r dd-trace/init', - SERVERSIDE_FEATURES_ON: '', - LOG_LEVEL: 'info', - REDIS_URL_NODE_01: 'b', - }) - }) - - it('secrets', () => { - expect(result.serviceDef[0].secrets).toEqual({ - BFF_TOKEN_SECRET_BASE64: `/k8s/${bffName}/${clientName}/BFF_TOKEN_SECRET_BASE64`, - IDENTITY_SERVER_CLIENT_SECRET: `/k8s/${bffName}/${clientName}/IDENTITY_SERVER_CLIENT_SECRET`, - CONFIGCAT_SDK_KEY: '/k8s/configcat/CONFIGCAT_SDK_KEY', - }) - }) - - it('service account', () => { - expect(result.serviceDef[0].podSecurityContext).toEqual({ - fsGroup: 65534, - }) - expect(result.serviceDef[0].serviceAccount).toEqual({ - annotations: { - 'eks.amazonaws.com/role-arn': `arn:aws:iam::111111:role/${bffName}`, - }, - create: true, - name: bffName, + + it('image and repo', () => { + expect(result.serviceDef[0].image.repository).toBe( + `821090935708.dkr.ecr.eu-west-1.amazonaws.com/${bffName}`, + ) }) - }) - it('ingress', () => { - expect(result.serviceDef[0].ingress).toEqual({ - 'primary-alb': { + it('command and args', () => { + expect(result.serviceDef[0].command).toStrictEqual(['node']) + expect(result.serviceDef[0].args).toStrictEqual(['main.js']) + }) + it('network policies', () => { + expect(result.serviceDef[0].grantNamespaces).toStrictEqual([]) + expect(result.serviceDef[0].grantNamespacesEnabled).toBe(false) + }) + + it('resources', () => { + expect(result.serviceDef[0].resources).toStrictEqual({ + limits: { + cpu: '400m', + memory: '512Mi', + }, + requests: { + cpu: '100m', + memory: '256Mi', + }, + }) + }) + it('replica count', () => { + expect(result.serviceDef[0].replicaCount).toStrictEqual({ + min: 2, + max: 3, + default: 2, + }) + }) + + it('environment variables', () => { + expect(result.serviceDef[0].env).toEqual({ + IDENTITY_SERVER_CLIENT_SCOPES: json(scopes), + IDENTITY_SERVER_CLIENT_ID: `@admin.island.is/bff-${key}`, + IDENTITY_SERVER_ISSUER_URL: 'https://identity-server.dev01.devland.is', + // BFF + BFF_NAME: key, + BFF_CLIENT_KEY_PATH: `/${key}`, + BFF_PAR_SUPPORT_ENABLED: 'false', + BFF_ALLOWED_REDIRECT_URIS: json(['https://beta.dev01.devland.is']), + BFF_CLIENT_BASE_URL: 'https://beta.dev01.devland.is', + BFF_LOGOUT_REDIRECT_URI: 'https://beta.dev01.devland.is', + BFF_CALLBACKS_BASE_PATH: `https://beta.dev01.devland.is/${key}/bff/callbacks`, + BFF_PROXY_API_ENDPOINT: + 'http://web-api.islandis.svc.cluster.local/api/graphql', + BFF_ALLOWED_EXTERNAL_API_URLS: json(['https://api.dev01.devland.is']), + BFF_CACHE_USER_PROFILE_TTL_MS: ( + ONE_HOUR_IN_MS - FIVE_SECONDS_IN_MS + ).toString(), + BFF_LOGIN_ATTEMPT_TTL_MS: ONE_WEEK_IN_MS.toString(), + NODE_OPTIONS: '--max-old-space-size=460 -r dd-trace/init', + SERVERSIDE_FEATURES_ON: '', + LOG_LEVEL: 'info', + REDIS_URL_NODE_01: 'b', + }) + }) + + it('secrets', () => { + expect(result.serviceDef[0].secrets).toEqual({ + BFF_TOKEN_SECRET_BASE64: `/k8s/${bffName}/${clientName}/BFF_TOKEN_SECRET_BASE64`, + IDENTITY_SERVER_CLIENT_SECRET: `/k8s/${bffName}/${clientName}/IDENTITY_SERVER_CLIENT_SECRET`, + CONFIGCAT_SDK_KEY: '/k8s/configcat/CONFIGCAT_SDK_KEY', + }) + }) + + it('service account', () => { + expect(result.serviceDef[0].podSecurityContext).toEqual({ + fsGroup: 65534, + }) + expect(result.serviceDef[0].serviceAccount).toEqual({ annotations: { - 'nginx.ingress.kubernetes.io/proxy-buffering': 'on', - 'nginx.ingress.kubernetes.io/proxy-buffer-size': '8k', - 'kubernetes.io/ingress.class': 'nginx-external-alb', - 'nginx.ingress.kubernetes.io/service-upstream': 'true', + 'eks.amazonaws.com/role-arn': `arn:aws:iam::111111:role/${bffName}`, }, - hosts: [ - { - host: 'beta.dev01.devland.is', - paths: ['/stjornbord/bff'], - }, - ], - }, + create: true, + name: bffName, + }) }) - }) -}) - -describe('Env definition defaults', () => { - const sut = service('api').namespace('islandis').image(bffName) - let result: SerializeSuccess - beforeEach(async () => { - result = (await generateOutputOne({ - outputFormat: renderers.helm, - service: sut, - runtime: new Kubernetes(Dev), - env: Dev, - })) as SerializeSuccess - }) - it('replica max count', () => { - expect(result.serviceDef[0].replicaCount).toStrictEqual({ - min: 2, - max: 3, - default: 2, + + it('ingress', () => { + expect(result.serviceDef[0].ingress).toEqual({ + 'primary-alb': { + annotations: { + 'nginx.ingress.kubernetes.io/proxy-buffering': 'on', + 'nginx.ingress.kubernetes.io/proxy-buffer-size': '8k', + 'kubernetes.io/ingress.class': 'nginx-external-alb', + 'nginx.ingress.kubernetes.io/service-upstream': 'true', + }, + hosts: [ + { + host: 'beta.dev01.devland.is', + paths: [`/${key}/bff`], + }, + ], + }, + }) }) - }) -}) + }, +) From cf2d25938e81dcd424a2cd607e9fa4f9d2426468 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B3n=20Levy?= Date: Wed, 30 Oct 2024 15:15:41 +0000 Subject: [PATCH 176/248] test: fix scope bad placement --- infra/src/dsl/portal-env.spec.ts | 215 +++++++++++++++---------------- 1 file changed, 106 insertions(+), 109 deletions(-) diff --git a/infra/src/dsl/portal-env.spec.ts b/infra/src/dsl/portal-env.spec.ts index 1b2bbd4b50b5..39e9dc8b78e3 100644 --- a/infra/src/dsl/portal-env.spec.ts +++ b/infra/src/dsl/portal-env.spec.ts @@ -29,14 +29,14 @@ const opts: Record = { clientName: 'portals-admin', serviceName: 'services-bff-portals-admin', key: 'stjornbord', - scopes: servicePortalScopes, + scopes: adminPortalScopes, }, portalsAdmin: { bffName: 'services-bff', clientName: 'portals-my-pages', serviceName: 'services-bff-portals-my-pages', key: 'minarsidur', - scopes: adminPortalScopes, + scopes: servicePortalScopes, }, } @@ -137,126 +137,123 @@ const createService = (config: ServiceConfig) => { }) } -describe.each(servicesOpts)( - '$name BFF PortalEnv serialization', - ({ name, config }) => { - const sut = createService(config) - const { serviceName, clientName, bffName, key, scopes } = config - let result: SerializeSuccess +describe.each(servicesOpts)('$name BFF serialization', ({ name, config }) => { + const sut = createService(config) + const { serviceName, clientName, bffName, key, scopes } = config + let result: SerializeSuccess - beforeEach(async () => { - result = (await generateOutputOne({ - outputFormat: renderers.helm, - service: sut, - runtime: new Kubernetes(Dev), - env: Dev, - })) as SerializeSuccess - }) - it('basic props', () => { - expect(result.serviceDef[0].enabled).toBe(true) - expect(result.serviceDef[0].namespace).toBe(clientName) - }) + beforeEach(async () => { + result = (await generateOutputOne({ + outputFormat: renderers.helm, + service: sut, + runtime: new Kubernetes(Dev), + env: Dev, + })) as SerializeSuccess + }) + it('basic props', () => { + expect(result.serviceDef[0].enabled).toBe(true) + expect(result.serviceDef[0].namespace).toBe(clientName) + }) - it('image and repo', () => { - expect(result.serviceDef[0].image.repository).toBe( - `821090935708.dkr.ecr.eu-west-1.amazonaws.com/${bffName}`, - ) - }) + it('image and repo', () => { + expect(result.serviceDef[0].image.repository).toBe( + `821090935708.dkr.ecr.eu-west-1.amazonaws.com/${bffName}`, + ) + }) + + it('command and args', () => { + expect(result.serviceDef[0].command).toStrictEqual(['node']) + expect(result.serviceDef[0].args).toStrictEqual(['main.js']) + }) + it('network policies', () => { + expect(result.serviceDef[0].grantNamespaces).toStrictEqual([]) + expect(result.serviceDef[0].grantNamespacesEnabled).toBe(false) + }) - it('command and args', () => { - expect(result.serviceDef[0].command).toStrictEqual(['node']) - expect(result.serviceDef[0].args).toStrictEqual(['main.js']) + it('resources', () => { + expect(result.serviceDef[0].resources).toStrictEqual({ + limits: { + cpu: '400m', + memory: '512Mi', + }, + requests: { + cpu: '100m', + memory: '256Mi', + }, }) - it('network policies', () => { - expect(result.serviceDef[0].grantNamespaces).toStrictEqual([]) - expect(result.serviceDef[0].grantNamespacesEnabled).toBe(false) + }) + it('replica count', () => { + expect(result.serviceDef[0].replicaCount).toStrictEqual({ + min: 2, + max: 3, + default: 2, }) + }) - it('resources', () => { - expect(result.serviceDef[0].resources).toStrictEqual({ - limits: { - cpu: '400m', - memory: '512Mi', - }, - requests: { - cpu: '100m', - memory: '256Mi', - }, - }) - }) - it('replica count', () => { - expect(result.serviceDef[0].replicaCount).toStrictEqual({ - min: 2, - max: 3, - default: 2, - }) + it('environment variables', () => { + expect(result.serviceDef[0].env).toEqual({ + IDENTITY_SERVER_CLIENT_SCOPES: json(scopes), + IDENTITY_SERVER_CLIENT_ID: `@admin.island.is/bff-${key}`, + IDENTITY_SERVER_ISSUER_URL: 'https://identity-server.dev01.devland.is', + // BFF + BFF_NAME: key, + BFF_CLIENT_KEY_PATH: `/${key}`, + BFF_PAR_SUPPORT_ENABLED: 'false', + BFF_ALLOWED_REDIRECT_URIS: json(['https://beta.dev01.devland.is']), + BFF_CLIENT_BASE_URL: 'https://beta.dev01.devland.is', + BFF_LOGOUT_REDIRECT_URI: 'https://beta.dev01.devland.is', + BFF_CALLBACKS_BASE_PATH: `https://beta.dev01.devland.is/${key}/bff/callbacks`, + BFF_PROXY_API_ENDPOINT: + 'http://web-api.islandis.svc.cluster.local/api/graphql', + BFF_ALLOWED_EXTERNAL_API_URLS: json(['https://api.dev01.devland.is']), + BFF_CACHE_USER_PROFILE_TTL_MS: ( + ONE_HOUR_IN_MS - FIVE_SECONDS_IN_MS + ).toString(), + BFF_LOGIN_ATTEMPT_TTL_MS: ONE_WEEK_IN_MS.toString(), + NODE_OPTIONS: '--max-old-space-size=460 -r dd-trace/init', + SERVERSIDE_FEATURES_ON: '', + LOG_LEVEL: 'info', + REDIS_URL_NODE_01: 'b', }) + }) - it('environment variables', () => { - expect(result.serviceDef[0].env).toEqual({ - IDENTITY_SERVER_CLIENT_SCOPES: json(scopes), - IDENTITY_SERVER_CLIENT_ID: `@admin.island.is/bff-${key}`, - IDENTITY_SERVER_ISSUER_URL: 'https://identity-server.dev01.devland.is', - // BFF - BFF_NAME: key, - BFF_CLIENT_KEY_PATH: `/${key}`, - BFF_PAR_SUPPORT_ENABLED: 'false', - BFF_ALLOWED_REDIRECT_URIS: json(['https://beta.dev01.devland.is']), - BFF_CLIENT_BASE_URL: 'https://beta.dev01.devland.is', - BFF_LOGOUT_REDIRECT_URI: 'https://beta.dev01.devland.is', - BFF_CALLBACKS_BASE_PATH: `https://beta.dev01.devland.is/${key}/bff/callbacks`, - BFF_PROXY_API_ENDPOINT: - 'http://web-api.islandis.svc.cluster.local/api/graphql', - BFF_ALLOWED_EXTERNAL_API_URLS: json(['https://api.dev01.devland.is']), - BFF_CACHE_USER_PROFILE_TTL_MS: ( - ONE_HOUR_IN_MS - FIVE_SECONDS_IN_MS - ).toString(), - BFF_LOGIN_ATTEMPT_TTL_MS: ONE_WEEK_IN_MS.toString(), - NODE_OPTIONS: '--max-old-space-size=460 -r dd-trace/init', - SERVERSIDE_FEATURES_ON: '', - LOG_LEVEL: 'info', - REDIS_URL_NODE_01: 'b', - }) + it('secrets', () => { + expect(result.serviceDef[0].secrets).toEqual({ + BFF_TOKEN_SECRET_BASE64: `/k8s/${bffName}/${clientName}/BFF_TOKEN_SECRET_BASE64`, + IDENTITY_SERVER_CLIENT_SECRET: `/k8s/${bffName}/${clientName}/IDENTITY_SERVER_CLIENT_SECRET`, + CONFIGCAT_SDK_KEY: '/k8s/configcat/CONFIGCAT_SDK_KEY', }) + }) - it('secrets', () => { - expect(result.serviceDef[0].secrets).toEqual({ - BFF_TOKEN_SECRET_BASE64: `/k8s/${bffName}/${clientName}/BFF_TOKEN_SECRET_BASE64`, - IDENTITY_SERVER_CLIENT_SECRET: `/k8s/${bffName}/${clientName}/IDENTITY_SERVER_CLIENT_SECRET`, - CONFIGCAT_SDK_KEY: '/k8s/configcat/CONFIGCAT_SDK_KEY', - }) + it('service account', () => { + expect(result.serviceDef[0].podSecurityContext).toEqual({ + fsGroup: 65534, + }) + expect(result.serviceDef[0].serviceAccount).toEqual({ + annotations: { + 'eks.amazonaws.com/role-arn': `arn:aws:iam::111111:role/${bffName}`, + }, + create: true, + name: bffName, }) + }) - it('service account', () => { - expect(result.serviceDef[0].podSecurityContext).toEqual({ - fsGroup: 65534, - }) - expect(result.serviceDef[0].serviceAccount).toEqual({ + it('ingress', () => { + expect(result.serviceDef[0].ingress).toEqual({ + 'primary-alb': { annotations: { - 'eks.amazonaws.com/role-arn': `arn:aws:iam::111111:role/${bffName}`, + 'nginx.ingress.kubernetes.io/proxy-buffering': 'on', + 'nginx.ingress.kubernetes.io/proxy-buffer-size': '8k', + 'kubernetes.io/ingress.class': 'nginx-external-alb', + 'nginx.ingress.kubernetes.io/service-upstream': 'true', }, - create: true, - name: bffName, - }) - }) - - it('ingress', () => { - expect(result.serviceDef[0].ingress).toEqual({ - 'primary-alb': { - annotations: { - 'nginx.ingress.kubernetes.io/proxy-buffering': 'on', - 'nginx.ingress.kubernetes.io/proxy-buffer-size': '8k', - 'kubernetes.io/ingress.class': 'nginx-external-alb', - 'nginx.ingress.kubernetes.io/service-upstream': 'true', + hosts: [ + { + host: 'beta.dev01.devland.is', + paths: [`/${key}/bff`], }, - hosts: [ - { - host: 'beta.dev01.devland.is', - paths: [`/${key}/bff`], - }, - ], - }, - }) + ], + }, }) - }, -) + }) +}) From 3ddca7eacbf6a9f20d7a540308b902f10e1fca4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B3n=20Levy?= Date: Wed, 30 Oct 2024 19:59:09 +0000 Subject: [PATCH 177/248] fix: minor cleanup --- apps/services/bff/infra/my-pages-portal.infra.ts | 5 +---- infra/src/dsl/types/input-types.ts | 10 ++++++---- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/apps/services/bff/infra/my-pages-portal.infra.ts b/apps/services/bff/infra/my-pages-portal.infra.ts index 82a63b663180..f9cfa8b23450 100644 --- a/apps/services/bff/infra/my-pages-portal.infra.ts +++ b/apps/services/bff/infra/my-pages-portal.infra.ts @@ -1,14 +1,11 @@ import { ServiceBuilder, json, service } from '../../../../infra/src/dsl/dsl' +import { BffInfraServices } from '../../../../infra/src/dsl/types/input-types' const bffName = 'services-bff' const clientName = 'portals-my-pages' const serviceName = `${bffName}-${clientName}` const key = 'minarsidur' -export type BffInfraServices = { - api: ServiceBuilder<'api'> -} - export const serviceSetup = ( services: BffInfraServices, ): ServiceBuilder => diff --git a/infra/src/dsl/types/input-types.ts b/infra/src/dsl/types/input-types.ts index 04b75ad4f20e..fc965e65c28b 100644 --- a/infra/src/dsl/types/input-types.ts +++ b/infra/src/dsl/types/input-types.ts @@ -92,7 +92,7 @@ export type MountedFile = { filename: string; env: string } export type PortalKeys = 'stjornbord' | 'minarsidur' export interface BffInfraServices { - api: ServiceBuilder | string + api: ServiceBuilder } export type ServiceDefinitionCore = { @@ -154,9 +154,11 @@ export interface Ingress { } paths: string[] public?: boolean - extraAnnotations?: Partial<{ - [env in OpsEnv]: { [annotation: string]: string | null } - }> + extraAnnotations?: Partial< + { + [env in OpsEnv]: { [annotation: string]: string | null } + } + > } export interface IngressForEnv { From 7708154f9c593607cde550797263e1198099ac01 Mon Sep 17 00:00:00 2001 From: andes-it Date: Wed, 30 Oct 2024 20:05:55 +0000 Subject: [PATCH 178/248] chore: nx format:write update dirty files --- infra/src/dsl/types/input-types.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/infra/src/dsl/types/input-types.ts b/infra/src/dsl/types/input-types.ts index fc965e65c28b..15d9467ae800 100644 --- a/infra/src/dsl/types/input-types.ts +++ b/infra/src/dsl/types/input-types.ts @@ -154,11 +154,9 @@ export interface Ingress { } paths: string[] public?: boolean - extraAnnotations?: Partial< - { - [env in OpsEnv]: { [annotation: string]: string | null } - } - > + extraAnnotations?: Partial<{ + [env in OpsEnv]: { [annotation: string]: string | null } + }> } export interface IngressForEnv { From b73a9bbe4899ff6534fe64642e11f3e40125b052 Mon Sep 17 00:00:00 2001 From: andes-it Date: Fri, 1 Nov 2024 11:18:36 +0000 Subject: [PATCH 179/248] chore: charts update dirty files --- charts/islandis/values.dev.yaml | 84 ++++++++++++++++++++++++++++ charts/islandis/values.prod.yaml | 86 +++++++++++++++++++++++++++++ charts/islandis/values.staging.yaml | 86 +---------------------------- 3 files changed, 171 insertions(+), 85 deletions(-) diff --git a/charts/islandis/values.dev.yaml b/charts/islandis/values.dev.yaml index 8aa5237929a6..7806f7be220a 100644 --- a/charts/islandis/values.dev.yaml +++ b/charts/islandis/values.dev.yaml @@ -2365,6 +2365,90 @@ services-bff-portals-admin: eks.amazonaws.com/role-arn: 'arn:aws:iam::013313053092:role/services-bff' create: true name: 'services-bff' +services-bff-portals-my-pages: + enabled: true + env: + BFF_ALLOWED_EXTERNAL_API_URLS: '["https://api.dev01.devland.is"]' + BFF_ALLOWED_REDIRECT_URIS: '["https://beta.dev01.devland.is"]' + BFF_CACHE_USER_PROFILE_TTL_MS: '3595000' + BFF_CALLBACKS_BASE_PATH: 'https://beta.dev01.devland.is/minarsidur/bff/callbacks' + BFF_CLIENT_BASE_URL: 'https://beta.dev01.devland.is' + BFF_CLIENT_KEY_PATH: '/minarsidur' + BFF_LOGIN_ATTEMPT_TTL_MS: '604800000' + BFF_LOGOUT_REDIRECT_URI: 'https://beta.dev01.devland.is' + BFF_NAME: 'minarsidur' + BFF_PAR_SUPPORT_ENABLED: 'true' + BFF_PROXY_API_ENDPOINT: 'http://web-api.islandis.svc.cluster.local/api/graphql' + IDENTITY_SERVER_CLIENT_ID: '@island.is/bff' + IDENTITY_SERVER_CLIENT_SCOPES: '["api_resource.scope","@island.is/applications:read","@island.is/applications:write","@island.is/user-profile:read","@island.is/user-profile:write","@island.is/auth/actor-delegations","@island.is/auth/delegations:write","@island.is/auth/consents","@skra.is/individuals","@island.is/documents","@island.is/endorsements","@admin.island.is/petitions","@island.is/assets/ip","@island.is/assets","@island.is/education","@island.is/education-license","@island.is/finance:overview","@island.is/finance/salary","@island.is/finance/schedule:read","@island.is/finance/loans","@island.is/internal","@island.is/internal:procuring","@island.is/me:details","@island.is/law-and-order","@island.is/licenses","@island.is/licenses:verify","@island.is/company","@island.is/vehicles","@island.is/work-machines","@island.is/health/payments","@island.is/health/medicines","@island.is/health/assistive-devices-and-nutrition","@island.is/health/therapies","@island.is/health/healthcare","@island.is/health/rights-status","@island.is/health/dentists","@island.is/health/organ-donation","@island.is/health/vaccinations","@island.is/signature-collection"]' + IDENTITY_SERVER_ISSUER_URL: 'https://identity-server.dev01.devland.is' + LOG_LEVEL: 'info' + NODE_OPTIONS: '--max-old-space-size=460 -r dd-trace/init' + REDIS_URL_NODE_01: '["clustercfg.general-redis-cluster-group.5fzau3.euw1.cache.amazonaws.com:6379"]' + SERVERSIDE_FEATURES_ON: '' + grantNamespaces: + - 'identity-server' + grantNamespacesEnabled: true + healthCheck: + liveness: + initialDelaySeconds: 3 + path: '/minarsidur/bff/liveness' + timeoutSeconds: 3 + readiness: + initialDelaySeconds: 3 + path: '/minarsidur/bff/health/check' + timeoutSeconds: 3 + hpa: + scaling: + metric: + cpuAverageUtilization: 90 + nginxRequestsIrate: 5 + replicas: + max: 10 + min: 2 + image: + repository: '821090935708.dkr.ecr.eu-west-1.amazonaws.com/services-bff' + ingress: + primary-alb: + annotations: + kubernetes.io/ingress.class: 'nginx-external-alb' + nginx.ingress.kubernetes.io/enable-global-auth: 'false' + nginx.ingress.kubernetes.io/proxy-buffer-size: '8k' + nginx.ingress.kubernetes.io/proxy-buffering: 'on' + nginx.ingress.kubernetes.io/service-upstream: 'true' + hosts: + - host: 'beta.dev01.devland.is' + paths: + - '/minarsidur/bff' + namespace: 'portals-my-pages' + podDisruptionBudget: + maxUnavailable: 1 + podSecurityContext: + fsGroup: 65534 + pvcs: [] + replicaCount: + default: 2 + max: 10 + min: 2 + resources: + limits: + cpu: '400m' + memory: '512Mi' + requests: + cpu: '100m' + memory: '256Mi' + secrets: + BFF_TOKEN_SECRET_BASE64: '/k8s/services-bff/portals-my-pages/BFF_TOKEN_SECRET_BASE64' + CONFIGCAT_SDK_KEY: '/k8s/configcat/CONFIGCAT_SDK_KEY' + IDENTITY_SERVER_CLIENT_SECRET: '/k8s/services-bff/portals-my-pages/IDENTITY_SERVER_CLIENT_SECRET' + securityContext: + allowPrivilegeEscalation: false + privileged: false + serviceAccount: + annotations: + eks.amazonaws.com/role-arn: 'arn:aws:iam::013313053092:role/services-bff' + create: true + name: 'services-bff' services-documents: enabled: true env: diff --git a/charts/islandis/values.prod.yaml b/charts/islandis/values.prod.yaml index 8ead1b0f95d5..e9f96e59bfd9 100644 --- a/charts/islandis/values.prod.yaml +++ b/charts/islandis/values.prod.yaml @@ -2238,6 +2238,92 @@ services-bff-portals-admin: eks.amazonaws.com/role-arn: 'arn:aws:iam::251502586493:role/services-bff' create: true name: 'services-bff' +services-bff-portals-my-pages: + enabled: true + env: + BFF_ALLOWED_EXTERNAL_API_URLS: '["https://api.island.is"]' + BFF_ALLOWED_REDIRECT_URIS: 'https://island.is' + BFF_CACHE_USER_PROFILE_TTL_MS: '3595000' + BFF_CALLBACKS_BASE_PATH: 'https://island.is/minarsidur/bff/callbacks' + BFF_CLIENT_BASE_URL: 'https://island.is' + BFF_CLIENT_KEY_PATH: '/minarsidur' + BFF_LOGIN_ATTEMPT_TTL_MS: '604800000' + BFF_LOGOUT_REDIRECT_URI: 'https://island.is' + BFF_NAME: 'minarsidur' + BFF_PAR_SUPPORT_ENABLED: 'true' + BFF_PROXY_API_ENDPOINT: 'http://web-api.islandis.svc.cluster.local/api/graphql' + IDENTITY_SERVER_CLIENT_ID: '@island.is/bff' + IDENTITY_SERVER_CLIENT_SCOPES: '["api_resource.scope","@island.is/applications:read","@island.is/applications:write","@island.is/user-profile:read","@island.is/user-profile:write","@island.is/auth/actor-delegations","@island.is/auth/delegations:write","@island.is/auth/consents","@skra.is/individuals","@island.is/documents","@island.is/endorsements","@admin.island.is/petitions","@island.is/assets/ip","@island.is/assets","@island.is/education","@island.is/education-license","@island.is/finance:overview","@island.is/finance/salary","@island.is/finance/schedule:read","@island.is/finance/loans","@island.is/internal","@island.is/internal:procuring","@island.is/me:details","@island.is/law-and-order","@island.is/licenses","@island.is/licenses:verify","@island.is/company","@island.is/vehicles","@island.is/work-machines","@island.is/health/payments","@island.is/health/medicines","@island.is/health/assistive-devices-and-nutrition","@island.is/health/therapies","@island.is/health/healthcare","@island.is/health/rights-status","@island.is/health/dentists","@island.is/health/organ-donation","@island.is/health/vaccinations","@island.is/signature-collection"]' + IDENTITY_SERVER_ISSUER_URL: 'https://innskra.island.is' + LOG_LEVEL: 'info' + NODE_OPTIONS: '--max-old-space-size=460 -r dd-trace/init' + REDIS_URL_NODE_01: '["clustercfg.general-redis-cluster-group.whakos.euw1.cache.amazonaws.com:6379"]' + SERVERSIDE_FEATURES_ON: 'driving-license-use-v1-endpoint-for-v2-comms' + grantNamespaces: + - 'identity-server' + grantNamespacesEnabled: true + healthCheck: + liveness: + initialDelaySeconds: 3 + path: '/minarsidur/bff/liveness' + timeoutSeconds: 3 + readiness: + initialDelaySeconds: 3 + path: '/minarsidur/bff/health/check' + timeoutSeconds: 3 + hpa: + scaling: + metric: + cpuAverageUtilization: 90 + nginxRequestsIrate: 5 + replicas: + max: 10 + min: 2 + image: + repository: '821090935708.dkr.ecr.eu-west-1.amazonaws.com/services-bff' + ingress: + primary-alb: + annotations: + kubernetes.io/ingress.class: 'nginx-external-alb' + nginx.ingress.kubernetes.io/proxy-buffer-size: '8k' + nginx.ingress.kubernetes.io/proxy-buffering: 'on' + nginx.ingress.kubernetes.io/service-upstream: 'true' + hosts: + - host: 'island.is' + paths: + - '/minarsidur/bff' + - host: 'www.island.is' + paths: + - '/minarsidur/bff' + namespace: 'portals-my-pages' + podDisruptionBudget: + maxUnavailable: 1 + podSecurityContext: + fsGroup: 65534 + pvcs: [] + replicaCount: + default: 2 + max: 10 + min: 2 + resources: + limits: + cpu: '400m' + memory: '512Mi' + requests: + cpu: '100m' + memory: '256Mi' + secrets: + BFF_TOKEN_SECRET_BASE64: '/k8s/services-bff/portals-my-pages/BFF_TOKEN_SECRET_BASE64' + CONFIGCAT_SDK_KEY: '/k8s/configcat/CONFIGCAT_SDK_KEY' + IDENTITY_SERVER_CLIENT_SECRET: '/k8s/services-bff/portals-my-pages/IDENTITY_SERVER_CLIENT_SECRET' + securityContext: + allowPrivilegeEscalation: false + privileged: false + serviceAccount: + annotations: + eks.amazonaws.com/role-arn: 'arn:aws:iam::251502586493:role/services-bff' + create: true + name: 'services-bff' services-documents: enabled: true env: diff --git a/charts/islandis/values.staging.yaml b/charts/islandis/values.staging.yaml index ac7dbb6df920..f55ed13cd43f 100644 --- a/charts/islandis/values.staging.yaml +++ b/charts/islandis/values.staging.yaml @@ -2104,90 +2104,6 @@ services-bff-portals-admin: eks.amazonaws.com/role-arn: 'arn:aws:iam::261174024191:role/services-bff' create: true name: 'services-bff' -services-bff-portals-admin: - enabled: true - env: - BFF_ALLOWED_EXTERNAL_API_URLS: '["https://api.staging01.devland.is"]' - BFF_ALLOWED_REDIRECT_URIS: '["https://beta.staging01.devland.is"]' - BFF_CACHE_USER_PROFILE_TTL_MS: '3595000' - BFF_CALLBACKS_BASE_PATH: 'https://beta.staging01.devland.is/stjornbord/bff/callbacks' - BFF_CLIENT_BASE_URL: 'https://beta.staging01.devland.is' - BFF_CLIENT_KEY_PATH: '/stjornbord' - BFF_LOGIN_ATTEMPT_TTL_MS: '604800000' - BFF_LOGOUT_REDIRECT_URI: 'https://beta.staging01.devland.is' - BFF_NAME: 'stjornbord' - BFF_PAR_SUPPORT_ENABLED: 'false' - BFF_PROXY_API_ENDPOINT: 'http://web-api.islandis.svc.cluster.local/api/graphql' - IDENTITY_SERVER_CLIENT_ID: '@admin.island.is/bff-stjornbord' - IDENTITY_SERVER_CLIENT_SCOPES: '["@admin.island.is/delegations","@admin.island.is/ads","@admin.island.is/regulations","@admin.island.is/regulations:manage","@admin.island.is/icelandic-names-registry","@admin.island.is/application-system:admin","@admin.island.is/application-system:institution","@admin.island.is/document-provider","@admin.island.is/auth","@admin.island.is/auth:admin","@admin.island.is/petitions","@admin.island.is/service-desk","@admin.island.is/ads:explicit","@admin.island.is/signature-collection:manage","@admin.island.is/signature-collection:process","@admin.island.is/form-system","@admin.island.is/form-system:admin","@admin.island.is/delegation-system","@admin.island.is/delegation-system:admin"]' - IDENTITY_SERVER_ISSUER_URL: 'https://identity-server.staging01.devland.is' - LOG_LEVEL: 'info' - NODE_OPTIONS: '--max-old-space-size=460 -r dd-trace/init' - REDIS_URL_NODE_01: '["clustercfg.general-redis-cluster-group.ab9ckb.euw1.cache.amazonaws.com:6379"]' - SERVERSIDE_FEATURES_ON: '' - grantNamespaces: - - 'identity-server' - grantNamespacesEnabled: true - healthCheck: - liveness: - initialDelaySeconds: 3 - path: '/stjornbord/bff/liveness' - timeoutSeconds: 3 - readiness: - initialDelaySeconds: 3 - path: '/stjornbord/bff/health/check' - timeoutSeconds: 3 - hpa: - scaling: - metric: - cpuAverageUtilization: 90 - nginxRequestsIrate: 5 - replicas: - max: 10 - min: 2 - image: - repository: '821090935708.dkr.ecr.eu-west-1.amazonaws.com/services-bff' - ingress: - primary-alb: - annotations: - kubernetes.io/ingress.class: 'nginx-external-alb' - nginx.ingress.kubernetes.io/enable-global-auth: 'false' - nginx.ingress.kubernetes.io/proxy-buffer-size: '8k' - nginx.ingress.kubernetes.io/proxy-buffering: 'on' - nginx.ingress.kubernetes.io/service-upstream: 'true' - hosts: - - host: 'beta.staging01.devland.is' - paths: - - '/stjornbord/bff' - namespace: 'portals-admin' - podDisruptionBudget: - maxUnavailable: 1 - podSecurityContext: - fsGroup: 65534 - pvcs: [] - replicaCount: - default: 2 - max: 10 - min: 2 - resources: - limits: - cpu: '400m' - memory: '512Mi' - requests: - cpu: '100m' - memory: '256Mi' - secrets: - BFF_TOKEN_SECRET_BASE64: '/k8s/services-bff/portals-admin/BFF_TOKEN_SECRET_BASE64' - CONFIGCAT_SDK_KEY: '/k8s/configcat/CONFIGCAT_SDK_KEY' - IDENTITY_SERVER_CLIENT_SECRET: '/k8s/services-bff/portals-admin/IDENTITY_SERVER_CLIENT_SECRET' - securityContext: - allowPrivilegeEscalation: false - privileged: false - serviceAccount: - annotations: - eks.amazonaws.com/role-arn: 'arn:aws:iam::261174024191:role/services-bff' - create: true - name: 'services-bff' services-bff-portals-my-pages: enabled: true env: @@ -2200,7 +2116,7 @@ services-bff-portals-my-pages: BFF_LOGIN_ATTEMPT_TTL_MS: '604800000' BFF_LOGOUT_REDIRECT_URI: 'https://beta.staging01.devland.is' BFF_NAME: 'minarsidur' - BFF_PAR_SUPPORT_ENABLED: 'false' + BFF_PAR_SUPPORT_ENABLED: 'true' BFF_PROXY_API_ENDPOINT: 'http://web-api.islandis.svc.cluster.local/api/graphql' IDENTITY_SERVER_CLIENT_ID: '@island.is/bff' IDENTITY_SERVER_CLIENT_SCOPES: '["api_resource.scope","@island.is/applications:read","@island.is/applications:write","@island.is/user-profile:read","@island.is/user-profile:write","@island.is/auth/actor-delegations","@island.is/auth/delegations:write","@island.is/auth/consents","@skra.is/individuals","@island.is/documents","@island.is/endorsements","@admin.island.is/petitions","@island.is/assets/ip","@island.is/assets","@island.is/education","@island.is/education-license","@island.is/finance:overview","@island.is/finance/salary","@island.is/finance/schedule:read","@island.is/finance/loans","@island.is/internal","@island.is/internal:procuring","@island.is/me:details","@island.is/law-and-order","@island.is/licenses","@island.is/licenses:verify","@island.is/company","@island.is/vehicles","@island.is/work-machines","@island.is/health/payments","@island.is/health/medicines","@island.is/health/assistive-devices-and-nutrition","@island.is/health/therapies","@island.is/health/healthcare","@island.is/health/rights-status","@island.is/health/dentists","@island.is/health/organ-donation","@island.is/health/vaccinations","@island.is/signature-collection"]' From 3f74e608c0e7f259bc383b5e8221c71e47b68e73 Mon Sep 17 00:00:00 2001 From: snaerseljan Date: Tue, 5 Nov 2024 11:04:39 +0000 Subject: [PATCH 180/248] Merge branch 'main' into feat/bff-my-pages # Conflicts: # apps/portals/my-pages/project.json # libs/react-spa/bff/src/lib/bff.hooks.ts --- apps/api/infra/api.ts | 2 +- apps/api/src/main.ts | 2 +- apps/auth-admin-web/infra/auth-admin-web.ts | 5 +- .../components/sitemap/AddNodeButton.tsx | 67 ++ .../components/sitemap/EditMenu.tsx | 62 ++ .../components/sitemap/SitemapNode.css.ts | 32 + .../components/sitemap/SitemapNode.tsx | 178 ++++ .../components/sitemap/SitemapNodeContent.tsx | 36 + .../sitemap/SitemapTreeField.css.ts | 15 + .../components/sitemap/SitemapTreeField.tsx | 115 +++ .../sitemap/SitemapTreeFieldDialog.css.ts | 16 + .../sitemap/SitemapTreeFieldDialog.tsx | 138 +++ .../components/sitemap/entryContext.ts | 50 + .../components/sitemap/utils.ts | 268 ++++++ .../pages/fields/sitemap-tree-field.tsx | 17 + .../interceptors/caseList.interceptor.ts | 24 +- .../interceptors/case.transformer.spec.ts | 23 + .../case/interceptors/case.transformer.ts | 21 +- .../20241101152701-correct-appealed-cases.js | 57 ++ .../src/app/modules/case/case.controller.ts | 115 +-- .../src/app/modules/case/case.service.ts | 7 +- .../case/limitedAccessCase.controller.ts | 22 +- .../app/modules/case/state/case.state.spec.ts | 875 ++++++++++++++---- .../src/app/modules/case/state/case.state.ts | 315 +++++-- .../test/caseController/transition.spec.ts | 14 +- .../transition.spec.ts | 3 +- .../subpoenaNotification.service.ts | 89 +- apps/judicial-system/web/pages/beinir/[id].ts | 3 + .../Shared/RouteHandler/RouteHandler.css.ts | 12 + .../Shared/RouteHandler/RouteHandler.tsx | 113 +++ apps/portals/admin/infra/portals-admin.ts | 1 + apps/portals/my-pages/project.json | 24 +- .../src/components/Greeting/Greeting.tsx | 6 +- .../my-pages/src/components/Header/Header.tsx | 6 +- .../src/components/Layout/FullWidthLayout.tsx | 29 +- .../Loaders/AuthOverlay/AuthOverlay.tsx | 4 +- .../src/screens/Dashboard/Dashboard.tsx | 38 +- .../auth/ids-api/infra/identity-server.ts | 5 + apps/services/auth/ids-api/infra/ids-api.ts | 2 +- apps/services/bff/infra/admin-portal.infra.ts | 3 +- .../src/app/modules/proxy/proxy.service.ts | 27 +- .../20241101095509-drop-course-enum.js | 41 + .../HeadWithSocialSharing.tsx | 6 + apps/web/screens/Article/Article.tsx | 1 + apps/web/screens/queries/Article.ts | 1 + charts/identity-server/values.dev.yaml | 5 + charts/identity-server/values.prod.yaml | 5 + charts/identity-server/values.staging.yaml | 5 + charts/islandis/values.dev.yaml | 3 +- charts/islandis/values.prod.yaml | 3 +- charts/islandis/values.staging.yaml | 3 +- .../src/lib/energyFunds.service.ts | 45 +- .../src/lib/ojoiApplication.resolver.ts | 8 + .../src/lib/ojoiApplication.service.ts | 25 + .../src/models/getPdf.response.ts | 7 + .../src/lib/transportAuthority.service.ts | 99 +- .../core/src/lib/fieldBuilders.spec.ts | 72 ++ .../application/core/src/lib/fieldBuilders.ts | 12 +- .../accident-notification-v2.utils.ts | 173 ++-- .../citizenship/citizenship.service.ts | 5 +- .../financial-aid/financial-aid.service.ts | 27 +- .../social-insurance-administration-utils.ts | 77 ++ ...l-insurance-administration.service.spec.ts | 5 + ...social-insurance-administration.service.ts | 121 ++- .../change-co-owner-of-vehicle.service.ts | 156 ++-- .../change-operator-of-vehicle.service.ts | 154 +-- .../transfer-of-vehicle-ownership.service.ts | 155 ++-- .../src/lib/templateLoaders.ts | 4 + .../locationSubSection.ts | 1 - .../lib/ChangeMachineSupervisorTemplate.ts | 2 - .../src/lib/DeregisterMachineTemplate.ts | 2 - .../src/lib/RegisterNewMachineTemplate.ts | 2 - .../src/lib/RequestForInspectionTemplate.ts | 2 - .../src/lib/StreetRegistrationTemplate.ts | 2 - .../lib/TransferOfMachineOwnershipTemplate.ts | 2 - .../NationalIdWithGivenFamilyName/index.tsx | 2 +- .../citizenship/src/fields/Parents/index.tsx | 16 +- .../SelectedRepeaterItem.tsx | 2 +- .../src/lib/CitizenshipTemplate.ts | 2 - .../citizenship/src/lib/dataSchema.ts | 1 + .../src/lib/EnergyFundsTemplate.tsx | 2 - .../HealthcareLicenseCertificateTemplate.ts | 2 - .../src/lib/HealthcareWorkPermitTemplate.ts | 2 - .../src/fields/Preview.tsx | 112 ++- .../src/graphql/queries.ts | 8 + .../src/hooks/usePdf.ts | 33 + .../src/lib/dataSchema.ts | 41 + .../src/lib/messages/error.ts | 11 + .../src/lib/messages/preview.ts | 20 + .../src/lib/utils.ts | 9 + .../AdditionalSupportForTheElderlyTemplate.ts | 1 + .../core/src/lib/messages.ts | 15 +- .../death-benefits/.babelrc | 12 + .../death-benefits/.eslintrc.json | 18 + .../death-benefits/README.md | 54 ++ .../death-benefits/jest.config.ts | 13 + .../death-benefits/project.json | 38 + .../assets/death-benefits-flow-chart.drawio | 124 +++ .../src/assets/death-benefits-flow-chart.png | Bin 0 -> 55376 bytes .../death-benefits/src/dataProviders/index.ts | 38 + .../src/fields/Review/index.tsx | 170 ++++ .../Review/review-groups/Attachments.tsx | 37 + .../Review/review-groups/BaseInformation.tsx | 112 +++ .../fields/Review/review-groups/Children.tsx | 47 + .../fields/Review/review-groups/Comment.tsx | 38 + .../Review/review-groups/DeceasedSpouse.tsx | 72 ++ .../Review/review-groups/ExpectingChild.tsx | 36 + .../review-groups/PaymentInformation.tsx | 192 ++++ .../src/fields/Review/review-groups/props.ts | 9 + .../death-benefits/src/fields/index.tsx | 1 + .../src/forms/AdditionalDocumentsRequired.ts | 63 ++ .../src/forms/DeathBenefitsForm.ts | 572 ++++++++++++ .../death-benefits/src/forms/InReview.ts | 27 + .../death-benefits/src/forms/Prerequisites.ts | 130 +++ .../death-benefits/src/index.ts | 11 + .../src/lib/DeathBenefitsTemplate.spec.ts | 191 ++++ .../src/lib/DeathBenefitsTemplate.ts | 540 +++++++++++ .../death-benefits/src/lib/constants.ts | 12 + .../death-benefits/src/lib/dataSchema.ts | 174 ++++ .../src/lib/deathBenefitsUtils.ts | 323 +++++++ .../death-benefits/src/lib/messages.ts | 183 ++++ .../death-benefits/src/types.ts | 11 + .../death-benefits/tsconfig.json | 17 + .../death-benefits/tsconfig.lib.json | 23 + .../death-benefits/tsconfig.spec.json | 20 + .../src/lib/HouseholdSupplementTemplate.ts | 1 + .../src/forms/Prerequisites.ts | 2 +- .../src/lib/PensionSupplementTemplate.ts | 1 + .../InformationSection/vehicleSubSection.ts | 2 +- .../src/lib/dataSchema.ts | 4 +- .../src/shared/types.ts | 2 +- .../src/utils/getSelectedVehicle.ts | 21 +- .../InformationSection/vehicleSubSection.ts | 2 +- .../src/lib/dataSchema.ts | 4 +- .../src/shared/types.ts | 2 +- .../src/utils/getSelectedVehicle.ts | 21 +- .../src/graphql/queries.ts | 1 - .../InformationSection/vehicleSubSection.ts | 2 +- .../prerequisitesSection.ts | 3 +- .../src/lib/dataSchema.ts | 4 +- .../src/shared/types.ts | 2 +- .../src/utils/getSelectedVehicle.ts | 21 +- .../types/src/lib/ApplicationTypes.ts | 5 + libs/application/types/src/lib/Fields.ts | 36 +- .../types/src/lib/InstitutionMapper.ts | 5 + .../AsyncSelectFormField.tsx | 4 +- .../CompanySearchFormField.tsx | 8 +- .../src/lib/DateFormField/DateFormField.tsx | 8 +- .../NationalIdWithNameFormField.tsx | 3 +- .../src/lib/PhoneFormField/PhoneFormField.tsx | 4 +- .../lib/SelectFormField/SelectFormField.tsx | 3 +- .../src/lib/TextFormField/TextFormField.tsx | 4 +- ...nel_logout_uri_to_client_bff-stjornbord.js | 27 + .../directorateOfImmigrationClient.service.ts | 7 +- .../application/src/clientConfig.json | 52 +- .../src/lib/ojoiApplicationClient.service.ts | 33 +- .../src/clientConfig.json | 266 +++++- .../src/lib/apiProvider.ts | 6 + .../src/lib/dto/application.dto.ts | 4 + ...alInsuranceAdministrationClient.service.ts | 14 + ...ocialInsuranceAdministrationClient.type.ts | 10 + .../lib/vehicleCodetablesClient.service.ts | 11 - .../src/lib/generated/contentfulTypes.d.ts | 3 + libs/cms/src/lib/models/article.model.ts | 4 + .../lib/search/importers/article.service.ts | 10 + .../importers/genericListItem.service.ts | 24 +- .../lib/search/importers/teamList.service.ts | 8 +- .../src/queries/search.ts | 13 + libs/feature-flags/src/lib/features.ts | 12 +- libs/judicial-system/consts/src/lib/consts.ts | 1 + .../CreateDelegation/CreateDelegation.tsx | 49 +- .../DelegationAdmin.tsx | 25 +- .../delegation-admin/src/screens/Root.tsx | 20 +- .../admin/ids-admin/src/lib/messages.ts | 16 + .../admin/ids-admin/src/lib/navigation.ts | 15 +- libs/portals/admin/ids-admin/src/lib/paths.ts | 6 + .../src/screens/Client/EditClient.tsx | 27 +- .../ids-admin/src/screens/Clients/Clients.tsx | 13 +- .../src/screens/Permissions/Permissions.tsx | 5 +- .../signature-collection/src/lib/messages.ts | 5 + .../Constituency/index.tsx | 182 ++-- .../PortalNavigation/PortalNavigation.tsx | 1 + .../delegations/DelegationViewModal.tsx | 10 +- .../incoming/DelegationsIncoming.tsx | 3 + .../outgoing/DelegationsOutgoing.tsx | 3 + libs/react-spa/bff/src/lib/BffProvider.tsx | 33 +- .../assets/src/screens/Overview/Overview.tsx | 18 +- .../UserProfileNotificationSettings.tsx | 12 +- .../Forms/ProfileForm/ProfileForm.tsx | 16 +- .../src/auth/UserMenu/UserProfileLocale.tsx | 6 +- tsconfig.base.json | 3 + 191 files changed, 7571 insertions(+), 1236 deletions(-) create mode 100644 apps/contentful-apps/components/sitemap/AddNodeButton.tsx create mode 100644 apps/contentful-apps/components/sitemap/EditMenu.tsx create mode 100644 apps/contentful-apps/components/sitemap/SitemapNode.css.ts create mode 100644 apps/contentful-apps/components/sitemap/SitemapNode.tsx create mode 100644 apps/contentful-apps/components/sitemap/SitemapNodeContent.tsx create mode 100644 apps/contentful-apps/components/sitemap/SitemapTreeField.css.ts create mode 100644 apps/contentful-apps/components/sitemap/SitemapTreeField.tsx create mode 100644 apps/contentful-apps/components/sitemap/SitemapTreeFieldDialog.css.ts create mode 100644 apps/contentful-apps/components/sitemap/SitemapTreeFieldDialog.tsx create mode 100644 apps/contentful-apps/components/sitemap/entryContext.ts create mode 100644 apps/contentful-apps/components/sitemap/utils.ts create mode 100644 apps/contentful-apps/pages/fields/sitemap-tree-field.tsx create mode 100644 apps/judicial-system/backend/migrations/20241101152701-correct-appealed-cases.js create mode 100644 apps/judicial-system/web/pages/beinir/[id].ts create mode 100644 apps/judicial-system/web/src/routes/Shared/RouteHandler/RouteHandler.css.ts create mode 100644 apps/judicial-system/web/src/routes/Shared/RouteHandler/RouteHandler.tsx create mode 100644 apps/services/university-gateway/migrations/20241101095509-drop-course-enum.js create mode 100644 libs/api/domains/official-journal-of-iceland-application/src/models/getPdf.response.ts create mode 100644 libs/application/core/src/lib/fieldBuilders.spec.ts create mode 100644 libs/application/templates/official-journal-of-iceland/src/hooks/usePdf.ts create mode 100644 libs/application/templates/social-insurance-administration/death-benefits/.babelrc create mode 100644 libs/application/templates/social-insurance-administration/death-benefits/.eslintrc.json create mode 100644 libs/application/templates/social-insurance-administration/death-benefits/README.md create mode 100644 libs/application/templates/social-insurance-administration/death-benefits/jest.config.ts create mode 100644 libs/application/templates/social-insurance-administration/death-benefits/project.json create mode 100644 libs/application/templates/social-insurance-administration/death-benefits/src/assets/death-benefits-flow-chart.drawio create mode 100644 libs/application/templates/social-insurance-administration/death-benefits/src/assets/death-benefits-flow-chart.png create mode 100644 libs/application/templates/social-insurance-administration/death-benefits/src/dataProviders/index.ts create mode 100644 libs/application/templates/social-insurance-administration/death-benefits/src/fields/Review/index.tsx create mode 100644 libs/application/templates/social-insurance-administration/death-benefits/src/fields/Review/review-groups/Attachments.tsx create mode 100644 libs/application/templates/social-insurance-administration/death-benefits/src/fields/Review/review-groups/BaseInformation.tsx create mode 100644 libs/application/templates/social-insurance-administration/death-benefits/src/fields/Review/review-groups/Children.tsx create mode 100644 libs/application/templates/social-insurance-administration/death-benefits/src/fields/Review/review-groups/Comment.tsx create mode 100644 libs/application/templates/social-insurance-administration/death-benefits/src/fields/Review/review-groups/DeceasedSpouse.tsx create mode 100644 libs/application/templates/social-insurance-administration/death-benefits/src/fields/Review/review-groups/ExpectingChild.tsx create mode 100644 libs/application/templates/social-insurance-administration/death-benefits/src/fields/Review/review-groups/PaymentInformation.tsx create mode 100644 libs/application/templates/social-insurance-administration/death-benefits/src/fields/Review/review-groups/props.ts create mode 100644 libs/application/templates/social-insurance-administration/death-benefits/src/fields/index.tsx create mode 100644 libs/application/templates/social-insurance-administration/death-benefits/src/forms/AdditionalDocumentsRequired.ts create mode 100644 libs/application/templates/social-insurance-administration/death-benefits/src/forms/DeathBenefitsForm.ts create mode 100644 libs/application/templates/social-insurance-administration/death-benefits/src/forms/InReview.ts create mode 100644 libs/application/templates/social-insurance-administration/death-benefits/src/forms/Prerequisites.ts create mode 100644 libs/application/templates/social-insurance-administration/death-benefits/src/index.ts create mode 100644 libs/application/templates/social-insurance-administration/death-benefits/src/lib/DeathBenefitsTemplate.spec.ts create mode 100644 libs/application/templates/social-insurance-administration/death-benefits/src/lib/DeathBenefitsTemplate.ts create mode 100644 libs/application/templates/social-insurance-administration/death-benefits/src/lib/constants.ts create mode 100644 libs/application/templates/social-insurance-administration/death-benefits/src/lib/dataSchema.ts create mode 100644 libs/application/templates/social-insurance-administration/death-benefits/src/lib/deathBenefitsUtils.ts create mode 100644 libs/application/templates/social-insurance-administration/death-benefits/src/lib/messages.ts create mode 100644 libs/application/templates/social-insurance-administration/death-benefits/src/types.ts create mode 100644 libs/application/templates/social-insurance-administration/death-benefits/tsconfig.json create mode 100644 libs/application/templates/social-insurance-administration/death-benefits/tsconfig.lib.json create mode 100644 libs/application/templates/social-insurance-administration/death-benefits/tsconfig.spec.json create mode 100644 libs/auth-api-lib/migrations/20241104143212-add_back_channel_logout_uri_to_client_bff-stjornbord.js diff --git a/apps/api/infra/api.ts b/apps/api/infra/api.ts index 586dfec4a2b0..f8d7e737d035 100644 --- a/apps/api/infra/api.ts +++ b/apps/api/infra/api.ts @@ -469,6 +469,6 @@ export const serviceSetup = (services: { 'api-catalogue', 'application-system', 'consultation-portal', - 'services-bff-portals-admin', + 'portals-admin', ) } diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 5fa1ec05053d..b1015b0e9e52 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -6,5 +6,5 @@ bootstrap({ name: 'api', port: 4444, stripNonClassValidatorInputs: false, - jsonBodyLimit: '300kb', + jsonBodyLimit: '350kb', }) diff --git a/apps/auth-admin-web/infra/auth-admin-web.ts b/apps/auth-admin-web/infra/auth-admin-web.ts index f9989f788d7b..44299b219a8d 100644 --- a/apps/auth-admin-web/infra/auth-admin-web.ts +++ b/apps/auth-admin-web/infra/auth-admin-web.ts @@ -8,8 +8,8 @@ const extraAnnotations = { 'client_header_buffer_size 16k; large_client_header_buffers 4 16k;', } -export const serviceSetup = (): ServiceBuilder<'auth-admin-web'> => { - return service('auth-admin-web') +export const serviceSetup = (): ServiceBuilder<'auth-admin-web'> => + service('auth-admin-web') .namespace('identity-server-admin') .image('auth-admin-web') .env({ @@ -74,4 +74,3 @@ export const serviceSetup = (): ServiceBuilder<'auth-admin-web'> => { staging: { progressDeadlineSeconds: 1200 }, prod: { progressDeadlineSeconds: 1200 }, }) -} diff --git a/apps/contentful-apps/components/sitemap/AddNodeButton.tsx b/apps/contentful-apps/components/sitemap/AddNodeButton.tsx new file mode 100644 index 000000000000..fad75ea5caf2 --- /dev/null +++ b/apps/contentful-apps/components/sitemap/AddNodeButton.tsx @@ -0,0 +1,67 @@ +import { Button, Menu } from '@contentful/f36-components' +import { ChevronDownIcon, PlusIcon } from '@contentful/f36-icons' + +import { TreeNodeType } from './utils' + +const optionMap = { + [TreeNodeType.CATEGORY]: 'Category', + [TreeNodeType.ENTRY]: 'Page', + [TreeNodeType.URL]: 'URL', +} + +interface AddNodeButtonProps { + addNode: (type: TreeNodeType, createNew?: boolean) => void + options?: TreeNodeType[] +} + +export const AddNodeButton = ({ + addNode, + options = [TreeNodeType.CATEGORY, TreeNodeType.ENTRY, TreeNodeType.URL], +}: AddNodeButtonProps) => { + return ( + + + + + + {options.map((option) => { + if (option !== TreeNodeType.ENTRY) { + return ( + { + addNode(option) + }} + > + {optionMap[option]} + + ) + } + return ( + + Page + + { + addNode(option, true) + }} + > + Create new + + { + addNode(option, false) + }} + > + Add existing + + + + ) + })} + + + ) +} diff --git a/apps/contentful-apps/components/sitemap/EditMenu.tsx b/apps/contentful-apps/components/sitemap/EditMenu.tsx new file mode 100644 index 000000000000..769fb0df2cb6 --- /dev/null +++ b/apps/contentful-apps/components/sitemap/EditMenu.tsx @@ -0,0 +1,62 @@ +import { useMemo } from 'react' +import { IconButton, Menu } from '@contentful/f36-components' +import { MoreVerticalIcon } from '@contentful/f36-icons' + +import { findNodes, type Tree, TreeNodeType } from './utils' + +interface EditMenuProps { + onEdit: () => void + onRemove: () => void + onMarkEntryAsPrimary: (nodeId: number, entryId: string) => void + entryId?: string + entryNodeId: number + root: Tree + isEntryNodePrimaryLocation?: boolean +} + +export const EditMenu = ({ + onEdit, + onRemove, + onMarkEntryAsPrimary, + isEntryNodePrimaryLocation, + entryId, + entryNodeId, + root, +}: EditMenuProps) => { + const sameEntryNodes = useMemo(() => { + if (!entryId || !entryNodeId) return [] + return findNodes( + root, + (otherNode) => + otherNode.type === TreeNodeType.ENTRY && otherNode.entryId === entryId, + ) + }, [entryId, entryNodeId, root]) + + return ( + + + } aria-label="Edit" /> + + + Edit + {sameEntryNodes.length > 1 && ( + { + if (isEntryNodePrimaryLocation) return + onMarkEntryAsPrimary(entryNodeId, entryId) + }} + > + {isEntryNodePrimaryLocation + ? 'Marked as primary ✅' + : 'Mark as primary'} + + )} + Remove + + + ) +} diff --git a/apps/contentful-apps/components/sitemap/SitemapNode.css.ts b/apps/contentful-apps/components/sitemap/SitemapNode.css.ts new file mode 100644 index 000000000000..c671dc85ff33 --- /dev/null +++ b/apps/contentful-apps/components/sitemap/SitemapNode.css.ts @@ -0,0 +1,32 @@ +import { style } from '@vanilla-extract/css' + +export { addNodeButtonContainer } from './SitemapTreeField.css' + +export const mainContainer = style({ + display: 'flex', + flexFlow: 'column nowrap', + gap: '8px', + userSelect: 'none', +}) + +export const nodeContainer = style({ + border: '1px solid #d3dce0', + padding: '10px 4px', + display: 'flex', + flexFlow: 'row nowrap', + justifyContent: 'space-between', + borderRadius: '6px', +}) + +export const contentContainer = style({ + display: 'flex', + flexFlow: 'row nowrap', + gap: '8px', + alignItems: 'center', +}) + +export const childNodeContainer = style({ + display: 'flex', + flexFlow: 'column nowrap', + gap: '8px', +}) diff --git a/apps/contentful-apps/components/sitemap/SitemapNode.tsx b/apps/contentful-apps/components/sitemap/SitemapNode.tsx new file mode 100644 index 000000000000..b7c6a2af7144 --- /dev/null +++ b/apps/contentful-apps/components/sitemap/SitemapNode.tsx @@ -0,0 +1,178 @@ +import { useContext, useEffect, useState } from 'react' +import type { FieldExtensionSDK } from '@contentful/app-sdk' +import { ChevronDownIcon, ChevronRightIcon } from '@contentful/f36-icons' +import { useSDK } from '@contentful/react-apps-toolkit' + +import { AddNodeButton } from './AddNodeButton' +import { EditMenu } from './EditMenu' +import { EntryContext } from './entryContext' +import { SitemapNodeContent } from './SitemapNodeContent' +import { Tree, TreeNode, TreeNodeType } from './utils' +import * as styles from './SitemapNode.css' + +interface SitemapNodeProps { + node: TreeNode + parentNode: Tree + root: Tree + indent?: number + addNode: (parentNode: Tree, type: TreeNodeType, createNew?: boolean) => void + removeNode: (parentNode: Tree, idOfNodeToRemove: number) => void + updateNode: (parentNode: Tree, updatedNode: TreeNode) => void + onMarkEntryAsPrimary: (nodeId: number, entryId: string) => void +} + +export const SitemapNode = ({ + node, + parentNode, + indent = 0, + root, + addNode, + removeNode, + updateNode, + onMarkEntryAsPrimary, +}: SitemapNodeProps) => { + const sdk = useSDK() + + const [showChildNodes, setShowChildNodes] = useState(false) + + const { fetchEntries, updateEntry } = useContext(EntryContext) + + useEffect(() => { + const entryNodes = node.childNodes.filter( + (node) => node.type === TreeNodeType.ENTRY, + ) + + if (node.type === TreeNodeType.ENTRY) { + entryNodes.push(node) + } + + if (entryNodes.length === 0) { + return + } + fetchEntries( + entryNodes.map((entryNode) => (entryNode as { entryId: string }).entryId), + ) + }, [fetchEntries, node, node.childNodes]) + + const isClickable = node.type === TreeNodeType.CATEGORY + + const handleClick = () => { + if (isClickable) { + setShowChildNodes((prev) => !prev) + } + } + + return ( +
+
+
{ + if (ev.key === ' ') { + handleClick() + } + }} + onClick={handleClick} + > +
+
+ {showChildNodes ? : } +
+ +
+
+
+ { + if (node.type === TreeNodeType.ENTRY) { + const entry = await sdk.navigator.openEntry(node.entryId, { + slideIn: { waitForClose: true }, + }) + + if (entry?.entity) { + updateEntry(entry.entity) + } + + return + } + + const updatedNode = await sdk.dialogs.openCurrentApp({ + parameters: { + node, + }, + minHeight: 400, + }) + updateNode(parentNode, updatedNode) + return + }} + onRemove={async () => { + const confirmed = await sdk.dialogs.openConfirm({ + title: 'Are you sure?', + message: `Entry and everything below it will be removed`, + }) + if (!confirmed) { + return + } + removeNode(parentNode, node.id) + }} + /> +
+
+ {showChildNodes && ( +
+ {node.childNodes.map((child) => ( + + ))} +
+ { + addNode(node, type, createNew) + }} + options={ + indent > 1 || node.type === TreeNodeType.ENTRY + ? [TreeNodeType.ENTRY, TreeNodeType.URL] + : [ + TreeNodeType.CATEGORY, + TreeNodeType.ENTRY, + TreeNodeType.URL, + ] + } + /> +
+
+ )} +
+ ) +} diff --git a/apps/contentful-apps/components/sitemap/SitemapNodeContent.tsx b/apps/contentful-apps/components/sitemap/SitemapNodeContent.tsx new file mode 100644 index 000000000000..f7c0121128e4 --- /dev/null +++ b/apps/contentful-apps/components/sitemap/SitemapNodeContent.tsx @@ -0,0 +1,36 @@ +import { ReactNode, useContext } from 'react' +import { FieldExtensionSDK } from '@contentful/app-sdk' +import { Stack, Text } from '@contentful/f36-components' +import { useSDK } from '@contentful/react-apps-toolkit' + +import { EntryContext } from './entryContext' +import { TreeNode, TreeNodeType } from './utils' + +interface SitemapNodeContentProps { + node: TreeNode +} + +export const SitemapNodeContent = ({ node }: SitemapNodeContentProps) => { + const { entries } = useContext(EntryContext) + const sdk = useSDK() + + const label: string | ReactNode = + node.type !== TreeNodeType.ENTRY + ? node.label + : entries[node.entryId]?.fields?.title?.[sdk.field.locale] || '...' + const slug = + node.type === TreeNodeType.CATEGORY + ? node.slug + : node.type === TreeNodeType.URL + ? node.url + : entries[node.entryId]?.fields?.slug?.[sdk.field.locale] || '...' + + return ( + + + {label} + + {slug} + + ) +} diff --git a/apps/contentful-apps/components/sitemap/SitemapTreeField.css.ts b/apps/contentful-apps/components/sitemap/SitemapTreeField.css.ts new file mode 100644 index 000000000000..2130aa67535d --- /dev/null +++ b/apps/contentful-apps/components/sitemap/SitemapTreeField.css.ts @@ -0,0 +1,15 @@ +import { style } from '@vanilla-extract/css' + +export const childNodeContainer = style({ + display: 'flex', + flexFlow: 'column nowrap', + gap: '12px', +}) + +export const addNodeButtonContainer = style({ + display: 'flex', + justifyContent: 'center', + border: '1px dashed #d3dce0', + padding: '10px 4px', + borderRadius: '6px', +}) diff --git a/apps/contentful-apps/components/sitemap/SitemapTreeField.tsx b/apps/contentful-apps/components/sitemap/SitemapTreeField.tsx new file mode 100644 index 000000000000..a0784a892ff9 --- /dev/null +++ b/apps/contentful-apps/components/sitemap/SitemapTreeField.tsx @@ -0,0 +1,115 @@ +import { useCallback, useEffect, useState } from 'react' +import { useDebounce } from 'react-use' +import type { FieldExtensionSDK } from '@contentful/app-sdk' +import { useSDK } from '@contentful/react-apps-toolkit' + +import { AddNodeButton } from './AddNodeButton' +import { EntryContext, useEntryContext } from './entryContext' +import { SitemapNode } from './SitemapNode' +import { + addNode as addNodeUtil, + findNodes, + removeNode as removeNodeUtil, + type Tree, + TreeNode, + TreeNodeType, + updateNode as updateNodeUtil, +} from './utils' +import * as styles from './SitemapTreeField.css' + +const DEBOUNCE_TIME = 100 + +export const SitemapTreeField = () => { + const sdk = useSDK() + const [tree, setTree] = useState( + sdk.field.getValue() || { + id: 0, + childNodes: [], + }, + ) + + useDebounce( + () => { + sdk.field.setValue(tree) + }, + DEBOUNCE_TIME, + [tree], + ) + + useEffect(() => { + sdk.window.startAutoResizer() + return () => { + sdk.window.stopAutoResizer() + } + }, [sdk.window, tree.childNodes.length]) + + const addNode = useCallback( + async (parentNode: Tree, type: TreeNodeType, createNew?: boolean) => { + await addNodeUtil(parentNode, type, sdk, tree, createNew) + setTree((prevTree) => ({ + ...prevTree, + })) + }, + [sdk, tree], + ) + + const removeNode = useCallback( + (parentNode: Tree, idOfNodeToRemove: number) => { + removeNodeUtil(parentNode, idOfNodeToRemove, tree) + setTree((prevTree) => ({ ...prevTree })) + }, + [tree], + ) + + const updateNode = useCallback((parentNode: Tree, updatedNode: TreeNode) => { + updateNodeUtil(parentNode, updatedNode) + setTree((prevTree) => ({ ...prevTree })) + }, []) + + const onMarkEntryAsPrimary = useCallback( + (nodeId: number, entryId: string) => { + const nodes = findNodes( + tree, + (otherNode) => + otherNode.type === TreeNodeType.ENTRY && otherNode.entryId == entryId, + ) + for (const node of nodes) { + if (node.type === TreeNodeType.ENTRY) { + node.primaryLocation = node.id === nodeId + } + } + setTree((prevTree) => ({ ...prevTree })) + }, + [tree], + ) + + return ( + +
+
+
+ {tree.childNodes.map((node) => ( + + ))} +
+ { + addNode(tree, type, createNew) + }} + /> +
+
+
+
+
+ ) +} diff --git a/apps/contentful-apps/components/sitemap/SitemapTreeFieldDialog.css.ts b/apps/contentful-apps/components/sitemap/SitemapTreeFieldDialog.css.ts new file mode 100644 index 000000000000..4b32b834356b --- /dev/null +++ b/apps/contentful-apps/components/sitemap/SitemapTreeFieldDialog.css.ts @@ -0,0 +1,16 @@ +import { style } from '@vanilla-extract/css' + +export const topRow = style({ + display: 'flex', + justifyContent: 'flex-end', +}) + +export const container = style({ + padding: '16px', +}) + +export const formContainer = style({ + display: 'flex', + flexFlow: 'column nowrap', + gap: '16px', +}) diff --git a/apps/contentful-apps/components/sitemap/SitemapTreeFieldDialog.tsx b/apps/contentful-apps/components/sitemap/SitemapTreeFieldDialog.tsx new file mode 100644 index 000000000000..3ecb706a59b9 --- /dev/null +++ b/apps/contentful-apps/components/sitemap/SitemapTreeFieldDialog.tsx @@ -0,0 +1,138 @@ +import { useState } from 'react' +import { DialogExtensionSDK } from '@contentful/app-sdk' +import { + Button, + FormControl, + IconButton, + Textarea, + TextInput, +} from '@contentful/f36-components' +import { CloseIcon } from '@contentful/f36-icons' +import { useSDK } from '@contentful/react-apps-toolkit' + +import { TreeNode, TreeNodeType } from './utils' +import * as styles from './SitemapTreeFieldDialog.css' + +interface CategoryState { + label: string + slug: string + description: string +} + +interface FormProps { + initialState: State + onSubmit: (props: State) => void +} + +const CategoryForm = ({ initialState, onSubmit }: FormProps) => { + const [state, setState] = useState(initialState) + return ( + +
+ Label + { + setState((prevState) => ({ ...prevState, label: ev.target.value })) + }} + /> +
+
+ Slug + { + setState((prevState) => ({ ...prevState, slug: ev.target.value })) + }} + /> +
+
+ Description +