diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index a28e1aacb..000000000 --- a/.dockerignore +++ /dev/null @@ -1,8 +0,0 @@ -frontend/* -!frontend/dist -!frontend/public -server/* -!server/src -!server/package.json -!server/node_modules -!server/tsconfig.json diff --git a/.github/workflows/deploy-to-dev.yaml b/.github/workflows/deploy-to-dev.yaml index e8b206492..ef9f5b19b 100644 --- a/.github/workflows/deploy-to-dev.yaml +++ b/.github/workflows/deploy-to-dev.yaml @@ -26,45 +26,45 @@ on: run-name: Dev deploy of ${{ github.ref_name }} jobs: - build: - name: Build + build-push: + name: Build and push runs-on: ubuntu-latest permissions: contents: read id-token: write outputs: image: ${{ steps.docker-build-push.outputs.image }} - version: ${{ steps.version.outputs.version }} steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Bun - uses: oven-sh/setup-bun@v1 + uses: oven-sh/setup-bun@v2 with: bun-version: latest - - - name: Install frontend dependencies - shell: bash + registry-url: https://npm.pkg.github.com/ + scope: "@navikt" + + - name: Generate bunfig.toml run: | - cd frontend - bun install + echo -e "[install.scopes]\n\"@navikt\" = { token = \"${{ secrets.READER_TOKEN }}\", url = \"https://npm.pkg.github.com/\" }" > ./frontend/bunfig.toml + cp ./frontend/bunfig.toml ./server/bunfig.toml + + - name: Install frontend dependencies + working-directory: ./frontend env: - NODE_AUTH_TOKEN: ${{ secrets.READER_TOKEN }} + BUN_AUTH_TOKEN: ${{ secrets.READER_TOKEN }} + run: bun install --frozen-lockfile - name: Test frontend - shell: bash - run: | - cd frontend - bun test + working-directory: ./frontend + run: bun test - name: Build frontend - shell: bash + working-directory: ./frontend env: VERSION: ${{ github.sha }} - run: | - cd frontend - bun run build + run: bun run build - name: Upload static files to CDN uses: nais/deploy/actions/cdn-upload/v2@master @@ -74,20 +74,20 @@ jobs: destination: klang project_id: ${{ vars.NAIS_MANAGEMENT_PROJECT_ID }} identity_provider: ${{ secrets.NAIS_WORKLOAD_IDENTITY_PROVIDER }} - + - name: Install server dependencies - shell: bash - run: | - cd server - bun install + working-directory: ./server env: - NODE_AUTH_TOKEN: ${{ secrets.READER_TOKEN }} + BUN_AUTH_TOKEN: ${{ secrets.READER_TOKEN }} + run: bun install --frozen-lockfile - name: Test server - shell: bash - run: | - cd server - bun test + working-directory: ./server + run: bun test + + - name: Build server + working-directory: ./server + run: bun run build - name: Generate version number id: version @@ -116,7 +116,7 @@ jobs: permissions: contents: read id-token: write - needs: build + needs: build-push steps: - name: Checkout uses: actions/checkout@v4 @@ -127,7 +127,7 @@ jobs: CLUSTER: dev-gcp VARS: nais/dev.yaml RESOURCE: nais/nais.yaml - VAR: image=${{ needs.build.outputs.image }} + VAR: image=${{ needs.build-push.outputs.image }} e2e_test: name: E2E diff --git a/.github/workflows/sanity-build.yaml b/.github/workflows/sanity-build.yaml index e945a5f2a..4825d8e90 100644 --- a/.github/workflows/sanity-build.yaml +++ b/.github/workflows/sanity-build.yaml @@ -13,18 +13,26 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + - name: Setup Bun uses: oven-sh/setup-bun@v1 with: bun-version: latest + registry-url: https://npm.pkg.github.com/ + scope: "@navikt" + - name: Generate bunfig.toml + run: | + cd ${{ matrix.apps }} + echo -e "[install.scopes]\n\"@navikt\" = { token = \"${{ secrets.READER_TOKEN }}\", url = \"https://npm.pkg.github.com/\" }" > bunfig.toml + - name: Install ${{ matrix.apps }} dependencies shell: bash + env: + BUN_AUTH_TOKEN: ${{ secrets.READER_TOKEN }} run: | cd ${{ matrix.apps }} - bun install - env: - NODE_AUTH_TOKEN: ${{ secrets.READER_TOKEN }} + bun install --frozen-lockfile - name: Restore caches for ${{ matrix.apps }} uses: actions/cache/restore@v3 @@ -44,7 +52,7 @@ jobs: shell: bash run: | cd ${{ matrix.apps }} - npm run lint + bun run lint - name: Test ${{ matrix.apps }} shell: bash diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 000000000..f04f04480 --- /dev/null +++ b/.tool-versions @@ -0,0 +1,2 @@ +bun 1.1.26 +nodejs 22.7.0 diff --git a/Dockerfile b/Dockerfile index e837b788e..df92b8889 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,19 +1,17 @@ -FROM oven/bun:1 +FROM node:22-alpine ENV NODE_ENV=production ENV NPM_CONFIG_CACHE=/tmp WORKDIR /usr/src/app -COPY server server -COPY frontend frontend -WORKDIR /usr/src/app/server +COPY server/dist server/dist +COPY frontend/dist/index.html frontend/dist/index.html -# This command will create an absolute path reference to JSDOM (used by nav-dekoratoren-moduler), which will not work if built outside Docker -RUN bun run build +WORKDIR /usr/src/app/server ARG VERSION ENV VERSION=$VERSION -CMD bun run prod +CMD node --trace-warnings dist/server.js EXPOSE 8080 diff --git a/README.md b/README.md index feae1897b..5b888f739 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,13 @@ Digital innsending av klager og anker. Samt ettersendelse. # Komme i gang ## Autorisering mot @navikt-NPM-registeret -1. Lag et Personal Access Token (PAT) med scope: `read:packages`. _PAT-en må være autorisert for organisasjonen `navikt`._ -2. Sett verdien i miljøvariabelen NODE_AUTH_TOKEN +1. [Lag et Personal Access Token (PAT)](https://github.com/settings/tokens) med scope: `read:packages`. _Tokenet må være autorisert for organisasjonen `navikt`._ +2. Opprett filen `bunfig.toml` i din `$HOME`-mappe med følgende innhold: + ```toml + [install.scopes] + "@navikt" = { token = "ghp_Qj6Xxn8HTUSJL9dNiZ0TW7R5YvupTZclTXsK", url = "https://npm.pkg.github.com/" } + ``` +3. Bytt ut `ghp_Qj6Xxn8HTUSJL9dNiZ0TW7R5YvupTZclTXsK` med ditt eget token. ### Referanser - https://github.com/navikt/nav-dekoratoren-moduler?tab=readme-ov-file#kom-i-gang @@ -64,17 +69,20 @@ For å lenke direkte til klageskjemaet må `innsendingsytelse` være satt i URL- Saksnummeret settes i klagen/anken, men bruker kan **ikke** endre det. Dersom dette ikke er oppgitt som query parameter, får bruker mulighet til å fylle inn saksnummer selv. -### Eksempel på fullstendig URL til klageskjema: -``` -https://klage.nav.no/nb/klage/DAGPENGER?saksnummer=12345 -``` +### Eksempler på fullstendige URLer +| Skjema | URL | +|--------|-----| +| Klage | `https://klage.nav.no/nb/klage/DAGPENGER?saksnummer=12345` | +| Anke | `https://klage.nav.no/nb/anke/DAGPENGER?saksnummer=12345` | +| Ettersendelse til klage | `https://klage.nav.no/nb/ettersendelse/klage/DAGPENGER?saksnummer=12345` | +| Ettersendelse til anke | `https://klage.nav.no/nb/ettersendelse/anke/DAGPENGER?saksnummer=12345` | ## URL-format ``` https://klage.nav.no/{språk}/{type}/{innsendingsytelse}?saksnummer={saksnummer} ``` -- `språk` = `nb | en` -- `type` = `klage | anke | ettersendelse` +- `språk` = `nb | nn | en` +- `type` = `klage | anke | ettersendelse/klage | ettersendelse/anke` - `innsendingsytelse` = Se liste over tilgjengelige ytelser under. - `saksnummer` = Relevant saksnummer. diff --git a/frontend/bun.lockb b/frontend/bun.lockb index 85a321c5e..c39381c20 100755 Binary files a/frontend/bun.lockb and b/frontend/bun.lockb differ diff --git a/frontend/bunfig.toml b/frontend/bunfig.toml deleted file mode 100644 index 4dfda52d5..000000000 --- a/frontend/bunfig.toml +++ /dev/null @@ -1,2 +0,0 @@ -[install.scopes] -"@navikt" = { token = "$NODE_AUTH_TOKEN", url = "https://npm.pkg.github.com/" } diff --git a/frontend/index.html b/frontend/index.html index 060d8c972..a12e14d03 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -12,12 +12,16 @@ + {{DECORATOR_STYLES}} + {{DECORATOR_SCRIPTS}} Klage eller anke på vedtak + {{DECORATOR_HEADER}}
+ {{DECORATOR_FOOTER}} - + \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index 36a68e075..061dae87e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,54 +5,55 @@ "license": "MIT", "type": "module", "scripts": { - "start": "vite & tsc --watch", + "start": "vite --base=/ & tsc --watch", "build": "vite build", + "build:local": "vite --base=/ build", "typecheck": "tsc", "lint": "eslint src --color --cache --cache-strategy content --cache-location .eslintcache" }, "devDependencies": { - "@types/bun": "1.1.6", - "@types/react": "18.3.3", + "@types/bun": "1.1.8", + "@types/react": "18.3.4", "@types/react-dom": "18.3.0", "@types/react-redux": "7.1.33", - "@typescript-eslint/eslint-plugin": "7.16.1", - "@typescript-eslint/parser": "7.16.1", + "@typescript-eslint/eslint-plugin": "8.3.0", + "@typescript-eslint/parser": "8.3.0", "@vitejs/plugin-react": "^4.3.1", "css-loader": "7.1.2", "eslint": "8.57.0", "eslint-config-prettier": "9.1.0", - "eslint-import-resolver-typescript-bun": "0.0.98", + "eslint-import-resolver-typescript-bun": "0.0.103", "eslint-plugin-import": "2.29.1", "eslint-plugin-jsx-a11y": "6.9.0", "eslint-plugin-prefer-arrow": "1.2.3", "eslint-plugin-prettier": "5.2.1", - "eslint-plugin-react": "7.34.4", + "eslint-plugin-react": "7.35.0", "eslint-plugin-react-hooks": "4.6.2", "eslint-plugin-vitest": "0.5.4", "prettier": "3.3.3", "style-loader": "4.0.0", "ts-loader": "9.5.1", - "typescript": "5.5.3", - "vite": "5.3.4", - "vite-tsconfig-paths": "4.3.2" + "typescript": "5.5.4", + "vite": "5.4.2", + "vite-tsconfig-paths": "5.0.1" }, "dependencies": { - "@grafana/faro-react": "^1.8.2", - "@grafana/faro-web-sdk": "^1.8.2", - "@grafana/faro-web-tracing": "^1.8.2", - "@navikt/aksel-icons": "6.13.0", - "@navikt/ds-css": "6.13.0", - "@navikt/ds-react": "6.13.0", + "@grafana/faro-react": "^1.9.1", + "@grafana/faro-web-sdk": "^1.9.1", + "@grafana/faro-web-tracing": "^1.9.1", + "@navikt/aksel-icons": "6.16.2", + "@navikt/ds-css": "6.16.2", + "@navikt/ds-react": "6.16.2", "@navikt/fnrvalidator": "2.1.0", "@navikt/nav-dekoratoren-moduler": "2.1.6", - "@reduxjs/toolkit": "2.2.6", + "@reduxjs/toolkit": "2.2.7", "@styled-icons/material": "10.47.0", "date-fns": "3.6.0", "react": "18.3.1", "react-dom": "18.3.1", "react-redux": "9.1.2", - "react-router": "6.25.1", - "react-router-dom": "6.25.1", + "react-router": "6.26.1", + "react-router-dom": "6.26.1", "styled-components": "6.1.12" } } \ No newline at end of file diff --git a/frontend/src/environment/environment.ts b/frontend/src/environment/environment.ts index a35da84b3..d4f979c92 100644 --- a/frontend/src/environment/environment.ts +++ b/frontend/src/environment/environment.ts @@ -3,7 +3,8 @@ export const LOGGED_IN_PATH = '/loggedin-redirect'; enum EnvString { PROD = 'production', DEV = 'development', - LOCAL = 'local', + LOCAL_BFF = 'local-bff', + LOCAL_FRONTEND = 'local-frontend', } interface EnvironmentVariables { @@ -26,50 +27,35 @@ class Environment implements EnvironmentVariables { public readonly isDeployed: boolean; constructor() { - const { apiUrl, environment, version, isProduction, isDevelopment, isLocal, isDeployed } = this.init(); - this.apiUrl = apiUrl; - this.environment = environment; - this.version = version; - this.isProduction = isProduction; - this.isDevelopment = isDevelopment; - this.isLocal = isLocal; - this.isDeployed = isDeployed; - } - - private init(): EnvironmentVariables { - const environment = this.getEnvironment(); - const version = this.getVersion(); - const isProduction = environment === EnvString.PROD; - const isDevelopment = environment === EnvString.DEV; - const isLocal = environment === EnvString.LOCAL; - const isDeployed = !isLocal; - - return { - apiUrl: '/api', - environment, - version, - isProduction, - isDevelopment, - isLocal, - isDeployed, - }; + this.apiUrl = '/api'; + this.environment = this.getEnvironment(); + this.version = this.getVersion(); + this.isProduction = this.environment === EnvString.PROD; + this.isDevelopment = this.environment === EnvString.DEV; + this.isLocal = this.environment === EnvString.LOCAL_FRONTEND; + this.isDeployed = !this.isLocal; } private getEnvironment(): EnvString { const env = document.documentElement.getAttribute('data-environment'); - if (env === EnvString.PROD || env === EnvString.DEV || env === EnvString.LOCAL) { + if ( + env === EnvString.PROD || + env === EnvString.DEV || + env === EnvString.LOCAL_BFF || + env === EnvString.LOCAL_FRONTEND + ) { return env; } - return EnvString.LOCAL; + return EnvString.LOCAL_FRONTEND; } private getVersion(): string { const version = document.documentElement.getAttribute('data-version'); if (version === null || version === '{{VERSION}}') { - return EnvString.LOCAL; + return EnvString.LOCAL_FRONTEND; } return version; diff --git a/frontend/src/hooks/use-user.ts b/frontend/src/hooks/use-user.ts index ef1c67c41..c0435d68a 100644 --- a/frontend/src/hooks/use-user.ts +++ b/frontend/src/hooks/use-user.ts @@ -1,10 +1,10 @@ -import { skipToken } from '@reduxjs/toolkit/query'; -import { useGetUserQuery, useIsAuthenticatedQuery } from '@app/redux-api/user/api'; +import { SkipToken, skipToken } from '@reduxjs/toolkit/query'; +import { useGetSessionQuery, useGetUserQuery } from '@app/redux-api/user/api'; -export const useIsAuthenticated = (skip?: typeof skipToken) => { - const { data, ...rest } = useIsAuthenticatedQuery(skip); +export const useIsAuthenticated = (skip?: SkipToken) => { + const { data, ...rest } = useGetSessionQuery(skip, { refetchOnFocus: true, refetchOnReconnect: true }); - return { ...rest, data: data?.tokenx }; + return { ...rest, data: data?.session.active ?? false }; }; export const useUser = () => { diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 296dc9371..518f1ad0d 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -14,6 +14,8 @@ if (ENVIRONMENT.isLocal) { chatbot: true, redirectToApp: true, logoutUrl: '/oauth2/logout', + context: 'privatperson', + level: 'Level4', logoutWarning: true, }, }); diff --git a/frontend/src/logging/logger.ts b/frontend/src/logging/logger.ts index 4939d27d5..89cad1eae 100644 --- a/frontend/src/logging/logger.ts +++ b/frontend/src/logging/logger.ts @@ -10,7 +10,8 @@ const START_TIME = Date.now(); const SESSION_ID = getUniqueId(); class FrontendLogger { - private tokenExpires: number | undefined; + private tokenExpiresAt: string | undefined; + private sessionEndsAt: string | undefined; private getBase = (level: Level): BaseEventData => { const now = Date.now(); @@ -24,8 +25,9 @@ class FrontendLogger { session_time: sessionTime, session_time_formatted: formatSessionTime(sessionTime), route: window.location.pathname, - token_expires: this.tokenExpires, - is_logged_in: this.tokenExpires !== undefined, + token_expires: this.tokenExpiresAt, + session_ends: this.sessionEndsAt, + is_logged_in: this.tokenExpiresAt !== undefined, }; }; @@ -66,9 +68,14 @@ class FrontendLogger { public sessionEvent = (action: SessionAction) => send({ type: EventTypes.SESSION, action, message: SESSION_ACTIONS[action], ...this.getBase(Level.DEBUG) }); - public setTokenExpires = (tokenExpires: number) => { - this.tokenExpires = tokenExpires; + public setTokenExpires = (tokenExpires: string) => { + this.tokenExpiresAt = tokenExpires; + }; + + public setSessionEndsAt = (sessionEndsAt: string) => { + this.sessionEndsAt = sessionEndsAt; }; } -export const { apiEvent, appEvent, errorEvent, navigationEvent, sessionEvent, setTokenExpires } = new FrontendLogger(); +export const { apiEvent, appEvent, errorEvent, navigationEvent, sessionEvent, setTokenExpires, setSessionEndsAt } = + new FrontendLogger(); diff --git a/frontend/src/logging/types.ts b/frontend/src/logging/types.ts index 446928ef0..a7708f4a0 100644 --- a/frontend/src/logging/types.ts +++ b/frontend/src/logging/types.ts @@ -20,8 +20,10 @@ export interface BaseEventData { level: Level; /** Current client Unix timestamp in milliseconds. */ client_timestamp: number; - /** Milliseconds until the token expires. */ - token_expires?: number; + /** DateTime when the token expires. */ + token_expires?: string; + /** DateTime when the session ends. */ + session_ends?: string; /** If the user is logged in. */ is_logged_in: boolean; /** Current client version. */ diff --git a/frontend/src/redux-api/common.ts b/frontend/src/redux-api/common.ts index 3129c0386..1cac70620 100644 --- a/frontend/src/redux-api/common.ts +++ b/frontend/src/redux-api/common.ts @@ -66,3 +66,4 @@ const staggeredBaseQuery = (baseUrl: string) => { export const API_PATH = '/api/klage-dittnav-api/api'; export const API_BASE_QUERY = staggeredBaseQuery(API_PATH); +export const OAUTH_BASE_QUERY = staggeredBaseQuery('/oauth2'); diff --git a/frontend/src/redux-api/server-sent-events.ts b/frontend/src/redux-api/server-sent-events.ts index 1dfda7a8c..ece784069 100644 --- a/frontend/src/redux-api/server-sent-events.ts +++ b/frontend/src/redux-api/server-sent-events.ts @@ -1,6 +1,6 @@ import { AppEventEnum } from '@app/logging/action'; import { apiEvent, appEvent } from '@app/logging/logger'; -import { userApi } from './user/api'; +import { oauthApi, userApi } from './user/api'; export enum ServerSentEventType { JOURNALPOSTID = 'journalpostId', @@ -92,7 +92,8 @@ export class ServerSentEventManager { if (!preflightOK) { // Probably the session timed out. Double check the logged in status. - userApi.util.invalidateTags(['user', 'isAuthenticated']); + userApi.util.invalidateTags(['user']); + oauthApi.util.invalidateTags(['session']); return; } diff --git a/frontend/src/redux-api/user/api.ts b/frontend/src/redux-api/user/api.ts index 77f91ceb8..a06f1b2cd 100644 --- a/frontend/src/redux-api/user/api.ts +++ b/frontend/src/redux-api/user/api.ts @@ -1,31 +1,61 @@ import { createApi } from '@reduxjs/toolkit/query/react'; -import { setTokenExpires } from '@app/logging/logger'; -import { API_BASE_QUERY } from '../common'; -import { IAuthResponse, IUser } from './types'; +import { setSessionEndsAt, setTokenExpires } from '@app/logging/logger'; +import { API_BASE_QUERY, OAUTH_BASE_QUERY } from '../common'; +import { IUser } from './types'; export const userApi = createApi({ reducerPath: 'userApi', baseQuery: API_BASE_QUERY, - tagTypes: ['isAuthenticated', 'user'], + tagTypes: ['user'], endpoints: (builder) => ({ getUser: builder.query({ query: () => '/bruker', providesTags: ['user'], - onQueryStarted: async (_, { dispatch, queryFulfilled }) => { - const { data } = await queryFulfilled; - const tokenx = data !== undefined; - dispatch(userApi.util.updateQueryData('isAuthenticated', undefined, (draft) => ({ ...draft, tokenx }))); - - setTokenExpires(data.tokenExpires); - }, }), - isAuthenticated: builder.query({ - query: () => '/bruker/authenticated', - providesTags: ['isAuthenticated'], + }), +}); + +interface OAuthSessionData { + session: { + created_at: string; + /** DateTime when the session ends. */ + ends_at: string; + timeout_at: string; + /** How long the session has left in seconds. */ + ends_in_seconds: number; + active: boolean; + timeout_in_seconds: number; + }; + tokens: { + /** DateTime when the token expires. */ + expire_at: string; + refreshed_at: string; + expire_in_seconds: number; + next_auto_refresh_in_seconds: number; + refresh_cooldown: boolean; + refresh_cooldown_seconds: number; + }; +} + +export const oauthApi = createApi({ + reducerPath: 'oauthApi', + baseQuery: OAUTH_BASE_QUERY, + tagTypes: ['session'], + endpoints: (builder) => ({ + getSession: builder.query({ + query: () => ({ url: '/session', validateStatus: ({ status, ok }) => ok || status === 401 }), + providesTags: ['session'], onQueryStarted: async (_, { dispatch, queryFulfilled }) => { const { data } = await queryFulfilled; - if (!data.tokenx) { + const hasData = data !== null; + + if (hasData) { + setTokenExpires(data.tokens.expire_at); + setSessionEndsAt(data.session.ends_at); + } + + if (!hasData || !data.session.active) { dispatch(userApi.util.updateQueryData('getUser', undefined, () => undefined)); } }, @@ -33,4 +63,6 @@ export const userApi = createApi({ }), }); -export const { useGetUserQuery, useIsAuthenticatedQuery } = userApi; +export const { useGetSessionQuery } = oauthApi; + +export const { useGetUserQuery } = userApi; diff --git a/frontend/src/redux-api/user/types.ts b/frontend/src/redux-api/user/types.ts index 753f9d419..07129cf14 100644 --- a/frontend/src/redux-api/user/types.ts +++ b/frontend/src/redux-api/user/types.ts @@ -11,10 +11,4 @@ export interface IName { export interface IUser { navn: IName; folkeregisteridentifikator?: IIdentifikator; - tokenExpires: number; // Expiration timestamp in milliseconds since 01-01-1970. -} - -export interface IAuthResponse { - tokenx: boolean; - selvbetjening: boolean; } diff --git a/frontend/src/redux/configure-store.ts b/frontend/src/redux/configure-store.ts index f1ed384e9..e2b1319b2 100644 --- a/frontend/src/redux/configure-store.ts +++ b/frontend/src/redux/configure-store.ts @@ -2,7 +2,7 @@ import { Middleware, configureStore } from '@reduxjs/toolkit'; import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; import { caseApi } from '@app/redux-api/case/api'; import { innsendingsytelserApi } from '@app/redux-api/innsendingsytelser'; -import { userApi } from '@app/redux-api/user/api'; +import { oauthApi, userApi } from '@app/redux-api/user/api'; import { RootState, rootReducer } from './root'; interface RejectedApiAction { @@ -30,7 +30,8 @@ const rtkQueryErrorLogger: Middleware = () => (next) => (action) => { console.error('rtkQueryError', action); if (action.payload.status === 401) { - userApi.util.invalidateTags(['isAuthenticated']); + userApi.util.invalidateTags(['user']); + oauthApi.util.invalidateTags(['session']); } } @@ -52,7 +53,13 @@ export const reduxStore = configureStore({ 'meta.arg.originalArgs.file', ], }, - }).concat([innsendingsytelserApi.middleware, userApi.middleware, caseApi.middleware, rtkQueryErrorLogger]), + }).concat([ + innsendingsytelserApi.middleware, + userApi.middleware, + caseApi.middleware, + oauthApi.middleware, + rtkQueryErrorLogger, + ]), }); export type AppDispatch = typeof reduxStore.dispatch; diff --git a/frontend/src/redux/root.ts b/frontend/src/redux/root.ts index 5eb3f6f68..4c2915199 100644 --- a/frontend/src/redux/root.ts +++ b/frontend/src/redux/root.ts @@ -1,13 +1,14 @@ import { combineReducers } from 'redux'; import { caseApi } from '@app/redux-api/case/api'; import { innsendingsytelserApi } from '@app/redux-api/innsendingsytelser'; -import { userApi } from '@app/redux-api/user/api'; +import { oauthApi, userApi } from '@app/redux-api/user/api'; import { sessionSlice } from './session/session'; export const rootReducer = combineReducers({ [innsendingsytelserApi.reducerPath]: innsendingsytelserApi.reducer, [userApi.reducerPath]: userApi.reducer, [caseApi.reducerPath]: caseApi.reducer, + [oauthApi.reducerPath]: oauthApi.reducer, session: sessionSlice.reducer, }); diff --git a/frontend/src/routes/redirects.tsx b/frontend/src/routes/redirects.tsx deleted file mode 100644 index dfee69ef1..000000000 --- a/frontend/src/routes/redirects.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { useEffect } from 'react'; -import { Outlet } from 'react-router-dom'; -import { LoadingPage } from '@app/components/loading-page/loading-page'; -import { useTranslation } from '@app/language/use-translation'; -import { AppEventEnum } from '@app/logging/action'; -import { appEvent } from '@app/logging/logger'; -import { useIsAuthenticatedQuery } from '@app/redux-api/user/api'; -import { login } from '@app/user/login'; - -export const UpgradeSession = () => { - const { user_loader } = useTranslation(); - const { isLoading, data } = useIsAuthenticatedQuery(); - - // Upgrade session if user is authenticated and has selvbetjening token but not tokenx. - // No session at all is allowed. - const shouldUpgradeSession = data?.selvbetjening === true && data?.tokenx === false; - - useEffect(() => { - if (shouldUpgradeSession) { - appEvent(AppEventEnum.SESSION_UPGRADE); - login(); - } - }, [shouldUpgradeSession]); - - if (isLoading || shouldUpgradeSession) { - return {user_loader.loading_user}; - } - - return ; -}; diff --git a/frontend/src/routes/routes.tsx b/frontend/src/routes/routes.tsx index dbd136969..3d6f089f4 100644 --- a/frontend/src/routes/routes.tsx +++ b/frontend/src/routes/routes.tsx @@ -1,4 +1,4 @@ -import { BrowserRouter, Route, Routes } from 'react-router-dom'; +import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; import { CaseBegrunnelsePage } from '@app/components/case/innlogget/begrunnelse/begrunnelse-page'; import { CaseInnsendingPage } from '@app/components/case/innlogget/innsending/innsending-page'; import { CaseKvitteringPage } from '@app/components/case/innlogget/kvittering/kvittering-page'; @@ -14,7 +14,6 @@ import { DekoratorSetRedirect } from './dekorator-set-redirect'; import { ErrorBoundary } from './error-boundary'; import { NavigationLogger } from './navigation-logger'; import { NotFoundPage } from './not-found-page'; -import { UpgradeSession } from './redirects'; export const Router = () => ( @@ -23,9 +22,10 @@ export const Router = () => ( - }> + + } /> } /> } /> } /> diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 507a42202..157131430 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -9,9 +9,9 @@ export default defineConfig({ server: { port: 8064, proxy: { - '/api': 'https://klage-dittnav-api.intern.dev.nav.no', - '/person/innloggingsstatus/auth': 'https://innloggingsstatus.dev.nav.no', + '/api': { target: 'https://klage.intern.dev.nav.no', changeOrigin: true, headers: { 'Origin': 'https://klage.intern.dev.nav.no' } }, + '/oauth2': { target: 'https://klage.intern.dev.nav.no', changeOrigin: true, headers: { 'Origin': 'https://klage.intern.dev.nav.no' } }, + '/person/innloggingsstatus/auth': { target: 'https://innloggingsstatus.dev.nav.no', changeOrigin: true, headers: { 'Origin': 'https://klage.intern.dev.nav.no' } }, }, }, -}) - +}); diff --git a/nais/nais.yaml b/nais/nais.yaml index 2deaf2018..7a44dfc4e 100644 --- a/nais/nais.yaml +++ b/nais/nais.yaml @@ -20,6 +20,9 @@ spec: min: 2 max: 4 cpuThresholdPercentage: 80 + redis: + - instance: obo-cache + access: readwrite liveness: path: /isAlive initialDelay: 3 @@ -32,6 +35,20 @@ spec: enabled: true sidecar: enabled: true + autoLogin: true + autoLoginIgnorePaths: + - /api/** + - / + - /nb/klage/** + - /nn/klage/** + - /en/klage/** + - /nb/anke/** + - /nn/anke/** + - /en/anke/** + - /nb/ettersendelse/** + - /nn/ettersendelse/** + - /en/ettersendelse/** + - /frontend-log prometheus: enabled: true accessPolicy: diff --git a/server/.env b/server/.env deleted file mode 100644 index da5c8f98e..000000000 --- a/server/.env +++ /dev/null @@ -1,3 +0,0 @@ -PORT=8080 -VERSION=dev -SLACK_URL=http://slack.com diff --git a/server/.vscode/Klageinngang (backend for frontend).code-workspace b/server/.vscode/Klageinngang (backend for frontend).code-workspace index ebbcef03b..19434190d 100644 --- a/server/.vscode/Klageinngang (backend for frontend).code-workspace +++ b/server/.vscode/Klageinngang (backend for frontend).code-workspace @@ -5,4 +5,4 @@ "name": "Klageinngang (backend for frontend)" } ] -} \ No newline at end of file +} diff --git a/server/.vscode/launch.json b/server/.vscode/launch.json new file mode 100644 index 000000000..351e5f9c2 --- /dev/null +++ b/server/.vscode/launch.json @@ -0,0 +1,13 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Launch Program", + "skipFiles": ["/**"], + "program": "${workspaceFolder}/dist/server.js", + "cwd": "${workspaceFolder}" + } + ] +} diff --git a/server/.vscode/settings.json b/server/.vscode/settings.json index d76deb715..b5324eee5 100644 --- a/server/.vscode/settings.json +++ b/server/.vscode/settings.json @@ -1,36 +1,43 @@ { - "eslint.validate": [ - "javascript", - "typescript" - ], - "eslint.run": "onType", - "eslint.codeActionsOnSave.mode": "all", - "editor.formatOnSave": true, - "eslint.format.enable": true, - "editor.linkedEditing": true, - "editor.defaultFormatter": "dbaeumer.vscode-eslint", - "editor.codeActionsOnSave": { - "source.fixAll.eslint": "explicit" - }, - "typescript.validate.enable": true, - "typescript.tsdk": "node_modules/typescript/lib", - "typescript.preferences.importModuleSpecifier": "non-relative", - "workbench.editor.closeOnFileDelete": true, - "npm-intellisense.importQuotes": "'", - "npm-intellisense.importES6": true, - "npm-intellisense.showBuildInLibs": true, - "liveshare.autoShareServers": true, - "liveshare.openSharedServers": false, - "liveshare.languages.allowGuestCommandControl": true, - "liveshare.allowGuestDebugControl": true, - "liveshare.allowGuestTaskControl": true, - "liveshare.notebooks.allowGuestExecuteCells": true, - "tslint.enable": false, - "prettier.requireConfig": true, - "[jsonc]": { - "editor.defaultFormatter": "vscode.json-language-features" - }, - "[json]": { - "editor.defaultFormatter": "vscode.json-language-features" - } -} \ No newline at end of file + "editor.formatOnSave": true, + "editor.linkedEditing": true, + "editor.defaultFormatter": "biomejs.biome", + "editor.codeActionsOnSave": { + "quickfix.biome": "always", + "source.fixAll.biome": "always", + "source.organizeImports.biome": "always", + "source.addMissingImports.ts": "explicit" + }, + "editor.acceptSuggestionOnCommitCharacter": false, + "typescript.validate.enable": true, + "typescript.tsdk": "node_modules/typescript/lib", + "typescript.preferences.importModuleSpecifier": "non-relative", + "workbench.editor.closeOnFileDelete": true, + "npm-intellisense.importQuotes": "'", + "npm-intellisense.importES6": true, + "npm-intellisense.showBuildInLibs": true, + "liveshare.autoShareServers": true, + "liveshare.openSharedServers": false, + "liveshare.languages.allowGuestCommandControl": true, + "liveshare.allowGuestDebugControl": true, + "liveshare.allowGuestTaskControl": true, + "liveshare.notebooks.allowGuestExecuteCells": true, + "[typescript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[javascript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[javascriptreact]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[json]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[jsonc]": { + "editor.defaultFormatter": "biomejs.biome" + } +} diff --git a/server/biome.json b/server/biome.json new file mode 100644 index 000000000..89c971ec6 --- /dev/null +++ b/server/biome.json @@ -0,0 +1,59 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.8.3/schema.json", + "organizeImports": { + "enabled": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "suspicious": { + "noDoubleEquals": { + "level": "error", + "fix": "unsafe" + }, + "noGlobalIsFinite": { + "level": "error", + "fix": "unsafe" + } + }, + "complexity": { + "useArrowFunction": { + "level": "error", + "fix": "safe" + }, + "useSimplifiedLogicExpression": { + "level": "warn" + } + }, + "correctness": { + "noUnusedImports": { + "level": "error" + } + }, + "style": { + "noUnusedTemplateLiteral": { + "level": "warn", + "fix": "unsafe" + } + } + } + }, + "formatter": { + "lineWidth": 120, + "indentStyle": "space" + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "arrowParentheses": "always", + "bracketSameLine": false + }, + "linter": { + "enabled": true + } + }, + "files": { + "ignore": ["dist/**"] + } +} diff --git a/server/bun.lockb b/server/bun.lockb index 143e5e3c3..0aa4a02dd 100755 Binary files a/server/bun.lockb and b/server/bun.lockb differ diff --git a/server/bunfig.toml b/server/bunfig.toml deleted file mode 100644 index 4dfda52d5..000000000 --- a/server/bunfig.toml +++ /dev/null @@ -1,2 +0,0 @@ -[install.scopes] -"@navikt" = { token = "$NODE_AUTH_TOKEN", url = "https://npm.pkg.github.com/" } diff --git a/server/eslint.config.js b/server/eslint.config.js deleted file mode 100644 index ea395a2c4..000000000 --- a/server/eslint.config.js +++ /dev/null @@ -1,214 +0,0 @@ -import globals from "globals"; -import pluginJs from "@eslint/js"; -import tseslint from "typescript-eslint"; -import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended"; - -export default [{ - languageOptions: { - globals: globals.node, - parserOptions: { - project: "./tsconfig.json" - } - - } -}, -pluginJs.configs.recommended, -...tseslint.configs.recommended, - eslintPluginPrettierRecommended, -{ - "rules": { - "@typescript-eslint/array-type": "error", - "@typescript-eslint/consistent-type-definitions": [ - "error", - "interface" - ], - "@typescript-eslint/consistent-type-imports": [ - "error", - { - "prefer": "no-type-imports" - } - ], - "@typescript-eslint/explicit-module-boundary-types": "off", - "@typescript-eslint/no-base-to-string": "error", - "@typescript-eslint/no-empty-function": "off", - "@typescript-eslint/no-shadow": [ - "error" - ], - "@typescript-eslint/no-unsafe-assignment": "error", - "@typescript-eslint/no-unused-vars": [ - "error", - { - "args": "all", - "argsIgnorePattern": "^_", - "caughtErrors": "all", - "caughtErrorsIgnorePattern": "^_", - "destructuredArrayIgnorePattern": "^_", - "ignoreRestSiblings": true - } - ], - "@typescript-eslint/no-var-requires": 0, - "@typescript-eslint/prefer-includes": "error", - "@typescript-eslint/prefer-nullish-coalescing": "error", - "@typescript-eslint/prefer-reduce-type-parameter": "error", - "@typescript-eslint/restrict-template-expressions": [ - "error", - { - "allowNumber": true - } - ], - "@typescript-eslint/strict-boolean-expressions": "error", - "@typescript-eslint/switch-exhaustiveness-check": "error", - "array-callback-return": "error", - "arrow-body-style": [ - "error", - "as-needed" - ], - "comma-dangle": [ - 0, - "never" - ], - "complexity": [ - "error", - { - "max": 20 - } - ], - "curly": [ - "error", - "all" - ], - "eol-last": [ - "error", - "always" - ], - "eqeqeq": "error", - "keyword-spacing": [ - "error", - { - "before": true - } - ], - "linebreak-style": [ - "error", - "unix" - ], - "max-depth": [ - "error", - { - "max": 4 - } - ], - "max-lines": [ - "error", - { - "max": 400, - "skipBlankLines": false, - "skipComments": true - } - ], - "no-alert": "error", - "no-console": [ - "error", - { - "allow": [ - "warn", - "error", - "info", - "debug" - ] - } - ], - "no-debugger": "error", - "no-duplicate-imports": "error", - "no-else-return": "error", - "no-eval": "error", - "no-extend-native": "error", - "no-implicit-coercion": "error", - "no-lonely-if": "error", - "no-nested-ternary": "error", - "no-proto": "error", - "no-restricted-globals": [ - "error", - { - "name": "event", - "message": "Use local parameter instead." - } - ], - "no-return-assign": [ - "error", - "always" - ], - "no-shadow": "off", - "no-unneeded-ternary": "error", - "no-unused-vars": "off", - "no-useless-rename": "error", - "no-useless-return": "error", - "no-var": "error", - "object-shorthand": [ - "error", - "always" - ], - "padded-blocks": [ - "error", - "never" - ], - "padding-line-between-statements": [ - "error", - { - "blankLine": "always", - "prev": "*", - "next": [ - "block", - "block-like", - "return" - ] - }, - { - "blankLine": "never", - "prev": "*", - "next": [ - "case", - "default" - ] - } - ], - "prefer-const": "error", - "prefer-destructuring": "error", - "prefer-object-spread": "error", - "prefer-rest-params": "error", - "prefer-spread": "error", - "prettier/prettier": [ - "error", - { - "semi": true, - "singleQuote": true, - "printWidth": 120, - "tabWidth": 2, - "endOfLine": "lf" - } - ], - "radix": [ - "error", - "always" - ], - "sort-imports": [ - "error", - { - "ignoreDeclarationSort": true - } - ], - "space-before-blocks": [ - "error", - { - "functions": "always", - "keywords": "always", - "classes": "always" - } - ], - "yoda": [ - "error", - "never" - ] - } -} -]; \ No newline at end of file diff --git a/server/package.json b/server/package.json index 719617e91..d3dd987ee 100644 --- a/server/package.json +++ b/server/package.json @@ -4,41 +4,30 @@ "private": true, "author": "NAV", "license": "MIT", + "main": "dist/server.js", "type": "module", "scripts": { - "start": "bun run watch", - "prod": "node --trace-warnings dist/server.js", + "start": "bun run build --watch & NAIS_CLUSTER_NAME=local node --watch --trace-warnings dist/server.js", "build": "bun build ./src/server.ts --target node --format esm --sourcemap --outdir dist", - "watch": "bun run build -- --watch", - "lint": "eslint ./src/**/*.ts --color --cache --cache-strategy content --cache-location .eslintcache", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "lint": "biome check" }, "dependencies": { - "@navikt/nav-dekoratoren-moduler": "2.1.6", - "compression": "1.7.4", - "cookie": "^0.6.0", - "cors": "2.8.5", - "express": "4.19.2", - "express-prom-bundle": "7.0.0", - "http-proxy-middleware": "3.0.0", - "jose": "5.6.3", - "jsdom": "^24.1.0", - "node-fetch": "3.3.2", + "@fastify/cors": "9.0.1", + "@fastify/http-proxy": "9.5.0", + "@fastify/type-provider-typebox": "4.1.0", + "fastify": "4.28.1", + "fastify-metrics": "11.0.0", + "happy-dom": "15.6.0", + "jose": "5.8.0", "openid-client": "5.6.5", "prom-client": "15.1.3", - "react": "^18.3.1" + "redis": "4.7.0" }, "devDependencies": { - "@eslint/js": "9.7.0", - "@types/bun": "1.1.6", - "@types/compression": "1.7.5", - "@types/cookie": "^0.6.0", - "@types/cors": "2.8.17", - "eslint": "8.57.0", - "eslint-config-prettier": "9.1.0", - "eslint-plugin-prettier": "5.2.1", - "globals": "15.8.0", - "typescript": "5.5.3", - "typescript-eslint": "7.16.1" + "@biomejs/biome": "1.8.3", + "@types/bun": "1.1.8", + "@types/node": "22.5.1", + "typescript": "5.5.4" } } diff --git a/server/src/auth/cache/cache-gauge.ts b/server/src/auth/cache/cache-gauge.ts new file mode 100644 index 000000000..10310536e --- /dev/null +++ b/server/src/auth/cache/cache-gauge.ts @@ -0,0 +1,37 @@ +import { proxyRegister } from '@app/prometheus/types'; +import { Counter, Gauge, Histogram } from 'prom-client'; + +const labelNames = ['hit'] as const; + +export const redisCacheGauge = new Counter({ + name: 'obo_redis_cache', + help: 'Number of requests to the Redis OBO cache. "hit" is the type of hit: "miss", "invalid", "hit" or "expired".', + labelNames, + registers: [proxyRegister], +}); + +export const redisCacheSizeGauge = new Gauge({ + name: 'obo_redis_cache_size', + help: 'Number of OBO tokens in the Redis cache.', + registers: [proxyRegister], +}); + +export const memoryCacheGauge = new Counter({ + name: 'obo_cache', + help: 'Number of requests to the OBO cache. "hit" is the type of hit: "miss", "redis", "hit", or "expired".', + labelNames, + registers: [proxyRegister], +}); + +export const memoryCacheSizeGauge = new Gauge({ + name: 'obo_cache_size', + help: 'Number of OBO tokens in the cache.', + registers: [proxyRegister], +}); + +export const oboRequestDuration = new Histogram({ + name: 'obo_request_duration', + help: 'Duration of OBO token requests in milliseconds.', + buckets: [0, 10, 100, 200, 300, 400, 500, 600, 800, 900, 1_000], + registers: [proxyRegister], +}); diff --git a/server/src/auth/cache/cache.ts b/server/src/auth/cache/cache.ts new file mode 100644 index 000000000..d8e77d574 --- /dev/null +++ b/server/src/auth/cache/cache.ts @@ -0,0 +1,80 @@ +import { OboMemoryCache } from '@app/auth/cache/memory-cache'; +import { OboRedisCache } from '@app/auth/cache/redis-cache'; +import { optionalEnvString } from '@app/config/env-var'; + +const REDIS_URI = optionalEnvString('REDIS_URI_OBO_CACHE'); +const REDIS_USERNAME = optionalEnvString('REDIS_USERNAME_OBO_CACHE'); +const REDIS_PASSWORD = optionalEnvString('REDIS_PASSWORD_OBO_CACHE'); + +class OboTieredCache { + #oboRedisCache: OboRedisCache; + #oboMemoryCache: OboMemoryCache | null = null; + #isReady = false; + + constructor(redisUri: string, redisUsername: string, redisPassword: string) { + this.#oboRedisCache = new OboRedisCache(redisUri, redisUsername, redisPassword); + this.#init(); + } + + async #init() { + await this.#oboRedisCache.init(); + const allTokenMessages = await this.#oboRedisCache.getAll(); + const oboMemoryCache = new OboMemoryCache(allTokenMessages); + this.#oboMemoryCache = oboMemoryCache; + this.#oboRedisCache.addTokenListener(({ key, token, expiresAt }) => oboMemoryCache.set(key, token, expiresAt)); + this.#isReady = true; + } + + public async get(key: string): Promise { + if (this.#oboMemoryCache === null) { + return null; + } + + const memoryHit = this.#oboMemoryCache.get(key); + + if (memoryHit !== null) { + return memoryHit.token; + } + + const redisHit = await this.#oboRedisCache.get(key); + + if (redisHit !== null) { + this.#oboMemoryCache.set(key, redisHit.token, redisHit.expiresAt); + + return redisHit.token; + } + + return null; + } + + public async set(key: string, token: string, expiresAt: number): Promise { + this.#oboMemoryCache?.set(key, token, expiresAt); + await this.#oboRedisCache.set(key, token, expiresAt); + } + + public get isReady(): boolean { + return this.#isReady && this.#oboRedisCache.isReady; + } +} + +class OboSimpleCache { + #oboMemoryCache = new OboMemoryCache([]); + + public get(key: string): string | null { + const memoryHit = this.#oboMemoryCache.get(key); + + return memoryHit?.token ?? null; + } + + public set(key: string, token: string, expiresAt: number): void { + this.#oboMemoryCache.set(key, token, expiresAt); + } + + public get isReady(): boolean { + return true; + } +} + +const hasRedis = REDIS_URI !== undefined && REDIS_USERNAME !== undefined && REDIS_PASSWORD !== undefined; + +export const oboCache = hasRedis ? new OboTieredCache(REDIS_URI, REDIS_USERNAME, REDIS_PASSWORD) : new OboSimpleCache(); diff --git a/server/src/auth/cache/memory-cache.ts b/server/src/auth/cache/memory-cache.ts new file mode 100644 index 000000000..ef09d11cd --- /dev/null +++ b/server/src/auth/cache/memory-cache.ts @@ -0,0 +1,88 @@ +import { memoryCacheGauge, memoryCacheSizeGauge } from '@app/auth/cache/cache-gauge'; +import type { TokenMessage } from '@app/auth/cache/types'; +import { getLogger } from '@app/logger'; + +const log = getLogger('obo-memory-cache'); + +type Value = [string, number]; + +export class OboMemoryCache { + #cache: Map; + + constructor(tokenMessages: TokenMessage[]) { + this.#cache = new Map( + tokenMessages.map((tokenMessage) => [tokenMessage.key, [tokenMessage.token, tokenMessage.expiresAt]]), + ); + + log.info({ msg: `Created OBO memory cache with ${tokenMessages.length} tokens.` }); + + /** + * Clean OBO token cache every 10 minutes. + * OBO tokens expire after 1 hour. + */ + setInterval(() => this.#clean(), 10 * 60 * 1_000); + } + + public get(key: string) { + const value = this.#cache.get(key); + + if (value === undefined) { + memoryCacheGauge.inc({ hit: 'miss' }); + + return null; + } + + const [token, expiresAt] = value; + + if (expiresAt <= now()) { + memoryCacheGauge.inc({ hit: 'expired' }); + this.#cache.delete(key); + memoryCacheSizeGauge.set(this.#cache.size); + + return null; + } + + memoryCacheGauge.inc({ hit: 'hit' }); + + return { token, expiresAt }; + } + + public set(key: string, token: string, expiresAt: number) { + this.#cache.set(key, [token, expiresAt]); + memoryCacheSizeGauge.set(this.#cache.size); + } + + #all() { + return Array.from(this.#cache.entries()); + } + + #clean() { + const before = this.#cache.size; + const timestamp = now(); + + const deleted: number = this.#all() + .map(([key, [, expires_at]]) => { + if (expires_at <= timestamp) { + return this.#cache.delete(key); + } + + return false; + }) + .filter((d) => d).length; + + const after = this.#cache.size; + memoryCacheSizeGauge.set(after); + + if (deleted === 0) { + log.debug({ msg: `Cleaned the OBO token cache. No expired tokens found. Cache had ${before} tokens.` }); + + return; + } + + log.debug({ + msg: `Cleaned the OBO token cache. Deleted ${deleted} expired tokens. Cache had ${before} tokens, ${after} remaining.`, + }); + } +} + +const now = () => Math.ceil(Date.now() / 1_000); diff --git a/server/src/auth/cache/redis-cache.ts b/server/src/auth/cache/redis-cache.ts new file mode 100644 index 000000000..b0a7c260b --- /dev/null +++ b/server/src/auth/cache/redis-cache.ts @@ -0,0 +1,168 @@ +import { memoryCacheGauge, redisCacheGauge, redisCacheSizeGauge } from '@app/auth/cache/cache-gauge'; +import type { TokenMessage } from '@app/auth/cache/types'; +import { getLogger } from '@app/logger'; +import { type RedisClientType, createClient } from 'redis'; + +const log = getLogger('obo-redis-cache'); + +type TokenListener = (message: TokenMessage) => void; + +const TOKEN_CHANNEL = 'obo-token'; + +export class OboRedisCache { + #client: RedisClientType; + #subscribeClient: RedisClientType; + + #listeners: TokenListener[] = []; + + constructor(url: string, username: string, password: string) { + this.#client = createClient({ url, username, password, pingInterval: 3_000 }); + this.#client.on('error', (error) => log.error({ msg: 'Redis Data Client Error', error })); + + this.#subscribeClient = this.#client.duplicate(); + this.#subscribeClient.on('error', (error) => log.error({ msg: 'Redis Subscribe Client Error', error })); + } + + public async init() { + await Promise.all([this.#subscribeClient.connect(), this.#client.connect()]); + + await this.#subscribeClient.subscribe(TOKEN_CHANNEL, (json) => { + try { + const parsed: unknown = JSON.parse(json); + + if (!isTokenMessage(parsed)) { + log.warn({ msg: 'Invalid token message' }); + + return; + } + + for (const listener of this.#listeners) { + listener(parsed); + } + } catch (error) { + log.warn({ msg: 'Failed to parse token message', error }); + } + }); + + this.#refreshCacheSizeMetric(); + + setInterval(() => this.#refreshCacheSizeMetric(), 30_000); + } + + #refreshCacheSizeMetric = async () => { + const count = await this.#client.dbSize(); + redisCacheSizeGauge.set(count); + }; + + public async getAll(): Promise { + const keys = await this.#client.keys('*'); + + if (keys.length === 0) { + return []; + } + + const tokens = await this.#client.mGet(keys); + + const promises = tokens.map(async (token, index) => { + if (token === null) { + return null; + } + + const key = keys[index]; + + if (key === undefined) { + return null; + } + + const ttl = await this.#client.ttl(key); + + if (ttl <= 0) { + return null; + } + + return { token, key, expiresAt: now() + ttl }; + }); + + return Promise.all(promises).then((results) => results.filter(isNotNull)); + } + + public async get(key: string) { + /** + * ttl() gets remaining time to live in seconds. + * Returns -2 if the key does not exist. + * Returns -1 if the key exists but has no associated expire. + * @see https://redis.io/docs/latest/commands/ttl/ + */ + const [token, ttl] = await Promise.all([this.#client.get(key), this.#client.ttl(key)]); + + if (token === null || ttl === -2) { + redisCacheGauge.inc({ hit: 'miss' }); + + return null; + } + + if (ttl === -1) { + redisCacheGauge.inc({ hit: 'invalid' }); + this.#client.del(key); + this.#refreshCacheSizeMetric(); + + return null; + } + + if (ttl === 0) { + redisCacheGauge.inc({ hit: 'expired' }); + + return null; + } + + redisCacheGauge.inc({ hit: 'hit' }); + memoryCacheGauge.inc({ hit: 'redis' }); + + return { token, expiresAt: now() + ttl }; + } + + public async set(key: string, token: string, expiresAt: number) { + const json = JSON.stringify({ key, token, expiresAt } satisfies TokenMessage); + this.#client.publish(TOKEN_CHANNEL, json); + + await this.#client.set(key, token, { EXAT: expiresAt }); + + this.#refreshCacheSizeMetric(); + } + + public addTokenListener(listener: TokenListener) { + this.#listeners.push(listener); + } + + public removeTokenListener(listener: TokenListener) { + const index = this.#listeners.indexOf(listener); + + if (index !== -1) { + this.#listeners.splice(index, 1); + } + } + + public get isReady() { + return ( + this.#client.isReady && + this.#client.isOpen && + this.#subscribeClient.isReady && + this.#subscribeClient.isOpen && + this.#subscribeClient.isPubSubActive + ); + } +} + +const now = () => Math.floor(Date.now() / 1_000); + +const isNotNull = (value: T | null): value is T => value !== null; + +const isTokenMessage = (message: unknown): message is TokenMessage => { + if (typeof message !== 'object' || message === null) { + return false; + } + + const { key, token, expiresAt } = message as TokenMessage; + + return typeof key === 'string' && typeof token === 'string' && typeof expiresAt === 'number'; +}; diff --git a/server/src/auth/cache/types.ts b/server/src/auth/cache/types.ts new file mode 100644 index 000000000..64f8864af --- /dev/null +++ b/server/src/auth/cache/types.ts @@ -0,0 +1,5 @@ +export interface TokenMessage { + key: string; + token: string; + expiresAt: number; +} diff --git a/server/src/auth/on-behalf-of-cache.ts b/server/src/auth/on-behalf-of-cache.ts deleted file mode 100644 index 3cffea74e..000000000 --- a/server/src/auth/on-behalf-of-cache.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { getLogger } from '@app/logger/logger'; - -const log = getLogger('auth'); - -export const oboCache: Map = new Map(); - -setInterval( - () => { - const before = oboCache.size; - const timestamp = now(); - - const deleted: number = Array.from(oboCache.entries()) - .map(([key, [, expires_at]]) => { - if (expires_at <= timestamp) { - return oboCache.delete(key); - } - - return false; - }) - .filter((d) => d).length; - - const after = oboCache.size; - - if (deleted === 0) { - log.debug({ message: `Cleaned the OBO token cache. No expired tokens found. Cache had ${before} tokens.` }); - - return; - } - - log.debug({ - message: `Cleaned the OBO token cache. Deleted ${deleted} expired tokens. Cache had ${before} tokens, ${after} remaining.`, - }); - }, - 10 * 60 * 1000, -); // 10 minutes. - -export const now = () => Math.round(Date.now() / 1000); diff --git a/server/src/auth/on-behalf-of.ts b/server/src/auth/on-behalf-of.ts index 91b9384c2..210fdd556 100644 --- a/server/src/auth/on-behalf-of.ts +++ b/server/src/auth/on-behalf-of.ts @@ -1,67 +1,62 @@ -import { Client, GrantBody } from 'openid-client'; -import { serverConfig } from '@app/config/server-config'; -import { getLogger } from '@app/logger/logger'; -import { now, oboCache } from './on-behalf-of-cache'; +import { createHash } from 'node:crypto'; +import { oboCache } from '@app/auth/cache/cache'; +import { NAIS_CLUSTER_NAME } from '@app/config/config'; +import { getLogger } from '@app/logger'; +import type { Client, GrantBody } from 'openid-client'; -const log = getLogger('auth'); +const log = getLogger('obo-token'); export const getOnBehalfOfAccessToken = async ( authClient: Client, - access_token: string, + accessToken: string, appName: string, + trace_id: string, + span_id: string, ): Promise => { - const cacheKey = `${access_token}-${appName}`; + const hash = createHash('sha256').update(accessToken).digest('hex'); + const cacheKey = `${hash}-${appName}`; + const token = await oboCache.get(cacheKey); - const cacheHit = oboCache.get(cacheKey); - - if (typeof cacheHit !== 'undefined') { - const [cached_access_token, expires_at] = cacheHit; - - if (expires_at > now()) { - return cached_access_token; - } - - oboCache.delete(cacheKey); + if (token !== null) { + return token; } if (typeof authClient.issuer.metadata.token_endpoint !== 'string') { - const error = new Error(`OpenID issuer misconfigured. Missing token endpoint.`); - log.error({ error }); + const error = new Error('OpenID issuer misconfigured. Missing token endpoint.'); + log.error({ msg: 'On-Behalf-Of error', error, trace_id, span_id }); throw error; } - const params: GrantBody = { - grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', - subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', - client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', - subject_token: getSubjectToken(access_token), - audience: `${serverConfig.cluster}:klage:${appName}`, - }; + try { + const params: GrantBody = { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', + subject_token: accessToken, + audience: `${NAIS_CLUSTER_NAME}:klage:${appName}`, + }; + + const { access_token: obo_access_token, expires_at } = await authClient.grant(params, { + clientAssertionPayload: { + aud: authClient.issuer.metadata.token_endpoint, + nbf: now(), + }, + }); + + if (typeof obo_access_token !== 'string') { + throw new Error('No on-behalf-of access token from TokenX.'); + } - const { access_token: obo_access_token, expires_at } = await authClient.grant(params, { - clientAssertionPayload: { - aud: authClient.issuer.metadata.token_endpoint, - nbf: now(), - }, - }); + if (typeof expires_at === 'number') { + oboCache.set(cacheKey, obo_access_token, expires_at); + } - if (typeof obo_access_token !== 'string') { - throw new Error('No on-behalf-of access token received.'); - } + return obo_access_token; + } catch (error) { + log.error({ msg: 'On-Behalf-Of error', error, trace_id, span_id }); - if (typeof expires_at === 'number') { - oboCache.set(cacheKey, [obo_access_token, expires_at]); + throw error; } - - return obo_access_token; }; -const getSubjectToken = (bearerToken: string) => { - const parts = bearerToken.split(' '); - - if (parts.length !== 2) { - throw new Error('Error while splitting bearer token'); - } - - return parts[1]; -}; +const now = () => Math.floor(Date.now() / 1_000); diff --git a/server/src/auth/token-x-client.ts b/server/src/auth/token-x-client.ts index 379dda9cc..c6817c05b 100644 --- a/server/src/auth/token-x-client.ts +++ b/server/src/auth/token-x-client.ts @@ -1,49 +1,40 @@ -import { JWK } from 'jose'; -import { Issuer } from 'openid-client'; -import { requiredEnvString, requiredEnvUrl } from '@app/config/env-var'; -import { parseJSON } from '@app/functions/parse-json'; -import { getLogger } from '@app/logger/logger'; +import { TOKEN_X_CLIENT_ID, TOKEN_X_PRIVATE_JWK, TOKEN_X_WELL_KNOWN_URL } from '@app/config/config'; +import { isLocal } from '@app/config/env'; +import { getLogger } from '@app/logger'; +import { type BaseClient, Issuer } from 'openid-client'; const log = getLogger('auth'); -const getJwk = () => { - const envVar = 'TOKEN_X_PRIVATE_JWK'; +let tokenXInstance: BaseClient | null = null; - const privateJwk = requiredEnvString(envVar); - - const jwk = parseJSON(privateJwk); - - if (jwk === null) { - throw new Error(`Could not parse ${envVar}`); +export const getTokenXClient = async (retries = 3): Promise => { + if (tokenXInstance !== null) { + return tokenXInstance; } - return jwk; -}; - -const getTokenXClient = async () => { try { - const wellKnownUrl = requiredEnvUrl('TOKEN_X_WELL_KNOWN_URL'); - const clientId = requiredEnvString('TOKEN_X_CLIENT_ID'); + const issuer = await Issuer.discover(TOKEN_X_WELL_KNOWN_URL); - const issuer = await Issuer.discover(wellKnownUrl); + const keys = [TOKEN_X_PRIVATE_JWK]; - const keys = [getJwk()]; + tokenXInstance = new issuer.Client( + { client_id: TOKEN_X_CLIENT_ID, token_endpoint_auth_method: 'private_key_jwt' }, + { keys }, + ); - return new issuer.Client({ client_id: clientId, token_endpoint_auth_method: 'private_key_jwt' }, { keys }); + return tokenXInstance; } catch (error) { - log.error({ error, message: 'Failed to get Token X client' }); - throw error; - } -}; - -export class TokenXClient { - private static instance: ReturnType | null = null; + if (retries !== 0) { + const triesRemaining = retries - 1; + log.warn({ msg: `Retrying to get Token X client. ${triesRemaining} tries remaining...` }); + tokenXInstance = null; - static async getInstance() { - if (this.instance === null) { - this.instance = getTokenXClient(); + return getTokenXClient(triesRemaining); } - return this.instance; + log.error({ error, msg: 'Failed to get Token X client' }); + throw error; } -} +}; + +export const getIsTokenXClientReady = () => isLocal || tokenXInstance !== null; diff --git a/server/src/config/config.ts b/server/src/config/config.ts index 77e817f30..6290c0372 100644 --- a/server/src/config/config.ts +++ b/server/src/config/config.ts @@ -1,18 +1,23 @@ -import path from 'path'; -import { isDeployed } from './env'; -import { requiredEnvString, requiredEnvUrl } from './env-var'; +import path from 'node:path'; +import { requiredEnvJson, requiredEnvNumber, requiredEnvString } from '@app/config/env-var'; +import type { JWK } from 'jose'; -export const slack = { - url: isDeployed ? requiredEnvUrl('SLACK_URL') : '', - channel: '#klage-notifications', - messagePrefix: `${requiredEnvString('NAIS_APP_NAME', 'klage-dittnav').toUpperCase()} frontend NodeJS -`, -}; - -export const KLAGE_DITTNAV_API_CLIENT_ID = 'klage-dittnav-api'; -export const OBO_CLIENT_IDS = [KLAGE_DITTNAV_API_CLIENT_ID]; -export const PROXIED_CLIENT_IDS = [KLAGE_DITTNAV_API_CLIENT_ID, 'klage-kodeverk-api']; +export const API_CLIENT_IDS = ['klage-dittnav-api', 'klage-kodeverk-api']; const cwd = process.cwd(); // This will be the server folder, as long as the paths in the NPM scripts are not changed. const serverDirectoryPath = cwd; const frontendDirectoryPath = path.resolve(serverDirectoryPath, '../frontend'); export const frontendDistDirectoryPath = path.resolve(frontendDirectoryPath, './dist'); + +export const NAIS_CLUSTER_NAME = requiredEnvString('NAIS_CLUSTER_NAME'); + +const isLocal = NAIS_CLUSTER_NAME === 'local'; + +const defaultValue = isLocal ? 'local' : undefined; +const localJwk: JWK = {}; + +export const TOKEN_X_CLIENT_ID = requiredEnvString('TOKEN_X_CLIENT_ID', defaultValue); +export const TOKEN_X_WELL_KNOWN_URL = requiredEnvString('TOKEN_X_WELL_KNOWN_URL', defaultValue); +export const TOKEN_X_PRIVATE_JWK = requiredEnvJson('TOKEN_X_PRIVATE_JWK', localJwk); +export const PROXY_VERSION = requiredEnvString('VERSION', defaultValue); +export const PORT = requiredEnvNumber('PORT', 8080); diff --git a/server/src/config/cors.ts b/server/src/config/cors.ts new file mode 100644 index 000000000..677bd1bb8 --- /dev/null +++ b/server/src/config/cors.ts @@ -0,0 +1,29 @@ +import { URL } from '@app/config/env'; +import type { FastifyCorsOptions } from '@fastify/cors'; + +export const corsOptions: FastifyCorsOptions = { + credentials: true, + methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE'], + allowedHeaders: [ + 'Accept-Language', + 'Accept', + 'Cache-Control', + 'Connection', + 'Content-Type', + 'Cookie', + 'DNT', + 'Host', + 'Origin', + 'Pragma', + 'Referer', + 'Sec-Fetch-Dest', + 'Sec-Fetch-Mode', + 'Sec-Fetch-Site', + 'User-Agent', + 'X-Forwarded-For', + 'X-Forwarded-Host', + 'X-Forwarded-Proto', + 'X-Requested-With', + ], + origin: URL, +}; diff --git a/server/src/config/env-var.ts b/server/src/config/env-var.ts index d7bfa8cad..c65c95137 100644 --- a/server/src/config/env-var.ts +++ b/server/src/config/env-var.ts @@ -1,8 +1,4 @@ -import { getLogger } from '@app/logger/logger'; - -const log = getLogger('env-var'); - -const optionalEnvString = (name: string): string | undefined => { +export const optionalEnvString = (name: string): string | undefined => { const envVariable = process.env[name]; if (typeof envVariable === 'string' && envVariable.length !== 0) { @@ -19,47 +15,48 @@ export const requiredEnvString = (name: string, defaultValue?: string): string = return envVariable; } - if (typeof defaultValue === 'string' && defaultValue.length !== 0) { + if (defaultValue !== undefined) { return defaultValue; } - log.error({ message: `Missing required environment variable '${name}'` }); - - process.exit(1); + throw new Error(`Missing required environment variable '${name}'.`); }; -export const requiredEnvUrl = (name: string, defaultValue?: string): string => { - const envString = requiredEnvString(name, defaultValue); +export const requiredEnvJson = (name: string, defaultValue?: T): T => { + const json = requiredEnvString(name, ''); - if (envString.startsWith('http://')) { - return envString.replace('http://', 'https://'); - } + try { + if (json.length === 0) { + if (defaultValue !== undefined) { + return defaultValue; + } - if (envString.startsWith('https://')) { - return envString; - } + throw new Error('Empty string'); + } + + return JSON.parse(json); + } catch { + if (defaultValue !== undefined) { + return defaultValue; + } - const error = new Error(`Environment variable '${name}' is not a URL. Value: '${envString}'.`); - log.error({ error }); - process.exit(1); + throw new Error(`Invalid JSON in environment variable '${name}'.`); + } }; export const requiredEnvNumber = (name: string, defaultValue?: number): number => { const envString = optionalEnvString(name); - const parsed = typeof envString === 'undefined' ? NaN : Number.parseInt(envString, 10); + const parsed = typeof envString === 'undefined' ? Number.NaN : Number.parseInt(envString, 10); if (Number.isInteger(parsed)) { return parsed; } - if (typeof defaultValue === 'number') { + if (defaultValue !== undefined) { return defaultValue; } const env = envString ?? 'undefined'; - log.error({ - message: `Could not parse environment variable '${name}' as integer/number. Parsed value: '${env}'.`, - }); - process.exit(1); + throw new Error(`Could not parse environment variable '${name}' as integer/number. Parsed value: '${env}'.`); }; diff --git a/server/src/config/env.ts b/server/src/config/env.ts index 30f2d8477..306090f5e 100644 --- a/server/src/config/env.ts +++ b/server/src/config/env.ts @@ -1,7 +1,7 @@ +import { NAIS_CLUSTER_NAME, PORT } from '@app/config/config'; import { requiredEnvString } from './env-var'; -import { serverConfig } from './server-config'; -const getEnvironmentVersion = (local: T, test: T, development: T, production: T): T => { +const getEnvironmentVersion = (local: T, development: T, production: T): T => { if (isDeployedToDev) { return development; } @@ -10,25 +10,31 @@ const getEnvironmentVersion = (local: T, test: T, development: T, production: return production; } - if (isTesting) { - return test; - } - return local; }; -const isDeployedToDev = serverConfig.cluster === 'dev-gcp'; -export const isDeployedToProd = serverConfig.cluster === 'prod-gcp'; +export const isDeployedToDev = NAIS_CLUSTER_NAME === 'dev-gcp'; +export const isDeployedToProd = NAIS_CLUSTER_NAME === 'prod-gcp'; export const isDeployed = isDeployedToDev || isDeployedToProd; -export const isTesting = requiredEnvString('NODE_ENV', 'unknown') === 'test'; +export const isLocal = !isDeployed; + +export const ENVIRONMENT = getEnvironmentVersion('local-bff', 'development', 'production'); + +const LOCAL_DOMAIN = `localhost:${PORT}`; +const LOCAL_URL = `http://${LOCAL_DOMAIN}`; -export const ENVIRONMENT = getEnvironmentVersion('local', 'test', 'development', 'production'); +export const DEV_DOMAIN = 'klage.intern.dev.nav.no'; +export const DEV_URL = `https://${DEV_DOMAIN}`; -export const DOMAIN: string = getEnvironmentVersion( - `http://localhost:${serverConfig.port}`, - `http://localhost:${serverConfig.port}`, - 'https://klage.intern.dev.nav.no', - 'https://klage.nav.no', -); +const PROD_DOMAIN = 'klage.intern.nav.no'; +const PROD_URL = `https://${PROD_DOMAIN}`; + +export const URL: string = getEnvironmentVersion(LOCAL_URL, DEV_URL, PROD_URL); export const NAIS_NAMESPACE = requiredEnvString('NAIS_NAMESPACE', 'none'); + +export const POD_NAME = requiredEnvString('OTEL_RESOURCE_ATTRIBUTES_POD_NAME', 'none'); + +export const YTELSE_OVERVIEW_URL = isDeployedToProd + ? 'https://www.nav.no/klage' + : 'https://www.ekstern.dev.nav.no/klage'; diff --git a/server/src/config/server-config.ts b/server/src/config/server-config.ts deleted file mode 100644 index 9b4cfd159..000000000 --- a/server/src/config/server-config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { requiredEnvNumber, requiredEnvString } from './env-var'; - -export const serverConfig = { - // should be equivalent to the URL this application is hosted on for correct CORS origin header. - host: requiredEnvString('HOST', '0.0.0.0'), - cluster: requiredEnvString('NAIS_CLUSTER_NAME', 'none'), - // Port for your application. - port: requiredEnvNumber('PORT', 8080), -}; diff --git a/server/src/config/version.ts b/server/src/config/version.ts index 4a78a9b01..cc01c2dcd 100644 --- a/server/src/config/version.ts +++ b/server/src/config/version.ts @@ -1,15 +1,11 @@ -import { isDeployed, isTesting } from './env'; -import { requiredEnvString } from './env-var'; +import { isDeployed } from '@app/config/env'; +import { requiredEnvString } from '@app/config/env-var'; const getDefaultVersion = () => { if (isDeployed) { return undefined; } - if (isTesting) { - return 'test'; - } - return 'local'; }; diff --git a/server/src/functions/get-cookie.ts b/server/src/functions/get-cookie.ts deleted file mode 100644 index dc0d3313b..000000000 --- a/server/src/functions/get-cookie.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { parse } from 'cookie'; -import { Request } from '@app/types/http'; - -export const getCookie = (req: Request, name: string): string | undefined => { - const cookieHeader = req.header('cookie'); - - if (cookieHeader === undefined) { - return undefined; - } - - const cookies = parse(cookieHeader); - - const value = cookies[name]; - - if (value === undefined || value.length === 0) { - return undefined; - } - - return value; -}; diff --git a/server/src/functions/parse-json.ts b/server/src/functions/parse-json.ts deleted file mode 100644 index f24347054..000000000 --- a/server/src/functions/parse-json.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { getLogger } from '@app/logger/logger'; - -const log = getLogger('parse-json'); - -export const parseJSON = (json: string): T | null => { - try { - return JSON.parse(json); - } catch (error) { - log.warn({ message: 'Failed to parse JSON', data: json, error }); - - return null; - } -}; diff --git a/server/src/functions/set-obo.ts b/server/src/functions/set-obo.ts deleted file mode 100644 index 8f9f0f92b..000000000 --- a/server/src/functions/set-obo.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { getOnBehalfOfAccessToken } from '@app/auth/on-behalf-of'; -import { TokenXClient } from '@app/auth/token-x-client'; -import { getLogger } from '@app/logger/logger'; -import { Request } from '@app/types/http'; - -const log = getLogger('obo-token'); - -export const setOboToken = async (req: Request, appName: string) => { - const authClient = await TokenXClient.getInstance(); - const tokenXtoken = req.header('Authorization'); - - if (typeof tokenXtoken === 'string') { - try { - const obo_access_token = await getOnBehalfOfAccessToken(authClient, tokenXtoken, appName); - req.headers['authorization'] = `Bearer ${obo_access_token}`; - req.headers['idporten-token'] = tokenXtoken; - } catch (error) { - log.warn({ - message: `Failed to prepare request with OBO token for route ${appName}`, - error, - data: { appName }, - }); - } - } -}; diff --git a/server/src/headers.ts b/server/src/headers.ts new file mode 100644 index 000000000..036f1a8a4 --- /dev/null +++ b/server/src/headers.ts @@ -0,0 +1,4 @@ +export const CLIENT_VERSION_HEADER = 'x-client-version'; +export const PROXY_VERSION_HEADER = 'x-proxy-version'; +export const TOKEN_X_TOKEN_HEADER = 'token-x-token'; +export const AUTHORIZATION_HEADER = 'authorization'; diff --git a/server/src/helpers/duration.test.ts b/server/src/helpers/duration.test.ts new file mode 100644 index 000000000..50d45544f --- /dev/null +++ b/server/src/helpers/duration.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'bun:test'; +import { formatDuration } from '@app/helpers/duration'; + +describe('format duration', () => { + it('should format 12.123 ms as 12 ms', () => { + expect.assertions(1); + const actual = formatDuration(12.123); + const expected = '12ms'; + expect(actual).toBe(expected); + }); + + it('should format 500 ms as 500 ms', () => { + expect.assertions(1); + const actual = formatDuration(500); + const expected = '500ms'; + expect(actual).toBe(expected); + }); + + it('should format 1_000 ms as 1 second', () => { + expect.assertions(1); + const actual = formatDuration(1_000); + const expected = '1.000s'; + expect(actual).toBe(expected); + }); + + it('should format 10_000 ms as 10 seconds', () => { + expect.assertions(1); + const actual = formatDuration(10_000); + const expected = '10.000s'; + expect(actual).toBe(expected); + }); + + it('should format 60_000 ms as 1 minute', () => { + expect.assertions(1); + const actual = formatDuration(60_000); + const expected = '1m0.000s'; + expect(actual).toBe(expected); + }); + + it('should format 600_000 ms as 10 minute', () => { + expect.assertions(1); + const actual = formatDuration(600_000); + const expected = '10m0.000s'; + expect(actual).toBe(expected); + }); + + it('should format 6_000_000 ms as 1 hour, 40 minutes and 0 seconds', () => { + expect.assertions(1); + const actual = formatDuration(6_000_000); + const expected = '1h40m0.000s'; + expect(actual).toBe(expected); + }); + + it('should format 6_000_750 ms as 1 hour, 40 minutes and 0.750 second', () => { + expect.assertions(1); + const actual = formatDuration(6_000_750); + const expected = '1h40m0.750s'; + expect(actual).toBe(expected); + }); + + it('should format 6_750_750 ms as 1 hour, 40 minutes and 30.750 second', () => { + expect.assertions(1); + const actual = formatDuration(6_750_750); + const expected = '1h52m30.750s'; + expect(actual).toBe(expected); + }); +}); diff --git a/server/src/helpers/duration.ts b/server/src/helpers/duration.ts new file mode 100644 index 000000000..af251a767 --- /dev/null +++ b/server/src/helpers/duration.ts @@ -0,0 +1,25 @@ +export const getDuration = (start: number) => Math.round(performance.now() - start); + +export const formatDuration = (ms: number): string => { + if (ms < 1_000) { + return `${ms.toFixed(0)}ms`; + } + + if (ms < 60_000) { + return `${(ms / 1_000).toFixed(3)}s`; + } + + const hours = Math.floor(ms / 3_600_000); + const minutes = Math.floor((ms % 3_600_000) / 60_000); + const seconds = ((ms % 60_000) / 1_000).toFixed(3); + + if (hours === 0) { + if (minutes === 0) { + return `${seconds}s`; + } + + return `${minutes}m${seconds}s`; + } + + return `${hours}h${minutes}m${seconds}s`; +}; diff --git a/server/src/helpers/get-header-query.ts b/server/src/helpers/get-header-query.ts new file mode 100644 index 000000000..a520ca45c --- /dev/null +++ b/server/src/helpers/get-header-query.ts @@ -0,0 +1,23 @@ +import type { FastifyRequest } from 'fastify'; + +export const CLIENT_VERSION_QUERY = 'version'; + +export const getHeaderOrQueryValue = ( + req: FastifyRequest<{ Querystring: Record }>, + headerKey: string, + queryKey: string, +): string | undefined => { + const header = req.headers[headerKey]; + + if (typeof header === 'string' && header.length !== 0) { + return header; + } + + const query = req.query[queryKey]; + + if (typeof query === 'string' && query.length !== 0) { + return query; + } + + return undefined; +}; diff --git a/server/src/helpers/prepare-request-headers.ts b/server/src/helpers/prepare-request-headers.ts new file mode 100644 index 000000000..8ec9d5fc3 --- /dev/null +++ b/server/src/helpers/prepare-request-headers.ts @@ -0,0 +1,54 @@ +import { PROXY_VERSION } from '@app/config/config'; +import { DEV_DOMAIN, isDeployed } from '@app/config/env'; +import { AUTHORIZATION_HEADER, CLIENT_VERSION_HEADER, PROXY_VERSION_HEADER, TOKEN_X_TOKEN_HEADER } from '@app/headers'; +import { getLogger } from '@app/logger'; + +import type { FastifyRequest, RawServerBase, RequestGenericInterface } from 'fastify'; + +const log = getLogger('prepare-proxy-request-headers'); + +export const getProxyRequestHeaders = ( + req: FastifyRequest, + appName: string, +): Record => { + const { traceparent, client_version, accessToken, trace_id, span_id } = req; + + const headers: Record = { + ...omit(req.raw.headers, 'set-cookie'), + host: isDeployed ? appName : DEV_DOMAIN, + traceparent, + [PROXY_VERSION_HEADER]: PROXY_VERSION, + }; + + if (exists(client_version)) { + headers[CLIENT_VERSION_HEADER] = client_version; + } + + if (exists(accessToken)) { + headers[TOKEN_X_TOKEN_HEADER] = accessToken; + } + + const oboAccessToken = req.getCachedOboAccessToken(appName); + + if (oboAccessToken !== undefined) { + headers[AUTHORIZATION_HEADER] = `Bearer ${oboAccessToken}`; + headers['idporten-token'] = req.accessToken; + } + + log.info({ + msg: 'Prepared proxy request headers', + trace_id, + span_id, + data: { contentType: headers['content-type'], contentLength: headers['content-length'] }, + }); + + return headers; +}; + +const exists = (value: string): boolean => value.length !== 0; + +const omit = , K extends keyof T>(obj: T, key: K): Omit => { + const { [key]: _, ...rest } = obj; + + return rest; +}; diff --git a/server/src/helpers/query-parser.ts b/server/src/helpers/query-parser.ts new file mode 100644 index 000000000..14465aa14 --- /dev/null +++ b/server/src/helpers/query-parser.ts @@ -0,0 +1,17 @@ +/** + * Parse a query string into an object. + * @param query `foo=bar&baz=abc,123` + * @returns `{ foo: 'bar', baz: 'abc,123' }` + */ +export const querystringParser = (query: string): Record => + query.split('&').reduce>((acc, q) => { + const [key, value] = q.split('='); + + if (key === undefined || value === undefined) { + return acc; + } + + acc[key] = value; + + return acc; + }, {}); diff --git a/server/src/helpers/traceparent.ts b/server/src/helpers/traceparent.ts new file mode 100644 index 000000000..9a6374911 --- /dev/null +++ b/server/src/helpers/traceparent.ts @@ -0,0 +1,36 @@ +import { randomBytes } from 'node:crypto'; +import { getLogger } from '@app/logger'; + +const log = getLogger('traceparent'); + +const TRACE_VERSION = '00'; +const TRACE_FLAGS = '00'; + +/** Generates a traceparent ID according to https://www.w3.org/TR/trace-context/#version-format + * `span_id` is referred to as `parent_id` in the spec. + */ +export const generateTraceparent = (trace_id: string = generateTraceId(), span_id: string = generateSpanId()): string => + `${TRACE_VERSION}-${trace_id}-${span_id}-${TRACE_FLAGS}`; + +export const generateSpanId = (): string => randomBytes(8).toString('hex'); +export const generateTraceId = (): string => randomBytes(16).toString('hex'); + +/** Parses traceId from traceparent ID according to https://www.w3.org/TR/trace-context/#version-format */ +export const getTraceIdAndSpanIdFromTraceparent = ( + traceparent: string, + clientVersion: string | undefined, +): { trace_id: string | undefined; span_id: string | undefined } => { + const [version, trace_id, span_id] = traceparent.split('-'); + + if (version !== TRACE_VERSION) { + log.warn({ + msg: `Invalid traceparent version: ${version}`, + data: { traceparent }, + trace_id, + span_id, + client_version: clientVersion, + }); + } + + return { trace_id, span_id }; +}; diff --git a/server/src/index-file.ts b/server/src/index-file.ts new file mode 100644 index 000000000..becba791b --- /dev/null +++ b/server/src/index-file.ts @@ -0,0 +1,84 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { frontendDistDirectoryPath } from '@app/config/config'; +import { ENVIRONMENT, isDeployedToDev, isDeployedToProd } from '@app/config/env'; +import { VERSION } from '@app/config/version'; +import { getLogger } from '@app/logger'; +import { fetchDecoratorHtml } from '@app/nav-dekoratoren/nav-dekoratoren'; +import type { DecoratorEnvProps } from '@app/nav-dekoratoren/types'; +import { EmojiIcons, sendToSlack } from '@app/slack'; + +const log = getLogger('index-file'); + +class IndexFile { + private readonly INDEX_TEMPLATE = readFileSync(path.join(frontendDistDirectoryPath, 'index.html'), 'utf-8'); + + #isReady = false; + + public get isReady() { + return this.#isReady; + } + + #indexHtml = ''; + + public get indexFile() { + return this.#indexHtml; + } + + constructor() { + this.generateFile(); + setInterval(this.generateFile, 60 * 1000); + } + + private getEnv = (): DecoratorEnvProps => { + if (isDeployedToProd) { + return { env: 'prod', serviceDiscovery: true }; + } + + return { env: 'dev', serviceDiscovery: isDeployedToDev }; + }; + + private generateFile = async (): Promise => { + try { + const start = performance.now(); + + const { DECORATOR_SCRIPTS, DECORATOR_STYLES, DECORATOR_HEADER, DECORATOR_FOOTER } = await fetchDecoratorHtml({ + ...this.getEnv(), + params: { + simple: true, + chatbot: true, + redirectToApp: true, + logoutUrl: '/oauth2/logout', + context: 'privatperson', + level: 'Level4', + logoutWarning: true, + }, + }); + + const end = performance.now(); + + this.#indexHtml = this.INDEX_TEMPLATE.replace('{{DECORATOR_SCRIPTS}}', DECORATOR_SCRIPTS) + .replace('{{DECORATOR_STYLES}}', DECORATOR_STYLES) + .replace('{{DECORATOR_HEADER}}', DECORATOR_HEADER) + .replace('{{DECORATOR_FOOTER}}', DECORATOR_FOOTER) + .replace('{{ENVIRONMENT}}', ENVIRONMENT) + .replace('{{VERSION}}', VERSION); + + this.#isReady = true; + + log.debug({ + msg: 'Successfully updated index.html with Dekoratøren and variables.', + data: { responseTime: Math.round(end - start) }, + }); + } catch (error) { + log.error({ error, msg: 'Failed to update index.html with Dekoratøren and variables' }); + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + sendToSlack(`Error when generating index file: ${errorMessage}`, EmojiIcons.Scream); + } + + return this.#indexHtml; + }; +} + +export const indexFile = new IndexFile(); diff --git a/server/src/init.ts b/server/src/init.ts index b450ba5fe..30a3b29b0 100644 --- a/server/src/init.ts +++ b/server/src/init.ts @@ -1,52 +1,30 @@ -import { Express, static as expressStatic } from 'express'; -import { frontendDistDirectoryPath } from '@app/config/config'; -import { redirectMiddleware } from '@app/middleware/redirect/redirect'; -import { sessionIdMiddleware } from '@app/middleware/session-id'; -import { frontendLog } from '@app/routes/frontend-log'; -import { serverConfig } from './config/server-config'; -import { getLogger } from './logger/logger'; -import { appHandler } from './routes/app-handler'; -import { errorReporter } from './routes/error-report'; -import { setupProxy } from './routes/setup-proxy'; -import { EmojiIcons, sendToSlack } from './slack'; +import { isDeployed } from '@app/config/env'; +import { formatDuration, getDuration } from '@app/helpers/duration'; +import { getLogger } from '@app/logger'; +import { EmojiIcons, sendToSlack } from '@app/slack'; +import { getTokenXClient } from './auth/token-x-client'; const log = getLogger('init'); -const PORT = serverConfig.port; - -export const init = async (server: Express) => { +export const init = async () => { try { - server.get('/loggedin-redirect', (req, res) => { - const { redirect } = req.query; + if (isDeployed) { + const start = performance.now(); + + await getTokenXClient(); - if (typeof redirect === 'string' && redirect.length !== 0) { - res.redirect(`/oauth2/login?redirect=${redirect}`); - } else { - res.redirect('/oauth2/login?redirect=/'); - } - }); - server.use(frontendLog()); - server.use(errorReporter()); - server.use(await setupProxy()); - server.get('/favicon.ico', (req, _, next) => { - req.url = '/favicon/favicon.ico'; - next(); - }); - server.use(expressStatic(frontendDistDirectoryPath, { index: false, fallthrough: true })); - server.use(sessionIdMiddleware); - server.use(redirectMiddleware); - server.get('*', appHandler); - server.listen(PORT, () => log.info({ message: `Listening on port ${PORT}` })); + const time = getDuration(start); + log.info({ msg: `TokenX client initialized in ${formatDuration(time)}`, data: { time } }); + } } catch (e) { if (e instanceof Error) { - log.error({ error: e, message: 'Server crashed' }); - await sendToSlack(`Server crashed: ${e.message}`, EmojiIcons.Scream); + log.error({ error: e, msg: 'Server crashed' }); + await sendToSlack(`Server crashed: ${e.message}`, EmojiIcons.Broken); } else if (typeof e === 'string' || typeof e === 'number') { - const message = `Server crashed: ${JSON.stringify(e)}`; - log.error({ message }); - await sendToSlack(message, EmojiIcons.Scream); + const msg = `Server crashed: ${JSON.stringify(e)}`; + log.error({ msg }); + await sendToSlack(msg, EmojiIcons.Broken); } - process.exit(1); } }; diff --git a/server/src/innsendingsytelser.ts b/server/src/innsendingsytelser.ts index 0ee5d99c8..76e61a7b9 100644 --- a/server/src/innsendingsytelser.ts +++ b/server/src/innsendingsytelser.ts @@ -96,12 +96,4 @@ enum Innsendingsytelse { YTELSER_TIL_TIDLIGERE_FAMILIEPLEIERE = 'YTELSER_TIL_TIDLIGERE_FAMILIEPLEIERE', } -const INNSENDINGSYTELSER = Object.values(Innsendingsytelse); - -export const isInnsendingsytelse = (value: string | null | undefined): value is Innsendingsytelse => { - if (value === null || value === undefined) { - return false; - } - - return INNSENDINGSYTELSER.some((innsendingsytelse) => innsendingsytelse === value); -}; +export const INNSENDINGSYTELSER = Object.values(Innsendingsytelse); diff --git a/server/src/logger.ts b/server/src/logger.ts new file mode 100644 index 000000000..f8ebb6719 --- /dev/null +++ b/server/src/logger.ts @@ -0,0 +1,143 @@ +import { isDeployed } from './config/env'; + +const VERSION = process.env.VERSION ?? 'unknown'; + +const LOGGERS: Map = new Map(); + +type SerializableValue = + | string + | number + | boolean + | string[] + | number[] + | boolean[] + | null + | null[] + | undefined + | undefined[] + | AnyObject + | AnyObject[]; + +export const isSerializable = (value: unknown): value is SerializableValue => { + return ( + typeof value === 'number' || + typeof value === 'string' || + typeof value === 'boolean' || + Array.isArray(value) || + value === null || + typeof value === 'object' + ); +}; + +export interface AnyObject { + [key: string]: SerializableValue; +} + +type LogArgs = + | { + msg?: string; + trace_id?: string; + span_id?: string; + client_version?: string; + tab_id?: string; + error: Error | unknown; + data?: SerializableValue; + } + | { + msg: string; + trace_id?: string; + span_id?: string; + client_version?: string; + tab_id?: string; + error?: Error | unknown; + data?: SerializableValue; + }; + +interface Logger { + debug: (args: LogArgs) => void; + info: (args: LogArgs) => void; + warn: (args: LogArgs) => void; + error: (args: LogArgs) => void; +} + +interface Log extends AnyObject { + '@timestamp': string; + trace_id?: string; + span_id?: string; + proxy_version: string; + client_version?: string; + module: string; + message?: string; + stacktrace?: string; +} + +type Level = 'debug' | 'info' | 'warn' | 'error'; + +export const getLogger = (module: string): Logger => { + const cachedLogger = LOGGERS.get(module); + + if (typeof cachedLogger !== 'undefined') { + return cachedLogger; + } + + const logger: Logger = { + debug: (args) => logDefined(getLog(module, 'debug', args), 'debug'), + info: (args) => logDefined(getLog(module, 'info', args), 'info'), + warn: (args) => logDefined(getLog(module, 'warn', args), 'warn'), + error: (args) => logDefined(getLog(module, 'error', args), 'error'), + }; + + LOGGERS.set(module, logger); + + return logger; +}; + +const logDefined = (message: string | undefined, level: Level): void => { + if (message === undefined) { + return; + } + + // eslint-disable-next-line no-console + console[level](message); +}; + +const getLog = ( + module: string, + level: Level, + { msg, trace_id, span_id, client_version, tab_id, error, data }: LogArgs, +): string | undefined => { + const log: Log = { + ...(typeof data === 'object' && data !== null && !Array.isArray(data) ? data : { data }), + level, + '@timestamp': new Date().toISOString(), + proxy_version: VERSION, + client_version, + module, + tab_id, + trace_id, + span_id, + }; + + if (error instanceof Error) { + log.stacktrace = error.stack; + log.message = typeof msg === 'string' ? `${msg} - ${error.name}: ${error.message}` : error.message; + } else { + log.message = msg; + } + + if (isDeployed) { + return JSON.stringify(log); + } + + if ( + module === 'http' || + module === 'version' || + module === 'api-proxy' || + module === 'obo-token-plugin' || + module === 'prepare-proxy-request-headers' + ) { + return; + } + + return msg; +}; diff --git a/server/src/logger/logger.ts b/server/src/logger/logger.ts deleted file mode 100644 index 891d69312..000000000 --- a/server/src/logger/logger.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { performance } from 'perf_hooks'; -import { RequestHandler } from 'express'; - -const VERSION = process.env['VERSION'] ?? 'unknown'; - -const LOGGERS: Map = new Map(); - -type SerializableValue = - | string - | number - | boolean - | string[] - | number[] - | boolean[] - | null - | null[] - | undefined - | undefined[]; - -export interface SerializableObject { - [key: string]: SerializableValue; -} - -interface MessageLog { - message: string; - error?: Error | unknown; - data?: SerializableValue | SerializableObject; -} - -interface ErrorLog { - message?: string; - error: Error | unknown; - data?: SerializableValue | SerializableObject; -} - -type ServerLog = MessageLog | ErrorLog; - -interface Logger { - debug: (args: ServerLog) => void; - info: (args: ServerLog) => void; - warn: (args: ServerLog) => void; - error: (args: ServerLog) => void; -} - -interface Log extends SerializableObject { - '@timestamp': string; - version: string; - module: string; - message?: string; - stacktrace?: string; -} - -export enum Level { - DEBUG = 'debug', - INFO = 'info', - WARN = 'warn', - ERROR = 'error', -} - -export const getLogger = (moduleName: string): Logger => { - const cachedLogger = LOGGERS.get(moduleName); - - if (cachedLogger !== undefined) { - return cachedLogger; - } - - const logger: Logger = { - debug: (args) => console.debug(getLog(moduleName, Level.DEBUG, args)), - info: (args) => console.info(getLog(moduleName, Level.INFO, args)), - warn: (args) => console.warn(getLog(moduleName, Level.WARN, args)), - error: (args) => console.warn(getLog(moduleName, Level.ERROR, args)), - }; - - LOGGERS.set(moduleName, logger); - - return logger; -}; - -const getLog = (module: string, level: Level, { message, error, data }: ServerLog) => { - const log: Log = { - level, - '@timestamp': new Date().toISOString(), - version: VERSION, - module, - }; - - if (typeof data === 'object' && data !== null) { - if (Array.isArray(data)) { - log['data'] = JSON.stringify(data, null, 2); - } else { - Object.entries(data).forEach(([key, value]) => { - if (typeof value !== 'object' && value !== null) { - log[key] = value; - } else { - log[key] = JSON.stringify(value, null, 2); - } - }); - } - } else { - log['data'] = data; - } - - if (error instanceof Error) { - log.stacktrace = error.stack; - log.message = typeof message === 'string' ? `${message} - ${error.name}: ${error.message}` : error.message; - } else { - log.message = message; - } - - return JSON.stringify(log); -}; - -const httpLogger = getLogger('http'); - -export const httpLoggingMiddleware: RequestHandler = (req, res, next) => { - const start = performance.now(); - - res.once('finish', () => { - const { method, url } = req; - const referrer = req.header('referrer'); - const userAgent = req.header('user-agent'); - - if (url.endsWith('/isAlive') || url.endsWith('/isReady')) { - return; - } - - const { statusCode } = res; - - const responseTime = Math.round(performance.now() - start); - - logHttpRequest({ - method, - url, - statusCode, - referrer, - userAgent, - responseTime, - }); - }); - - next(); -}; - -interface HttpData extends SerializableObject { - method: string; - url: string; - statusCode: number; - responseTime: number; - referrer?: string; - userAgent?: string; -} - -const logHttpRequest = (data: HttpData) => { - const message = `${data.statusCode} ${data.method} ${data.url}`; - - if (data.statusCode >= 500) { - httpLogger.error({ message, data }); - - return; - } - - if (data.statusCode >= 400) { - httpLogger.warn({ message, data }); - - return; - } - - httpLogger.debug({ message, data }); -}; diff --git a/server/src/logger/types.ts b/server/src/logger/types.ts deleted file mode 100644 index 529e9d6ee..000000000 --- a/server/src/logger/types.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { Level } from '@app/logger/logger'; - -export enum FrontendEventTypes { - NAVIGATION = 'navigation', - APP = 'app', - ERROR = 'error', - API = 'api', - SESSION = 'session', -} - -interface BaseEventData { - session_id: string; - level: Level; - /** Current client Unix timestamp in milliseconds. */ - client_timestamp: number; - /** Milliseconds until the token expires. */ - token_expires?: number; - /** If the user is logged in. */ - is_logged_in: boolean; - /** Current client version. */ - client_version: string; - /** Milliseconds since start of session. */ - session_time: number; - /** Formatted time since start of session. - * @example `1:35:45.987` - */ - session_time_formatted: string; - /** Current path. - * @example `/nb/klage/1234/begrunnelse` - * */ - route: string; - message: string; -} - -interface NavigationEvent extends BaseEventData { - type: FrontendEventTypes.NAVIGATION; -} - -interface AppEvent extends BaseEventData { - type: FrontendEventTypes.APP; - action: string; -} - -interface ErrorEvent extends BaseEventData { - type: FrontendEventTypes.ERROR; - message: string; - stack?: string; -} - -interface ApiEvent extends BaseEventData { - type: FrontendEventTypes.API; - request: string; - response_time: number; - status: number | string; -} - -enum SessionAction { - /** Load session case */ - LOAD = 'load', - /** Create session case */ - CREATE = 'create', - /** Load or create session case */ - LOAD_OR_CREATE = 'load-create', - /** Delete session case */ - DELETE = 'delete', - /** Set session case */ - SET = 'set', - /** Update session case */ - UPDATE = 'update', -} - -interface SessionEvent extends BaseEventData { - type: FrontendEventTypes.SESSION; - message: string; - action: SessionAction; -} - -export type FrontendLogEvent = NavigationEvent | AppEvent | ErrorEvent | ApiEvent | SessionEvent; diff --git a/server/src/middleware/redirect/counters.ts b/server/src/middleware/redirect/counters.ts deleted file mode 100644 index da27ed8c0..000000000 --- a/server/src/middleware/redirect/counters.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Counter, LabelValues } from 'prom-client'; -import { registers } from '@app/prometheus/types'; - -const viewCountLabels = ['url', 'has_saksnummer', 'redirected_from', 'referrer'] as const; - -export const viewCountCounter = new Counter({ - name: 'view_count', - help: 'Number of views.', - labelNames: viewCountLabels, - registers, -}); - -const externalRedirectLabels = ['url', 'has_saksnummer', 'redirected_from', 'referrer'] as const; -export type ExternalRedirectLabels = LabelValues<(typeof externalRedirectLabels)[number]>; - -export const externalRedirectCounter = new Counter({ - name: 'external_redirect', - help: 'Number of redirects to nav.no/klage.', - labelNames: externalRedirectLabels, - registers, -}); - -const intternalRedirectLabels = ['url', 'redirected_to', 'redirected_from', 'has_saksnummer', 'referrer'] as const; -export type InternalRedirectLabels = LabelValues<(typeof intternalRedirectLabels)[number]>; - -export const internalRedirectCounter = new Counter({ - name: 'internal_redirect', - help: 'Number of internal redirects from deprecated URLs.', - labelNames: intternalRedirectLabels, - registers, -}); diff --git a/server/src/middleware/redirect/functions.ts b/server/src/middleware/redirect/functions.ts deleted file mode 100644 index aa45220d4..000000000 --- a/server/src/middleware/redirect/functions.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { getCookie } from '@app/functions/get-cookie'; -import { Request, Response } from '@app/types/http'; - -const EXPIRED = new Date(0); - -export const deleteRedirectedCookie = (res: Response) => - res.cookie('redirected_from', '', { maxAge: 0, expires: EXPIRED, httpOnly: true, sameSite: 'strict' }); - -export const getRedirectedFrom = (req: Request): string | undefined => getCookie(req, 'redirected_from'); - -export const getHasSaksnummer = (req: Request) => (getSaksnummer(req) !== null ? 'true' : 'false'); - -export const removeSaksnummer = (url: string | undefined) => { - if (url === undefined) { - return undefined; - } - const [path, query] = url.split('?'); - - if (query === undefined) { - return path; - } - - const params = query.split('&'); - const filtered = params.filter((param) => !param.startsWith('saksnummer=')); - - if (filtered.length === 0) { - return path; - } - - return `${path}?${filtered.join('&')}`; -}; - -export const getSaksnummer = (req: Request): string | null => getQueryParam(req, 'saksnummer'); - -const getQueryParam = (req: Request, param: string) => { - const queryParam = req.query[param]; - - if (typeof queryParam === 'string' && queryParam.length !== 0) { - return encodeURIComponent(queryParam); - } - - if (Array.isArray(queryParam)) { - const [first] = queryParam; - - if (typeof first === 'string' && first.length !== 0) { - return encodeURIComponent(first); - } - } - - return null; -}; - -export const getReferrer = (req: Request) => req.header('referer') ?? 'UNKNOWN'; diff --git a/server/src/middleware/redirect/guards.ts b/server/src/middleware/redirect/guards.ts deleted file mode 100644 index 2a15d5804..000000000 --- a/server/src/middleware/redirect/guards.ts +++ /dev/null @@ -1,27 +0,0 @@ -export const isSak = (sak: string | undefined): sak is 'sak' => sak === 'sak'; - -export const isAnonymousStep = (step: string | undefined): step is 'begrunnelse' | 'oppsummering' | 'innsending' => { - if (step === undefined) { - return false; - } - - return step === 'begrunnelse' || step === 'oppsummering' || step === 'innsending'; -}; - -export const isLoggedInStep = ( - step: string | undefined, -): step is 'begrunnelse' | 'oppsummering' | 'innsending' | 'kvittering' => { - if (step === undefined) { - return false; - } - - return isAnonymousStep(step) || step === 'kvittering'; -}; - -export const isLang = (lang: string | undefined): lang is 'nb' | 'nn' | 'en' => { - if (lang === undefined) { - return false; - } - - return lang === 'nb' || lang === 'nn' || lang === 'en'; -}; diff --git a/server/src/middleware/redirect/is-authenticated.ts b/server/src/middleware/redirect/is-authenticated.ts deleted file mode 100644 index 22c8fa3f1..000000000 --- a/server/src/middleware/redirect/is-authenticated.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { KLAGE_DITTNAV_API_CLIENT_ID } from '@app/config/config'; -import { setOboToken } from '@app/functions/set-obo'; -import { getLogger } from '@app/logger/logger'; -import { noRedirect } from '@app/middleware/redirect/redirect-functions'; -import { Request, Response } from '@app/types/http'; - -const log = getLogger('auth-check'); - -const AUTH_URL = 'http://klage-dittnav-api/api/bruker/authenticated'; - -interface Authenticated { - authenticated: boolean; - tokenx: boolean; - selvbetjening: boolean; -} - -export const ensureAuthentication = async (req: Request, res: Response, next: () => void): Promise => { - // Skipping auth check during test run. Assuming user is not authenticated. - if (process.env.NODE_ENV === 'test') { - return redirectToLogin(req, res); - } - - try { - await setOboToken(req, KLAGE_DITTNAV_API_CLIENT_ID); - - const headers = new Headers(); - - for (const [key, value] of Object.entries(req.headers)) { - headers.append(key, value as string); - } - - const response = await fetch(AUTH_URL, { headers, method: 'GET' }); - - if (!response.ok) { - log.warn({ - message: `Auth service responded with error status (${response.status}). Assuming user is not authenticated.`, - data: { status: response.status, url: req.url, authenticated: false }, - }); - - redirectToLogin(req, res); - - return; - } - - const auth = (await response.json()) as Authenticated; - - if (!auth.authenticated) { - log.warn({ - message: 'User is not authenticated', - data: { status: response.status, url: req.url, ...auth }, - }); - - redirectToLogin(req, res); - - return; - } - - log.debug({ message: 'User is authenticated', data: { url: req.url, ...auth } }); - } catch (error) { - log.error({ - error, - message: 'Could not reach auth service. Failed to check if user is authenticated.', - data: { url: req.url, authenticated: false }, - }); - - redirectToLogin(req, res); - - return; - } - - return noRedirect(req, res, next); -}; - -const redirectToLogin = (req: Request, res: Response): void => { - const redirectAfter = encodeURIComponent(req.url); - - res.redirect(302, `/oauth2/login?redirect=${redirectAfter}`); -}; diff --git a/server/src/middleware/redirect/logger.ts b/server/src/middleware/redirect/logger.ts deleted file mode 100644 index 142155149..000000000 --- a/server/src/middleware/redirect/logger.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { getLogger } from '@app/logger/logger'; - -export const log = getLogger('redirect-middleware'); diff --git a/server/src/middleware/redirect/redirect-functions.ts b/server/src/middleware/redirect/redirect-functions.ts deleted file mode 100644 index 8f0d1351c..000000000 --- a/server/src/middleware/redirect/redirect-functions.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { isDeployedToProd } from '@app/config/env'; -import { - ExternalRedirectLabels, - InternalRedirectLabels, - externalRedirectCounter, - internalRedirectCounter, - viewCountCounter, -} from '@app/middleware/redirect/counters'; -import { - deleteRedirectedCookie, - getHasSaksnummer, - getRedirectedFrom, - getReferrer, - getSaksnummer, - removeSaksnummer, -} from '@app/middleware/redirect/functions'; -import { log } from '@app/middleware/redirect/logger'; -import { Request, Response } from '@app/types/http'; - -const YTELSE_OVERVIEW_URL = isDeployedToProd ? 'https://www.nav.no/klage' : 'https://www.ekstern.dev.nav.no/klage'; - -export const redirectToExternalKlagePage = (req: Request, res: Response) => { - const has_saksnummer = getHasSaksnummer(req); - const redirected_from = getRedirectedFrom(req); - - const shared: ExternalRedirectLabels = { - has_saksnummer, - referrer: getReferrer(req), - }; - - log.warn({ - message: `Invalid URL. Redirecting to external URL ${YTELSE_OVERVIEW_URL}`, - data: { ...shared, url: req.url, redirected_from, reason: 'invalid', session_id: res.locals['sessionId'] }, - }); - - externalRedirectCounter.inc({ - ...shared, - url: removeSaksnummer(req.url), - redirected_from: removeSaksnummer(redirected_from), - }); - - deleteRedirectedCookie(res); - res.redirect(301, YTELSE_OVERVIEW_URL); -}; - -export const redirectToInternalPage = (req: Request, res: Response, path: string) => { - const saksnummer = getSaksnummer(req); - const path_with_saksnummer = saksnummer === null ? path : `${path}?saksnummer=${saksnummer}`; - const redirected_from = getRedirectedFrom(req); - - const shared: InternalRedirectLabels = { - referrer: getReferrer(req), - has_saksnummer: saksnummer !== null ? 'true' : 'false', - }; - - log.warn({ - message: `Redirecting to internal path ${path_with_saksnummer}`, - data: { - ...shared, - url: req.url, - redirect_to: path_with_saksnummer, - redirected_from, - reason: 'deprecated', - session_id: res.locals['sessionId'], - }, - }); - - internalRedirectCounter.inc({ - ...shared, - url: removeSaksnummer(req.url), - redirected_to: path, - redirected_from: removeSaksnummer(redirected_from), - }); - - res.cookie('redirected_from', req.url, { httpOnly: true, sameSite: 'strict' }); - res.redirect(301, path_with_saksnummer); -}; - -export const noRedirect = (req: Request, res: Response, next: () => void) => { - const redirected_from = getRedirectedFrom(req); - const referrer = getReferrer(req); - - viewCountCounter.inc({ - url: removeSaksnummer(req.url), - redirected_from: removeSaksnummer(redirected_from), - referrer: removeSaksnummer(referrer), - }); - - deleteRedirectedCookie(res); - - return next(); -}; diff --git a/server/src/middleware/redirect/redirect.test.ts b/server/src/middleware/redirect/redirect.test.ts deleted file mode 100644 index 237b68209..000000000 --- a/server/src/middleware/redirect/redirect.test.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { describe, expect, it, jest } from 'bun:test'; -import { redirectMiddleware } from '@app/middleware/redirect/redirect'; -import { Request, Response } from '@app/types/http'; - -const NON_REDIRECT_URLS = [ - '/nb/klage/DAGPENGER', - '/nb/klage/DAGPENGER/begrunnelse', - '/nb/klage/DAGPENGER/oppsummering', - '/nb/klage/DAGPENGER/innsending', - '/nb/anke/DAGPENGER', - '/nb/anke/DAGPENGER/begrunnelse', - '/nb/anke/DAGPENGER/oppsummering', - '/nb/anke/DAGPENGER/innsending', - '/nb/ettersendelse/klage/DAGPENGER', - '/nb/ettersendelse/klage/DAGPENGER/begrunnelse', - '/nb/ettersendelse/klage/DAGPENGER/oppsummering', - '/nb/ettersendelse/klage/DAGPENGER/innsending', - '/nb/ettersendelse/anke/DAGPENGER', - '/nb/ettersendelse/anke/DAGPENGER/begrunnelse', - '/nb/ettersendelse/anke/DAGPENGER/oppsummering', - '/nb/ettersendelse/anke/DAGPENGER/innsending', -]; - -const LOGIN_REDIRECT_URLS = [ - '/nb/sak/123/begrunnelse', - '/nb/sak/123/oppsummering', - '/nb/sak/123/innsending', - '/nb/sak/123/kvittering', - '/nb/sak/123/begrunnelse', - '/nb/sak/123/oppsummering', - '/nb/sak/123/innsending', - '/nb/sak/123/kvittering', -]; - -const INTERNAL_REDIRECT_URLS: [string, string][] = [ - ['/nb/sak/123', '/nb/sak/123/begrunnelse'], - ['/nb/sak/123', '/nb/sak/123/begrunnelse'], - ['/nb/klage/uinnlogget/DAGPENGER', '/nb/klage/DAGPENGER'], - ['/nb/klage/uinnlogget/DAGPENGER/begrunnelse', '/nb/klage/DAGPENGER/begrunnelse'], - ['/nb/klage/ny/SYKEPENGER', '/nb/klage/SYKEPENGER'], - ['/nb/klage/ny/DAGPENGER', '/nb/klage/DAGPENGER'], - ['/nb/klage/ny/DAGPENGER/begrunnelse', '/nb/klage/DAGPENGER/begrunnelse'], - ['/nb/anke/uinnlogget/DAGPENGER', '/nb/anke/DAGPENGER'], - ['/nb/anke/uinnlogget/DAGPENGER/begrunnelse', '/nb/anke/DAGPENGER/begrunnelse'], - ['/nb/anke/ny/DAGPENGER', '/nb/anke/DAGPENGER'], - ['/nb/anke/ny/DAGPENGER/begrunnelse', '/nb/anke/DAGPENGER/begrunnelse'], - ['/nb/ettersendelse/DAGPENGER', '/nb/ettersendelse/klage/DAGPENGER'], - ['/nb/ettersendelse/uinnlogget/DAGPENGER', '/nb/ettersendelse/klage/DAGPENGER'], - ['/nb/ettersendelse/ny/DAGPENGER', '/nb/ettersendelse/klage/DAGPENGER'], - ['/nb/klage/ny/DAGPENGER?saksnummer=123', '/nb/klage/DAGPENGER?saksnummer=123'], -]; - -const EXTERNAL_REDIRECT_URLS = [ - '/', - '/nb', - '/nb/ny', - '/nb/klage', - '/nb/anke', - '/nb/klage/uinnlogget', - '/nb/klage/ny', - '/nb/anke/uinnlogget', - '/nb/anke/ny', - '/nb/ettersendelse/uinnlogget', - '/nb/ettersendelse/ny', -]; -describe('redirect', () => { - it('should not redirect other methods than GET', async () => { - expect.assertions(3); - - const url = '/nb/klage/ny'; - const req: Request = createRequest(url, 'POST'); - const res = createResponse(); - const next = jest.fn(); - - await redirectMiddleware(req, res, next); - - expect(res.redirect).not.toHaveBeenCalled(); - expect(res.cookie).not.toHaveBeenCalled(); - expect(next).toHaveBeenCalledWith(); - }); - - it.each(NON_REDIRECT_URLS)('should not redirect %s', async (url) => { - expect.assertions(3); - - const req: Request = createRequest(url, 'GET'); - const res = createResponse(); - const next = jest.fn(); - - await redirectMiddleware(req, res, next); - - expect(res.redirect).not.toHaveBeenCalled(); - expect(res.cookie).toHaveBeenCalledWith('redirected_from', '', { - expires: new Date(0), - httpOnly: true, - maxAge: 0, - sameSite: 'strict', - }); - expect(next).toHaveBeenCalledWith(); - }); - - it.each(INTERNAL_REDIRECT_URLS)('should redirect %s to %s', async (from, to) => { - expect.assertions(3); - - const req: Request = createRequest(from, 'GET'); - const res = createResponse(); - const next = jest.fn(); - - await redirectMiddleware(req, res, next); - - expect(res.redirect).toHaveBeenCalledWith(301, to); - expect(res.cookie).toHaveBeenCalledWith('redirected_from', from, { httpOnly: true, sameSite: 'strict' }); - expect(next).not.toHaveBeenCalled(); - }); - - it.each(EXTERNAL_REDIRECT_URLS)('should redirect %s to external page', async (url) => { - expect.assertions(3); - - const req: Request = createRequest(url, 'GET'); - const res = createResponse(); - const next = jest.fn(); - - await redirectMiddleware(req, res, next); - - expect(res.redirect).toHaveBeenCalledWith(301, 'https://www.ekstern.dev.nav.no/klage'); - expect(res.cookie).toHaveBeenCalledWith('redirected_from', '', { - expires: new Date(0), - httpOnly: true, - maxAge: 0, - sameSite: 'strict', - }); - expect(next).not.toHaveBeenCalled(); - }); - - it.each(LOGIN_REDIRECT_URLS)('should redirect %s to login', async (url) => { - expect.assertions(3); - - const req: Request = createRequest(url, 'GET'); - const res = createResponse(); - const next = jest.fn(); - - await redirectMiddleware(req, res, next); - - expect(res.redirect).toHaveBeenCalledWith(302, `/oauth2/login?redirect=${encodeURIComponent(url)}`); - expect(res.cookie).not.toHaveBeenCalled(); - expect(next).not.toHaveBeenCalled(); - }); -}); - -const createRequest = (url: string, method: string): Request => { - const [pathname, search] = url.split('?'); - - if (pathname === undefined) { - throw new Error(`Could not split url ${url} into pathname and search.`); - } - - return { - url, - path: pathname, - method, - query: search === undefined ? {} : parseQueryParams(search), - headers: {}, - header: () => undefined, - }; -}; - -const createResponse = (): Response => ({ - redirect: jest.fn(), - cookie: jest.fn(), - locals: { sessionId: '' }, -}); - -const parseQueryParams = (search: string): Record => { - const parts = search.split('&'); - - const result: Record = {}; - - for (const s of parts) { - const [key, value] = s.split('='); - - if (key === undefined || value === undefined) { - continue; - } - - result[key] = value; - } - - return result; -}; diff --git a/server/src/middleware/redirect/redirect.ts b/server/src/middleware/redirect/redirect.ts deleted file mode 100644 index 1e92430ad..000000000 --- a/server/src/middleware/redirect/redirect.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { isInnsendingsytelse } from '@app/innsendingsytelser'; -import { isAnonymousStep, isLang, isLoggedInStep, isSak } from '@app/middleware/redirect/guards'; -import { ensureAuthentication } from '@app/middleware/redirect/is-authenticated'; -import { - noRedirect, - redirectToExternalKlagePage, - redirectToInternalPage, -} from '@app/middleware/redirect/redirect-functions'; -import { Request, Response } from '@app/types/http'; - -// eslint-disable-next-line complexity -export const redirectMiddleware = async (req: Request, res: Response, next: () => void) => { - if (req.method !== 'GET') { - return next(); - } - - const [, first, second, third, fourth, fifth] = req.path.split('/'); // The first element is an empty string. Ex.: ['', 'nb', 'klage', 'DAGPENGER']. - - if (!isLang(first) || third === undefined) { - return redirectToExternalKlagePage(req, res); - } - - // Logged in paths. - if (isSak(second)) { - if (fourth === undefined || !isLoggedInStep(fourth)) { - return redirectToInternalPage(req, res, `/${first}/${second}/${third}/begrunnelse`); - } - - await ensureAuthentication(req, res, next); - - return; - } - - // Not logged in ettersendelse paths. - if (second === 'ettersendelse') { - // Legacy path handling. - if (third !== 'klage' && third !== 'anke') { - if (third === 'uinnlogget' || third === 'ny') { - if (!isInnsendingsytelse(fourth)) { - return redirectToExternalKlagePage(req, res); - } - - const path = isAnonymousStep(fifth) - ? `/${first}/${second}/klage/${fourth}/${fifth}` - : `/${first}/${second}/klage/${fourth}`; - - return redirectToInternalPage(req, res, path); - } - - if (!isInnsendingsytelse(third)) { - return redirectToExternalKlagePage(req, res); - } - - return redirectToInternalPage(req, res, `/${first}/${second}/klage/${third}`); - } - - if (!isInnsendingsytelse(fourth)) { - return redirectToExternalKlagePage(req, res); - } - - if (fifth === undefined || isAnonymousStep(fifth)) { - return noRedirect(req, res, next); - } - - return redirectToInternalPage(req, res, `/${first}/${second}/${third}/${fourth}`); - } - - // Not logged in klage/anke paths. - if (second !== 'klage' && second !== 'anke') { - return redirectToExternalKlagePage(req, res); - } - - // Handle /ny and /uinnlogget for now. - if (third === 'uinnlogget' || third === 'ny') { - if (!isInnsendingsytelse(fourth)) { - return redirectToExternalKlagePage(req, res); - } - - const path = isAnonymousStep(fifth) ? `/${first}/${second}/${fourth}/${fifth}` : `/${first}/${second}/${fourth}`; - - return redirectToInternalPage(req, res, path); - } - - if (!isInnsendingsytelse(third)) { - return redirectToExternalKlagePage(req, res); - } - - if (fourth === undefined || isAnonymousStep(fourth)) { - return noRedirect(req, res, next); - } - - return redirectToInternalPage(req, res, `/${first}/${second}/${third}`); -}; diff --git a/server/src/middleware/session-id.ts b/server/src/middleware/session-id.ts deleted file mode 100644 index 16cf21c24..000000000 --- a/server/src/middleware/session-id.ts +++ /dev/null @@ -1,19 +0,0 @@ -import crypto from 'crypto'; -import { getCookie } from '@app/functions/get-cookie'; -import { Request, Response } from '@app/types/http'; - -export const sessionIdMiddleware = (req: Request, res: Response, next: () => void) => { - const sessionId = getCookie(req, 'session-id'); - - if (sessionId === undefined) { - const newSessionId = crypto.randomUUID(); - res.cookie('session-id', newSessionId, { httpOnly: true, sameSite: 'strict' }); - res.locals['sessionId'] = newSessionId; - - return next(); - } - - res.locals['sessionId'] = sessionId; - - return next(); -}; diff --git a/server/src/nav-dekoratoren/csr-elements.ts b/server/src/nav-dekoratoren/csr-elements.ts new file mode 100644 index 000000000..01b688c6a --- /dev/null +++ b/server/src/nav-dekoratoren/csr-elements.ts @@ -0,0 +1,22 @@ +import type { DecoratorFetchProps, DecoratorUrlProps } from '@app/nav-dekoratoren/types'; +import { getDecoratorUrl } from '@app/nav-dekoratoren/urls'; + +export const getCsrElements = (csrProps: DecoratorFetchProps) => { + const props: DecoratorUrlProps = { + ...csrProps, + csr: true, + }; + + const envUrl = getDecoratorUrl(props); + const assetsUrl = getDecoratorUrl({ ...props, params: undefined }); + const scriptSrc = `${assetsUrl}/client.js`; + + return { + header: '
', + footer: '', + env: `
`, + styles: ``, + scripts: ``, + scriptSrc, + }; +}; diff --git a/server/src/nav-dekoratoren/nav-dekoratoren.ts b/server/src/nav-dekoratoren/nav-dekoratoren.ts new file mode 100644 index 000000000..bef704454 --- /dev/null +++ b/server/src/nav-dekoratoren/nav-dekoratoren.ts @@ -0,0 +1,88 @@ +import { getCsrElements } from '@app/nav-dekoratoren/csr-elements'; +import type { DecoratorFetchProps } from '@app/nav-dekoratoren/types'; +import { getDecoratorUrl } from '@app/nav-dekoratoren/urls'; +import { Window } from 'happy-dom'; + +type DecoratorElements = { + DECORATOR_STYLES: string; + DECORATOR_SCRIPTS: string; + DECORATOR_HEADER: string; + DECORATOR_FOOTER: string; +}; + +const fetchDecorator = async (url: string, props: DecoratorFetchProps, retries = 3): Promise => + fetch(url) + .then((res) => { + if (res.ok) { + return res.text(); + } + throw new Error(`${res.status} - ${res.statusText}`); + }) + .then((html) => { + const window = new Window({ + settings: { + disableJavaScriptFileLoading: true, + disableCSSFileLoading: true, + disableComputedStyleRendering: true, + disableJavaScriptEvaluation: true, + }, + }); + window.document.write(html); + const { document } = window; + + const styles = document.getElementById('styles')?.innerHTML; + if (!styles) { + throw new Error('Decorator styles element not found!'); + } + + const scripts = document.getElementById('scripts')?.innerHTML; + if (!scripts) { + throw new Error('Decorator scripts element not found!'); + } + + const header = document.getElementById('header-withmenu')?.innerHTML; + if (!header) { + throw new Error('Decorator header element not found!'); + } + + const footer = document.getElementById('footer-withmenu')?.innerHTML; + if (!footer) { + throw new Error('Decorator footer element not found!'); + } + + const elements = { + DECORATOR_STYLES: styles.trim(), + DECORATOR_SCRIPTS: scripts.trim(), + DECORATOR_HEADER: header.trim(), + DECORATOR_FOOTER: footer.trim(), + }; + + return elements; + }) + .catch((e) => { + if (retries > 0) { + console.warn(`Failed to fetch decorator, retrying ${retries} more times - Url: ${url} - Error: ${e}`); + return fetchDecorator(url, props, retries - 1); + } + + throw e; + }); + +export const fetchDecoratorHtml = async (props: DecoratorFetchProps): Promise => { + const url = getDecoratorUrl(props); + + return fetchDecorator(url, props).catch((e) => { + console.error( + `Failed to fetch decorator, falling back to elements for client-side rendering - Url: ${url} - Error: ${e}`, + ); + + const csrElements = getCsrElements(props); + + return { + DECORATOR_STYLES: csrElements.styles, + DECORATOR_SCRIPTS: `${csrElements.env}${csrElements.scripts}`, + DECORATOR_HEADER: csrElements.header, + DECORATOR_FOOTER: csrElements.footer, + }; + }); +}; diff --git a/server/src/nav-dekoratoren/types.ts b/server/src/nav-dekoratoren/types.ts new file mode 100644 index 000000000..8050ad253 --- /dev/null +++ b/server/src/nav-dekoratoren/types.ts @@ -0,0 +1,58 @@ +type DecoratorLocale = 'nb' | 'nn' | 'en' | 'se' | 'pl' | 'uk' | 'ru'; + +export type DecoratorLanguageOption = + | { + url?: string; + locale: DecoratorLocale; + handleInApp: true; + } + | { + url: string; + locale: DecoratorLocale; + handleInApp?: false; + }; + +export type DecoratorBreadcrumb = { + url: string; + title: string; + analyticsTitle?: string; + handleInApp?: boolean; +}; + +export type DecoratorNaisEnv = 'prod' | 'dev' | 'beta' | 'betaTms' | 'devNext' | 'prodNext'; + +export type DecoratorEnvProps = + | { env: 'localhost'; localUrl: string } + | { env: DecoratorNaisEnv; serviceDiscovery?: boolean }; + +export type DecoratorFetchProps = { + params?: DecoratorParams; + noCache?: boolean; +} & DecoratorEnvProps; + +export type DecoratorUrlProps = { + csr?: boolean; +} & DecoratorFetchProps; + +type DecoratorParams = Partial<{ + context: 'privatperson' | 'arbeidsgiver' | 'samarbeidspartner'; + simple: boolean; + simpleHeader: boolean; + simpleFooter: boolean; + enforceLogin: boolean; + redirectToApp: boolean; + redirectToUrl: string; + redirectToUrlLogout: string; + level: string; + language: DecoratorLocale; + availableLanguages: DecoratorLanguageOption[]; + breadcrumbs: DecoratorBreadcrumb[]; + utilsBackground: 'white' | 'gray' | 'transparent'; + feedback: boolean; + chatbot: boolean; + chatbotVisible: boolean; + urlLookupTable: boolean; + shareScreen: boolean; + logoutUrl: string; + logoutWarning: boolean; +}>; diff --git a/server/src/nav-dekoratoren/urls.ts b/server/src/nav-dekoratoren/urls.ts new file mode 100644 index 000000000..195359fa5 --- /dev/null +++ b/server/src/nav-dekoratoren/urls.ts @@ -0,0 +1,56 @@ +import type { + DecoratorBreadcrumb, + DecoratorLanguageOption, + DecoratorNaisEnv, + DecoratorUrlProps, +} from '@app/nav-dekoratoren/types'; + +type NaisUrls = Record; + +const externalUrls: NaisUrls = { + prod: 'https://www.nav.no/dekoratoren', + dev: 'https://dekoratoren.ekstern.dev.nav.no', + beta: 'https://dekoratoren-beta.intern.dev.nav.no', + betaTms: 'https://dekoratoren-beta-tms.intern.dev.nav.no', + devNext: 'https://decorator-next.ekstern.dev.nav.no', + prodNext: 'https://www.nav.no/decorator-next', +}; + +const serviceUrls: NaisUrls = { + prod: 'http://nav-dekoratoren.personbruker', + dev: 'http://nav-dekoratoren.personbruker', + beta: 'http://nav-dekoratoren-beta.personbruker', + betaTms: 'http://nav-dekoratoren-beta-tms.personbruker', + devNext: 'http://decorator-next.personbruker', + prodNext: 'http://decorator-next.personbruker', +}; + +const objectToQueryString = ( + params: Record, +) => + params + ? Object.entries(params).reduce( + (acc, [k, v], i) => + v !== undefined + ? `${acc}${i ? '&' : '?'}${k}=${encodeURIComponent(typeof v === 'object' ? JSON.stringify(v) : v)}` + : acc, + '', + ) + : ''; + +const getNaisUrl = (env: DecoratorNaisEnv, csr = false, serviceDiscovery = true) => { + const shouldUseServiceDiscovery = serviceDiscovery && !csr; + + return (shouldUseServiceDiscovery ? serviceUrls[env] : externalUrls[env]) || externalUrls.prod; +}; + +export const getDecoratorUrl = (props: DecoratorUrlProps) => { + const { env, params, csr } = props; + const baseUrl = env === 'localhost' ? props.localUrl : getNaisUrl(env, csr, props.serviceDiscovery); + + if (params === undefined) { + return baseUrl; + } + + return `${baseUrl}/${csr === true ? 'env' : ''}${objectToQueryString(params)}`; +}; diff --git a/server/src/plugins/access-token.ts b/server/src/plugins/access-token.ts new file mode 100644 index 000000000..7e72f2b53 --- /dev/null +++ b/server/src/plugins/access-token.ts @@ -0,0 +1,38 @@ +import { AUTHORIZATION_HEADER } from '@app/headers'; +import type { FastifyRequest } from 'fastify'; +import fastifyPlugin from 'fastify-plugin'; + +declare module 'fastify' { + interface FastifyRequest { + accessToken: string; + } +} + +export const ACCESS_TOKEN_PLUGIN_ID = 'access-token'; + +export const accessTokenPlugin = fastifyPlugin( + async (app) => { + app.decorateRequest('accessToken', ''); + + app.addHook('preHandler', async (req) => { + const accessToken = getAccessToken(req); + + if (accessToken !== undefined) { + req.accessToken = accessToken; + } + }); + }, + { fastify: '4', name: ACCESS_TOKEN_PLUGIN_ID }, +); + +const getAccessToken = (req: FastifyRequest): string | undefined => { + const authHeader = req.headers[AUTHORIZATION_HEADER]; + + if (authHeader !== undefined) { + const [, accessToken] = authHeader.split(' '); + + return accessToken; + } + + return undefined; +}; diff --git a/server/src/plugins/api-proxy.ts b/server/src/plugins/api-proxy.ts new file mode 100644 index 000000000..e9c4f1609 --- /dev/null +++ b/server/src/plugins/api-proxy.ts @@ -0,0 +1,142 @@ +import { DEV_URL, isDeployed } from '@app/config/env'; +import { getDuration } from '@app/helpers/duration'; +import { getProxyRequestHeaders } from '@app/helpers/prepare-request-headers'; +import { getLogger } from '@app/logger'; +import { OBO_ACCESS_TOKEN_PLUGIN_ID } from '@app/plugins/obo-token'; +import { SERVER_TIMING_HEADER, SERVER_TIMING_PLUGIN_ID } from '@app/plugins/server-timing'; +import proxy from '@fastify/http-proxy'; +import fastifyPlugin from 'fastify-plugin'; + +const log = getLogger('api-proxy'); + +declare module 'fastify' { + interface FastifyRequest { + proxyStartTime: number; + } +} + +interface ApiProxyPluginOptions { + appNames: string[]; +} + +export const API_PROXY_PLUGIN_ID = 'api-proxy'; + +export const apiProxyPlugin = fastifyPlugin( + async (app, { appNames }) => { + app.decorateRequest('proxyStartTime', 0); + + app.addHook('onSend', async (req, reply) => { + if (!req.url.startsWith('/api/')) { + return; + } + + const appName = req.url.split('/').at(2); + + if (appName === undefined || !appNames.includes(appName)) { + return; + } + + const { method, url, trace_id, span_id, client_version, proxyStartTime } = req; + const responseTime = getDuration(proxyStartTime); + + log.info({ + msg: `Proxy response (${appName}) ${reply.statusCode} ${method} ${url} ${responseTime}ms`, + trace_id, + span_id, + client_version, + data: { + method, + url, + statusCode: reply.statusCode, + responseTime, + contentType: reply.getHeader('content-type'), + contentLength: reply.getHeader('content-length'), + }, + }); + }); + + for (const appName of appNames) { + const upstream = isDeployed ? `http://${appName}` : `${DEV_URL}/api/${appName}`; + const prefix = `/api/${appName}`; + + app.register(proxy, { + upstream, + prefix, + cacheURLs: 10_000, + websocket: true, + proxyPayloads: true, + preHandler: async (req, reply) => { + log.info({ + msg: `Proxy request (${appName}) ${req.method} ${req.url}`, + trace_id: req.trace_id, + span_id: req.span_id, + data: { + method: req.method, + contentType: req.headers['content-type'], + contentLength: req.headers['content-length'], + url: req.url, + }, + }); + req.proxyStartTime = performance.now(); + await req.getOboAccessToken(appName, reply); + }, + retryMethods: ['GET'], // Only retry GET requests. All others are not idempotent. + replyOptions: { + rewriteRequestHeaders: (req) => getProxyRequestHeaders(req, appName), + rewriteHeaders: (headers, req) => { + const serverTiming = headers[SERVER_TIMING_HEADER]; + + const total = `proxy;dur=${req === undefined ? 0 : getDuration(req.proxyStartTime)};desc="Proxy total (${appName})"`; + + switch (typeof serverTiming) { + case 'string': + return { + ...headers, + [SERVER_TIMING_HEADER]: serverTiming + .split(',') + .map((entry) => prefixServerTimingEntry(entry, appName)) + .concat(total) + .join(', '), + }; + case 'object': + return { + ...headers, + [SERVER_TIMING_HEADER]: serverTiming + .map((entry) => prefixServerTimingEntry(entry, appName)) + .concat(total) + .join(', '), + }; + default: + return headers; + } + }, + }, + }); + } + }, + { fastify: '4', name: API_PROXY_PLUGIN_ID, dependencies: [OBO_ACCESS_TOKEN_PLUGIN_ID, SERVER_TIMING_PLUGIN_ID] }, +); + +const prefixServerTimingEntry = (entry: string, appName: string): string => { + const [name, duration, description] = entry.trim().split(';'); + + const timing = `${appName}-${name};${duration}`; + + if (description === undefined) { + return timing; + } + + const [_, desc] = description.split('='); + + if (desc === undefined) { + return timing; + } + + if (desc.startsWith('"') && desc.endsWith('"')) { + const content = desc.slice(1, -1); + + return `${timing};desc="${content} (${appName})"`; + } + + return `${timing};desc="${desc} (${appName})"`; +}; diff --git a/server/src/plugins/client-version.ts b/server/src/plugins/client-version.ts new file mode 100644 index 000000000..bf4887891 --- /dev/null +++ b/server/src/plugins/client-version.ts @@ -0,0 +1,27 @@ +import { CLIENT_VERSION_HEADER } from '@app/headers'; +import { CLIENT_VERSION_QUERY, getHeaderOrQueryValue } from '@app/helpers/get-header-query'; +import type { FastifyRequest } from 'fastify'; +import fastifyPlugin from 'fastify-plugin'; + +declare module 'fastify' { + interface FastifyRequest { + client_version: string; + } +} + +export const CLIENT_VERSION_PLUGIN_ID = 'client-version'; + +export const clientVersionPlugin = fastifyPlugin( + async (app) => { + app.decorateRequest('client_version', ''); + + app.addHook('preHandler', async (req: FastifyRequest<{ Querystring: Record }>) => { + const client_version = getHeaderOrQueryValue(req, CLIENT_VERSION_HEADER, CLIENT_VERSION_QUERY); + + if (client_version !== undefined) { + req.client_version = client_version; + } + }); + }, + { fastify: '4', name: CLIENT_VERSION_PLUGIN_ID }, +); diff --git a/server/src/plugins/error-report.ts b/server/src/plugins/error-report.ts new file mode 100644 index 000000000..ebb14b36f --- /dev/null +++ b/server/src/plugins/error-report.ts @@ -0,0 +1,23 @@ +import { getLogger, isSerializable } from '@app/logger'; +import fastifyPlugin from 'fastify-plugin'; + +const log = getLogger('frontend-error-reporter'); + +export const ERROR_REPORT_PLUGIN_ID = 'error-report'; + +export const errorReportPlugin = fastifyPlugin( + async (app) => { + app.post('/error-report', (req, reply) => { + if (!isSerializable(req.body)) { + reply.status(400).send('Invalid request body'); + + return; + } + + log.warn({ msg: 'Error report', data: req.body }); + + reply.status(200).send(); + }); + }, + { fastify: '4', name: ERROR_REPORT_PLUGIN_ID }, +); diff --git a/server/src/plugins/frontend-log/frontend-log.ts b/server/src/plugins/frontend-log/frontend-log.ts new file mode 100644 index 000000000..794665dbd --- /dev/null +++ b/server/src/plugins/frontend-log/frontend-log.ts @@ -0,0 +1,86 @@ +import { getLogger } from '@app/logger'; +import { FrontendEventTypes, Level, SessionAction } from '@app/plugins/frontend-log/types'; +import { Type, type TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; +import fastifyPlugin from 'fastify-plugin'; + +const log = getLogger('frontend-log'); + +export const FRONTEND_LOG_PLUGIN_ID = 'frontend-log'; + +export const frontendLogPlugin = fastifyPlugin( + async (app) => { + app.withTypeProvider().post( + '/frontend-log', + { + schema: { + body: Type.Composite([ + Type.Object({ + session_id: Type.String(), + level: Type.Enum(Level), + client_timestamp: Type.Number({ description: 'Current client Unix timestamp in milliseconds.' }), + token_expires: Type.Optional(Type.String({ description: 'DateTime when the token expires.' })), + session_ends: Type.Optional(Type.String({ description: 'DateTime when the session ends.' })), + is_logged_in: Type.Boolean(), + client_version: Type.String(), + session_time: Type.Number({ description: 'Milliseconds since start of session.' }), + session_time_formatted: Type.String({ + description: 'Formatted time since start of session.', + format: 'time', + examples: ['1:35:45.987'], + }), + route: Type.String({ + description: 'Current path.', + examples: ['/nb/sak/uuid-uuid-uuid-uuid/begrunnelse'], + }), + message: Type.String(), + }), + Type.Union([ + // Navigation event + Type.Object({ + type: Type.Literal(FrontendEventTypes.NAVIGATION), + }), + // App event + Type.Object({ + type: Type.Literal(FrontendEventTypes.APP), + action: Type.String(), + }), + // Session event + Type.Object({ + type: Type.Literal(FrontendEventTypes.SESSION), + message: Type.String(), + action: Type.Enum(SessionAction), + }), + // API event + Type.Object( + { + type: Type.Literal(FrontendEventTypes.API), + request: Type.String(), + response_time: Type.Number({ description: 'API response time in milliseconds.' }), + status: Type.Union([Type.Number(), Type.String()]), + }, + { description: 'API event.' }, + ), + // Error event + Type.Object( + { + type: Type.Literal(FrontendEventTypes.ERROR), + message: Type.String(), + stack: Type.Optional(Type.String({ description: 'Stack trace of the error.' })), + }, + { description: 'Error event.' }, + ), + ]), + ]), + }, + }, + (req, reply) => { + const { level, message, ...data } = req.body; + + log[level]({ msg: message, data }); + + reply.status(200).send(); + }, + ); + }, + { fastify: '4', name: FRONTEND_LOG_PLUGIN_ID }, +); diff --git a/server/src/plugins/frontend-log/types.ts b/server/src/plugins/frontend-log/types.ts new file mode 100644 index 000000000..2a095d28c --- /dev/null +++ b/server/src/plugins/frontend-log/types.ts @@ -0,0 +1,29 @@ +export enum Level { + DEBUG = 'debug', + INFO = 'info', + WARN = 'warn', + ERROR = 'error', +} + +export enum FrontendEventTypes { + NAVIGATION = 'navigation', + APP = 'app', + ERROR = 'error', + API = 'api', + SESSION = 'session', +} + +export enum SessionAction { + /** Load session case */ + LOAD = 'load', + /** Create session case */ + CREATE = 'create', + /** Load or create session case */ + LOAD_OR_CREATE = 'load-create', + /** Delete session case */ + DELETE = 'delete', + /** Set session case */ + SET = 'set', + /** Update session case */ + UPDATE = 'update', +} diff --git a/server/src/plugins/health.ts b/server/src/plugins/health.ts new file mode 100644 index 000000000..ba161190f --- /dev/null +++ b/server/src/plugins/health.ts @@ -0,0 +1,46 @@ +import { oboCache } from '@app/auth/cache/cache'; +import { getIsTokenXClientReady } from '@app/auth/token-x-client'; +import { indexFile } from '@app/index-file'; +import { getLogger } from '@app/logger'; +import fastifyPlugin from 'fastify-plugin'; + +export const HEALTH_PLUGIN_ID = 'health'; + +const log = getLogger('liveness'); + +export const healthPlugin = fastifyPlugin( + async (app) => { + app.get('/isAlive', (__, reply) => reply.status(200).type('text/plain').send('Alive')); + + app.get('/isReady', async (__, reply) => { + if (!indexFile.isReady) { + log.info({ msg: 'Index file not ready' }); + + return reply.status(503).type('text/plain').send('Index file not ready'); + } + + const isTokenXClientReady = getIsTokenXClientReady(); + + if (!(oboCache.isReady || isTokenXClientReady)) { + log.info({ msg: 'OBO Cache and TokenX Client not ready' }); + + return reply.status(503).type('text/plain').send('OBO Cache and TokenX Client not ready'); + } + + if (!oboCache.isReady) { + log.info({ msg: 'OBO Cache not ready' }); + + return reply.status(503).type('text/plain').send('OBO Cache not ready'); + } + + if (!isTokenXClientReady) { + log.info({ msg: 'TokenX Client not ready' }); + + return reply.status(503).type('text/plain').send('TokenX Client not ready'); + } + + return reply.status(200).type('text/plain').send('Ready'); + }); + }, + { fastify: '4', name: HEALTH_PLUGIN_ID }, +); diff --git a/server/src/plugins/http-logger.ts b/server/src/plugins/http-logger.ts new file mode 100644 index 000000000..ee4dad0a5 --- /dev/null +++ b/server/src/plugins/http-logger.ts @@ -0,0 +1,72 @@ +import { getDuration } from '@app/helpers/duration'; +import { type AnyObject, getLogger } from '@app/logger'; +import { PROXY_VERSION_PLUGIN_ID } from '@app/plugins/proxy-version'; +import { SERVE_INDEX_PLUGIN_ID } from '@app/plugins/serve-index/serve-index'; +import fastifyPlugin from 'fastify-plugin'; + +export const HTTP_LOGGER_PLUGIN_ID = 'http-logger'; + +export const httpLoggerPlugin = fastifyPlugin( + async (app) => { + app.addHook('onResponse', async (req, res) => { + const { url } = req; + + if (url.endsWith('/isAlive') || url.endsWith('/isReady') || url.endsWith('/metrics')) { + return; + } + + const { trace_id, span_id, client_version, startTime } = req; + + const responseTime = getDuration(startTime); + + logHttpRequest({ + method: req.method, + url, + status_code: res.statusCode, + trace_id, + span_id, + client_version, + responseTime, + request_content_length: req.headers['content-length'], + request_content_type: req.headers['content-type'], + response_content_length: res.getHeader('content-length'), + response_content_type: res.getHeader('content-type'), + }); + }); + }, + { + fastify: '4', + name: HTTP_LOGGER_PLUGIN_ID, + dependencies: [PROXY_VERSION_PLUGIN_ID, SERVE_INDEX_PLUGIN_ID], + }, +); + +const httpLogger = getLogger('http'); + +interface HttpData extends AnyObject { + method: string; + url: string; + status_code: number; + trace_id: string | undefined; + span_id: string | undefined; + client_version: string | undefined; + responseTime: number; +} + +const logHttpRequest = ({ trace_id, span_id, client_version, ...data }: HttpData) => { + const msg = `Response ${data.status_code} ${data.method} ${data.url} ${data.responseTime}ms`; + + if (data.status_code >= 500) { + httpLogger.error({ msg, trace_id, span_id, data, client_version }); + + return; + } + + if (data.status_code >= 400) { + httpLogger.warn({ msg, trace_id, span_id, data, client_version }); + + return; + } + + httpLogger.debug({ msg, trace_id, span_id, data, client_version }); +}; diff --git a/server/src/plugins/local-dev.ts b/server/src/plugins/local-dev.ts new file mode 100644 index 000000000..d81f6427a --- /dev/null +++ b/server/src/plugins/local-dev.ts @@ -0,0 +1,109 @@ +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { DEV_URL, isLocal } from '@app/config/env'; +import { getLogger } from '@app/logger'; +import { Type, type TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; +import fastifyPlugin from 'fastify-plugin'; + +const log = getLogger('local-dev-plugin'); + +const getMimeType = (filePath: string): string | undefined => { + const extension = filePath.split('.').at(-1); + + switch (extension) { + case 'css': + return 'text/css'; + case 'js': + return 'application/javascript'; + case 'woff2': + return 'font/woff2'; + case 'woff': + return 'font/woff'; + case 'html': + return 'text/html'; + case 'json': + return 'application/json'; + case 'png': + return 'image/png'; + case 'svg': + return 'image/svg+xml'; + case 'ico': + return 'image/x-icon'; + default: + return undefined; + } +}; + +interface FileEntry { + data: Buffer; + mimeType: string; +} + +export const LOCAL_DEV_PLUGIN_ID = 'local-dev'; + +export const localDevPlugin = fastifyPlugin( + async (app) => { + // Serve assets only in local environment. In production and dev, assets are served by the CDN. + if (!isLocal) { + return; + } + + app + .withTypeProvider() + .get( + '/oauth2/:endpoint', + { schema: { params: Type.Object({ endpoint: Type.String() }) } }, + async (req, reply) => { + const { endpoint } = req.params; + + const res = await fetch(`${DEV_URL}/oauth2/${endpoint}`, { + headers: { ...req.headers, host: DEV_URL }, + method: req.method, + }); + + return reply.send(res); + }, + ); + + app.post('/collect', async (_, reply) => { + log.info({ msg: 'Collect' }); + + return reply.status(200).send(); + }); + + const ASSETS_FOLDER = '../frontend/dist/assets'; + + const files: Map = new Map(); + + for (const fileName of readdirSync(ASSETS_FOLDER)) { + const filePath = `${ASSETS_FOLDER}/${fileName}`; + + if (existsSync(filePath)) { + const fileKey = `/assets/${fileName}`; + const data = readFileSync(filePath); + const mimeType = getMimeType(filePath); + + if (mimeType === undefined) { + log.warn({ msg: `Unknown MIME type for asset file "${fileName}"`, data: { path: filePath } }); + } + + files.set(fileKey, { data, mimeType: mimeType ?? 'text/plain' }); + } + } + + app.get('/assets/*', async (req, res) => { + const fileEntry = files.get(req.url); + + if (fileEntry === undefined) { + log.warn({ msg: 'File not found', data: { path: req.url } }); + res.header('content-type', 'text/plain').status(404); + + return res.send('Not Found'); + } + + const { data, mimeType } = fileEntry; + + return res.header('content-type', mimeType).status(200).send(data); + }); + }, + { fastify: '4', name: LOCAL_DEV_PLUGIN_ID }, +); diff --git a/server/src/plugins/not-found.ts b/server/src/plugins/not-found.ts new file mode 100644 index 000000000..84efe224c --- /dev/null +++ b/server/src/plugins/not-found.ts @@ -0,0 +1,150 @@ +import { YTELSE_OVERVIEW_URL, isDeployedToProd } from '@app/config/env'; +import { INNSENDINGSYTELSER } from '@app/innsendingsytelser'; +import { getLogger } from '@app/logger'; +import { externalRedirectCounter } from '@app/plugins/serve-index/counters'; +import { removeSaksnummer } from '@app/plugins/serve-index/remove-saksnummer'; +import { CASE_TYPES } from '@app/plugins/serve-index/segments'; +import { SERVE_INDEX_PLUGIN_ID } from '@app/plugins/serve-index/serve-index'; +import fastifyPlugin from 'fastify-plugin'; + +const log = getLogger('not-found-plugin'); + +export const NOT_FOUND_PLUGIN_ID = 'not-found'; + +export const notFoundPlugin = fastifyPlugin( + async (app) => { + app.setNotFoundHandler((req, reply) => { + if (isDeployedToProd) { + log.warn({ + msg: `Invalid URL. Redirecting to external URL ${YTELSE_OVERVIEW_URL}`, + data: { url: req.url }, + }); + + externalRedirectCounter.inc({ + url: removeSaksnummer(req.url), + }); + + return reply.redirect(YTELSE_OVERVIEW_URL); + } + + return reply.status(404).header('content-type', 'text/html').send(DEV_404_HTML); + }); + }, + { fastify: '4', name: NOT_FOUND_PLUGIN_ID, dependencies: [SERVE_INDEX_PLUGIN_ID] }, +); + +const getCells = (ytelse: string): string[] => + CASE_TYPES.flatMap((type) => [ + createCell(`/nb/${type}/${ytelse}`, type), + createCell(`/nb/ettersendelse/${type}/${ytelse}`, `${type} ettersendelse`), + ]); + +const createCell = (path: string, type: string) => `${type}`; + +const getRows = (): string[][] => + INNSENDINGSYTELSER.map((ytelse) => [ + `${ytelse.toLowerCase().replaceAll('_', ' ')}`, + ...getCells(ytelse), + ]); + +const DEV_404_HTML = isDeployedToProd + ? '' + : ` + + + 404 - Siden finnes ikke + + + +

Siden finnes ikke

+

I produksjon ville du ha blitt videresendt til denne siden ${YTELSE_OVERVIEW_URL}.

+

Tilgjengelige skjemaer

+ + + + + ${CASE_TYPES.map((type) => ``).join('')} + + + + ${getRows() + .map((row) => `${row.join('')}`) + .join('')} + +
Ytelse${type}${type} ettersendelse
+ + + `.trim(); diff --git a/server/src/plugins/obo-token.ts b/server/src/plugins/obo-token.ts new file mode 100644 index 000000000..1b051260d --- /dev/null +++ b/server/src/plugins/obo-token.ts @@ -0,0 +1,128 @@ +import { oboRequestDuration } from '@app/auth/cache/cache-gauge'; +import { getOnBehalfOfAccessToken } from '@app/auth/on-behalf-of'; +import { getTokenXClient } from '@app/auth/token-x-client'; +import { isDeployed } from '@app/config/env'; +import { getDuration } from '@app/helpers/duration'; +import { getLogger } from '@app/logger'; +import { ACCESS_TOKEN_PLUGIN_ID } from '@app/plugins/access-token'; +import { SERVER_TIMING_PLUGIN_ID } from '@app/plugins/server-timing'; +import type { FastifyReply, FastifyRequest } from 'fastify'; +import fastifyPlugin from 'fastify-plugin'; + +const log = getLogger('obo-token-plugin'); + +const oboAccessTokenMapKey = Symbol('oboAccessTokenMap'); + +declare module 'fastify' { + interface FastifyRequest { + [oboAccessTokenMapKey]: Map; + getOboAccessToken(appName: string, reply: FastifyReply): Promise; + getCachedOboAccessToken(appName: string): string | undefined; + } +} + +const NOOP = async () => undefined; + +export const OBO_ACCESS_TOKEN_PLUGIN_ID = 'obo-access-token'; + +export const oboAccessTokenPlugin = fastifyPlugin( + async (app) => { + app.decorateRequest(oboAccessTokenMapKey, null); + + app.addHook('onRequest', async (req): Promise => { + req[oboAccessTokenMapKey] = new Map(); + }); + + if (isDeployed) { + app.decorateRequest('getOboAccessToken', async function (appName: string, reply: FastifyReply) { + const cachedOboAccessToken = getCachedOboAccessToken(appName, this); + + if (cachedOboAccessToken !== undefined) { + log.debug({ + msg: `Using cached OBO token for "${appName}".`, + trace_id: this.trace_id, + span_id: this.span_id, + client_version: this.client_version, + data: { route: this.url }, + }); + + return cachedOboAccessToken; + } + + const oboAccessToken = await getOboToken(appName, this, reply); + + if (oboAccessToken !== undefined) { + log.debug({ + msg: `Adding OBO token for "${appName}". Had ${this[oboAccessTokenMapKey].size} before.`, + trace_id: this.trace_id, + span_id: this.span_id, + client_version: this.client_version, + data: { route: this.url }, + }); + + this[oboAccessTokenMapKey].set(appName, oboAccessToken); + } + + return oboAccessToken; + }); + } else { + app.decorateRequest('getOboAccessToken', NOOP); + } + + app.decorateRequest('getCachedOboAccessToken', function (appName: string) { + return getCachedOboAccessToken(appName, this); + }); + }, + { + fastify: '4', + name: OBO_ACCESS_TOKEN_PLUGIN_ID, + dependencies: [ACCESS_TOKEN_PLUGIN_ID, SERVER_TIMING_PLUGIN_ID], + }, +); + +const getCachedOboAccessToken = (appName: string, req: FastifyRequest) => { + log.debug({ + msg: `Getting OBO token for "${appName}". Has ${req[oboAccessTokenMapKey].size} tokens.`, + trace_id: req.trace_id, + span_id: req.span_id, + client_version: req.client_version, + data: { route: req.url }, + }); + + return req[oboAccessTokenMapKey].get(appName); +}; + +type GetOboToken = (appName: string, req: FastifyRequest, reply: FastifyReply) => Promise; + +const getOboToken: GetOboToken = async (appName, req, reply) => { + const { trace_id, span_id, accessToken } = req; + + if (accessToken.length === 0) { + return undefined; + } + + try { + const tokenXClientStart = performance.now(); + const authClient = await getTokenXClient(); + reply.addServerTiming('token_x_client_middleware', getDuration(tokenXClientStart), 'TokenX Client Middleware'); + + const oboStart = performance.now(); + const oboAccessToken = await getOnBehalfOfAccessToken(authClient, accessToken, appName, trace_id, span_id); + + const duration = getDuration(oboStart); + oboRequestDuration.observe(duration); + reply.addServerTiming('obo_token_middleware', duration, 'OBO Token Middleware'); + + return oboAccessToken; + } catch (error) { + log.warn({ + msg: 'Failed to prepare request with OBO token.', + error, + trace_id, + span_id, + data: { route: req.url }, + }); + + return undefined; + } +}; diff --git a/server/src/plugins/proxy-version.ts b/server/src/plugins/proxy-version.ts new file mode 100644 index 000000000..283a40699 --- /dev/null +++ b/server/src/plugins/proxy-version.ts @@ -0,0 +1,15 @@ +import { PROXY_VERSION } from '@app/config/config'; +import { PROXY_VERSION_HEADER } from '@app/headers'; +import fastifyPlugin from 'fastify-plugin'; + +export const PROXY_VERSION_PLUGIN_ID = 'proxy-version'; + +export const proxyVersionPlugin = fastifyPlugin( + async (app) => { + // Add proxy version header to all responses. + app.addHook('onSend', async (__, reply) => { + reply.header(PROXY_VERSION_HEADER, PROXY_VERSION); + }); + }, + { fastify: '4', name: PROXY_VERSION_PLUGIN_ID }, +); diff --git a/server/src/plugins/serve-index/counters.ts b/server/src/plugins/serve-index/counters.ts new file mode 100644 index 000000000..5f68e81c4 --- /dev/null +++ b/server/src/plugins/serve-index/counters.ts @@ -0,0 +1,20 @@ +import { proxyRegister } from '@app/prometheus/types'; +import { Counter } from 'prom-client'; + +const viewCountLabels = ['url', 'has_saksnummer', 'redirected_from', 'referrer'] as const; + +export const viewCountCounter = new Counter({ + name: 'view_count', + help: 'Number of views.', + labelNames: viewCountLabels, + registers: [proxyRegister], +}); + +const externalRedirectLabels = ['url', 'has_saksnummer', 'redirected_from', 'referrer'] as const; + +export const externalRedirectCounter = new Counter({ + name: 'external_redirect', + help: 'Number of redirects to nav.no/klage.', + labelNames: externalRedirectLabels, + registers: [proxyRegister], +}); diff --git a/server/src/plugins/serve-index/get-paths.test.ts b/server/src/plugins/serve-index/get-paths.test.ts new file mode 100644 index 000000000..295137e59 --- /dev/null +++ b/server/src/plugins/serve-index/get-paths.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'bun:test'; +import { getAnonymousPaths, getLoggedInPaths } from './get-paths'; + +const ANONYMOUS_PATHS = [ + '/nb/klage/DAGPENGER', + '/nb/klage/DAGPENGER', + '/nb/klage/DAGPENGER', + '/nb/anke/DAGPENGER', + '/nb/anke/DAGPENGER', + '/nb/anke/DAGPENGER', + '/nb/klage/DAGPENGER/begrunnelse', + '/nb/klage/DAGPENGER/oppsummering', + '/nb/klage/DAGPENGER/innsending', + '/nb/anke/DAGPENGER/begrunnelse', + '/nb/anke/DAGPENGER/oppsummering', + '/nb/anke/DAGPENGER/innsending', + '/nb/ettersendelse/klage/DAGPENGER', + '/nb/ettersendelse/klage/DAGPENGER', + '/nb/ettersendelse/klage/DAGPENGER', + '/nb/ettersendelse/anke/DAGPENGER', + '/nb/ettersendelse/anke/DAGPENGER', + '/nb/ettersendelse/anke/DAGPENGER', + '/nb/ettersendelse/klage/DAGPENGER/begrunnelse', + '/nb/ettersendelse/klage/DAGPENGER/oppsummering', + '/nb/ettersendelse/klage/DAGPENGER/innsending', + '/nb/ettersendelse/anke/DAGPENGER/begrunnelse', + '/nb/ettersendelse/anke/DAGPENGER/oppsummering', + '/nb/ettersendelse/anke/DAGPENGER/innsending', +]; + +const LOGGED_IN_PATHS = [ + '/nb/sak/:id/begrunnelse', + '/nb/sak/:id/oppsummering', + '/nb/sak/:id/innsending', + '/nb/sak/:id/kvittering', + '/nn/sak/:id/begrunnelse', + '/nn/sak/:id/oppsummering', + '/nn/sak/:id/innsending', + '/nn/sak/:id/kvittering', + '/en/sak/:id/begrunnelse', + '/en/sak/:id/oppsummering', + '/en/sak/:id/innsending', + '/en/sak/:id/kvittering', +]; + +describe('generate paths', () => { + it('should include all known anonymous paths', async () => { + expect.assertions(ANONYMOUS_PATHS.length + 1); + + const paths = getAnonymousPaths(); + + // No duplicates. + expect(paths).toBeArrayOfSize(new Set(paths).size); + + for (const url of ANONYMOUS_PATHS) { + expect(paths).toContain(url); + } + }); + + it('should include all known logged in paths', async () => { + expect.assertions(LOGGED_IN_PATHS.length + 1); + + const paths = getLoggedInPaths(); + + // No duplicates. + expect(paths).toBeArrayOfSize(new Set(paths).size); + + for (const url of LOGGED_IN_PATHS) { + expect(paths).toContain(url); + } + }); +}); diff --git a/server/src/plugins/serve-index/get-paths.ts b/server/src/plugins/serve-index/get-paths.ts new file mode 100644 index 000000000..bbd2caa3e --- /dev/null +++ b/server/src/plugins/serve-index/get-paths.ts @@ -0,0 +1,40 @@ +import { INNSENDINGSYTELSER } from '@app/innsendingsytelser'; +import { CASE_TYPES, LANGUAGES, LOGGED_IN_STEPS, STEPS } from '@app/plugins/serve-index/segments'; + +export const getAnonymousPaths = (): string[] => { + const paths: string[] = []; + + for (const lang of LANGUAGES) { + for (const type of CASE_TYPES) { + for (const ytelse of INNSENDINGSYTELSER) { + paths.push(`/${lang}/${type}/${ytelse}`); + paths.push(`/${lang}/ettersendelse/${type}/${ytelse}`); + + for (const step of STEPS) { + paths.push(`/${lang}/${type}/${ytelse}/${step}`); + paths.push(`/${lang}/ettersendelse/${type}/${ytelse}/${step}`); + } + } + } + } + + return paths; +}; + +export const getLoggedInPaths = (): string[] => { + const paths: string[] = []; + + for (const lang of LANGUAGES) { + paths.push(`/${lang}/sak/:id`); + + for (const step of STEPS) { + paths.push(`/${lang}/sak/:id/${step}`); + } + + for (const step of LOGGED_IN_STEPS) { + paths.push(`/${lang}/sak/:id/${step}`); + } + } + + return paths; +}; diff --git a/server/src/middleware/redirect/functions.test.ts b/server/src/plugins/serve-index/remove-saksnummer.test.ts similarity index 93% rename from server/src/middleware/redirect/functions.test.ts rename to server/src/plugins/serve-index/remove-saksnummer.test.ts index e1707abcb..e961bb8b4 100644 --- a/server/src/middleware/redirect/functions.test.ts +++ b/server/src/plugins/serve-index/remove-saksnummer.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'bun:test'; -import { removeSaksnummer } from '@app/middleware/redirect/functions'; +import { removeSaksnummer } from '@app/plugins/serve-index/remove-saksnummer'; describe('redirect', () => { it('should remove the whole query when saksnummer is only param', () => { diff --git a/server/src/plugins/serve-index/remove-saksnummer.ts b/server/src/plugins/serve-index/remove-saksnummer.ts new file mode 100644 index 000000000..e3daf262b --- /dev/null +++ b/server/src/plugins/serve-index/remove-saksnummer.ts @@ -0,0 +1,19 @@ +export const removeSaksnummer = (url: string | undefined) => { + if (url === undefined) { + return undefined; + } + const [path, query] = url.split('?'); + + if (query === undefined) { + return path; + } + + const params = query.split('&'); + const filtered = params.filter((param) => !param.startsWith('saksnummer=')); + + if (filtered.length === 0) { + return path; + } + + return `${path}?${filtered.join('&')}`; +}; diff --git a/server/src/plugins/serve-index/segments.ts b/server/src/plugins/serve-index/segments.ts new file mode 100644 index 000000000..57f4fa398 --- /dev/null +++ b/server/src/plugins/serve-index/segments.ts @@ -0,0 +1,28 @@ +enum Language { + NB = 'nb', + NN = 'nn', + EN = 'en', +} + +export const LANGUAGES = Object.values(Language); + +enum CaseType { + KLAGE = 'klage', + ANKE = 'anke', +} + +export const CASE_TYPES = Object.values(CaseType); + +enum Step { + BEGRUNNELSE = 'begrunnelse', + OPPSUMMERING = 'oppsummering', + INNSENDING = 'innsending', +} + +export const STEPS = Object.values(Step); + +enum LoggedInStep { + KVITTERING = 'kvittering', +} + +export const LOGGED_IN_STEPS = Object.values(LoggedInStep); diff --git a/server/src/plugins/serve-index/serve-index.ts b/server/src/plugins/serve-index/serve-index.ts new file mode 100644 index 000000000..48c3e9c0d --- /dev/null +++ b/server/src/plugins/serve-index/serve-index.ts @@ -0,0 +1,28 @@ +import { indexFile } from '@app/index-file'; +import { API_PROXY_PLUGIN_ID } from '@app/plugins/api-proxy'; +import { LOCAL_DEV_PLUGIN_ID } from '@app/plugins/local-dev'; +import { viewCountCounter } from '@app/plugins/serve-index/counters'; +import { getAnonymousPaths, getLoggedInPaths } from '@app/plugins/serve-index/get-paths'; +import type { FastifyReply, FastifyRequest } from 'fastify'; +import fastifyPlugin from 'fastify-plugin'; + +const serveIndexHandler = async (_: FastifyRequest, reply: FastifyReply) => { + viewCountCounter.inc(); + + return reply.header('content-type', 'text/html').status(200).send(indexFile.indexFile); +}; + +export const SERVE_INDEX_PLUGIN_ID = 'serve-index'; + +export const serveIndexPlugin = fastifyPlugin( + async (app) => { + for (const path of getLoggedInPaths()) { + app.get(path, serveIndexHandler); + } + + for (const path of getAnonymousPaths()) { + app.get(path, serveIndexHandler); + } + }, + { fastify: '4', name: SERVE_INDEX_PLUGIN_ID, dependencies: [API_PROXY_PLUGIN_ID, LOCAL_DEV_PLUGIN_ID] }, +); diff --git a/server/src/plugins/server-timing.ts b/server/src/plugins/server-timing.ts new file mode 100644 index 000000000..7abf304ab --- /dev/null +++ b/server/src/plugins/server-timing.ts @@ -0,0 +1,178 @@ +import { getDuration } from '@app/helpers/duration'; +import fastifyPlugin from 'fastify-plugin'; + +const serverTimingsKey = Symbol('server-timings'); +const serverTimingStartsKey = Symbol('server-timing-starts'); +const serverTimingHeaders = Symbol('server-timing-headers'); + +interface ServerTimingStart { + start: number; + description?: string; +} + +interface ServerTiming { + name: string; + duration: number; + description?: string; +} + +type AddServerTimingFn = (name: string, duration: number, description?: string) => void; +type StartServerTimingFn = (name: string, description?: string) => void; +type EndServerTimingFn = (name: string) => void; +type TimedFn = () => Promise | void; +type MeasureServerTimingFn = (opts: { name: string; description?: string }, timedFn: TimedFn) => void; + +declare module 'fastify' { + interface FastifyReply { + /** + * Adds a server timing entry to the response. + * @param name - The name of the server timing entry. + * @param duration - The duration of the server timing entry in milliseconds. + * @param description - (Optional) A description of the server timing entry. + */ + readonly addServerTiming: AddServerTimingFn; + + /** + * Starts a server timing entry with the given name. + * @param name - The name of the server timing entry. + * @param description - (Optional) A description of the server timing entry. + */ + readonly startServerTiming: StartServerTimingFn; + + /** + * Ends the server timing entry with the given name and adds it to the response. + * @param name - The name of the server timing entry. + */ + readonly endServerTiming: EndServerTimingFn; + + readonly measureServerTiming: MeasureServerTimingFn; + + readonly appendServerTimingHeader: (headerValue: string) => void; + + /** + * Server timing entries that will be added to the response. + */ + [serverTimingsKey]: ServerTiming[]; + [serverTimingStartsKey]: Map; + /** + * Server timing headers that will be added to the response. + * From services that this service calls. + */ + [serverTimingHeaders]: string[]; + } + + interface FastifyRequest { + /** + * The start time of the request. + */ + startTime: number; + + /** + * Returns the duration of the request. + */ + readonly getResponseTime: () => number; + } +} + +export const SERVER_TIMING_HEADER = 'server-timing'; + +/** + * Options for the Server Timing plugin. + */ +interface ServerTimingPluginOptions { + /** + * Whether to automatically add a total server timing entry to the response. + */ + enableAutoTotal?: boolean; +} + +export const SERVER_TIMING_PLUGIN_ID = 'server-timing'; + +/** + * Fastify plugin that adds server timing functionality. + * + * Adds and exposes the following functionality to the `reply` object: + * - `reply.addServerTiming(name, duration, description)`: Adds a server timing entry to the response. + * - `reply.startServerTiming(name, description)`: Starts a server timing entry with the given name. + * - `reply.endServerTiming(name)`: Ends the server timing entry with the given name and adds it to the response. + * + * Adds and exposes the following functionality to the `request` object: + * - `request.startTime`: The start time of the request. + * - `request.getResponseTime()`: Returns the duration of the request. + */ +export const serverTimingPlugin = fastifyPlugin( + async (app, { enableAutoTotal = true }) => { + app.decorateRequest('startTime', 0); + app.decorateReply(serverTimingsKey, null); + app.decorateReply | null>(serverTimingStartsKey, null); + app.decorateReply(serverTimingHeaders, null); + + app.addHook('onRequest', (req, reply, done) => { + reply[serverTimingsKey] = []; + reply[serverTimingStartsKey] = new Map(); + reply[serverTimingHeaders] = []; + req.startTime = performance.now(); + done(); + }); + + app.decorateRequest('getResponseTime', function () { + return getDuration(this.startTime); + }); + + app.decorateReply('addServerTiming', function (name: string, duration: number, description?: string) { + this[serverTimingsKey].push({ name, duration, description }); + }); + + app.decorateReply('startServerTiming', function (name: string, description?: string) { + this[serverTimingStartsKey].set(name, { start: performance.now(), description }); + }); + + app.decorateReply('endServerTiming', function (name: string) { + const start = this[serverTimingStartsKey].get(name); + + if (start === undefined) { + return; + } + + const duration = getDuration(start.start); + + this[serverTimingsKey].push({ name, duration, description: start.description }); + }); + + app.decorateReply('measureServerTiming', async function ({ name, description }, timedFn) { + const start = performance.now(); + await timedFn(); + this.addServerTiming(name, getDuration(start), description); + }); + + app.decorateReply('appendServerTimingHeader', function (serverTimingHeader: string) { + this[serverTimingHeaders].push(serverTimingHeader); + }); + + app.addHook('onSend', async (req, reply) => { + if (enableAutoTotal) { + reply.addServerTiming('total', req.getResponseTime(), 'Total'); + } + + const existingServerTimingHeader = reply.getHeader(SERVER_TIMING_HEADER); + + const serverTimingHeader = serverTimingsToHeaderEntries(reply[serverTimingsKey]).concat( + reply[serverTimingHeaders], + ); + + if (typeof existingServerTimingHeader === 'string') { + serverTimingHeader.push(...existingServerTimingHeader.split(',').map((entry) => entry.trim())); + } else if (Array.isArray(existingServerTimingHeader)) { + serverTimingHeader.push(...existingServerTimingHeader); + } + + reply.header(SERVER_TIMING_HEADER, serverTimingHeader.join(', ')); + }); + }, + { fastify: '4', name: SERVER_TIMING_PLUGIN_ID }, +); + +const serverTimingsToHeaderEntries = (serverTimings: ServerTiming[]): string[] => + serverTimings.map(({ name, duration, description }) => + description === undefined ? `${name};dur=${duration}` : `${name};dur=${duration};desc="${description}"`, + ); diff --git a/server/src/plugins/traceparent/traceparent.ts b/server/src/plugins/traceparent/traceparent.ts new file mode 100644 index 000000000..deea36fdf --- /dev/null +++ b/server/src/plugins/traceparent/traceparent.ts @@ -0,0 +1,69 @@ +import { + generateSpanId, + generateTraceId, + generateTraceparent, + getTraceIdAndSpanIdFromTraceparent, +} from '@app/helpers/traceparent'; +import type { FastifyRequest } from 'fastify'; +import fastifyPlugin from 'fastify-plugin'; + +declare module 'fastify' { + interface FastifyRequest { + traceparent: string; + trace_id: string; + span_id: string; + } +} + +export const TRACEPARENT_PLUGIN_ID = 'traceparent'; + +export const traceparentPlugin = fastifyPlugin( + async (app) => { + app.decorateRequest('traceparent', ''); + app.decorateRequest('trace_id', ''); + app.decorateRequest('span_id', ''); + + app.addHook('preHandler', async (req: FastifyRequest<{ Querystring: Record }>) => { + const { trace_id, span_id, traceparent } = getTraceIdAndSpanId(req); + req.trace_id = trace_id; + req.span_id = span_id; + req.traceparent = traceparent; + }); + }, + { fastify: '4', name: TRACEPARENT_PLUGIN_ID }, +); + +const TRACEPARENT_HEADER = 'traceparent'; +const TRACEPARENT_QUERY = 'traceparent'; + +const getTraceIdAndSpanId = ({ + headers, + query, + client_version: clientVersion, +}: FastifyRequest<{ Querystring: Record }>) => { + const traceparentHeader = headers[TRACEPARENT_HEADER]; + + if (typeof traceparentHeader === 'string' && traceparentHeader.length !== 0) { + const { trace_id, span_id } = getTraceIdAndSpanIdFromTraceparent(traceparentHeader, clientVersion); + + if (trace_id !== undefined && span_id !== undefined) { + return { trace_id, span_id, traceparent: traceparentHeader }; + } + } + + const traceparentQuery = query[TRACEPARENT_QUERY]; + + if (typeof traceparentQuery === 'string' && traceparentQuery.length !== 0) { + const { trace_id, span_id } = getTraceIdAndSpanIdFromTraceparent(traceparentQuery, clientVersion); + + if (trace_id !== undefined && span_id !== undefined) { + return { trace_id, span_id, traceparent: traceparentQuery }; + } + } + + const trace_id = generateTraceId(); + const span_id = generateSpanId(); + const traceparent = generateTraceparent(trace_id, span_id); + + return { trace_id, span_id, traceparent }; +}; diff --git a/server/src/process-errors.ts b/server/src/process-errors.ts index cf3ea1f73..c248254fe 100644 --- a/server/src/process-errors.ts +++ b/server/src/process-errors.ts @@ -1,4 +1,4 @@ -import { getLogger } from './logger/logger'; +import { getLogger } from '@app/logger'; import { EmojiIcons, sendToSlack } from './slack'; const log = getLogger(''); @@ -6,15 +6,15 @@ const log = getLogger(''); export const processErrors = () => { process .on('unhandledRejection', (reason, promise) => { - log.error({ error: reason, message: `Process ${process.pid} received a unhandledRejection signal` }); + log.error({ error: reason, msg: `Process ${process.pid} received a unhandledRejection signal` }); - promise.catch((error: unknown) => log.error({ error, message: `Uncaught error` })); + promise.catch((error: unknown) => log.error({ error, msg: 'Uncaught error' })); }) .on('uncaughtException', (error) => - log.error({ error, message: `Process ${process.pid} received a uncaughtException signal` }), + log.error({ error, msg: `Process ${process.pid} received a uncaughtException signal` }), ) .on('SIGTERM', (signal) => { - log.info({ message: `Process ${process.pid} received a ${signal} signal.` }); + log.info({ msg: `Process ${process.pid} received a ${signal} signal.` }); process.exit(0); }) .on('SIGINT', (signal) => { @@ -23,8 +23,8 @@ export const processErrors = () => { process.exit(1); }) .on('beforeExit', async (code) => { - const message = `Crash ${JSON.stringify(code)}`; - log.error({ message }); - sendToSlack(message, EmojiIcons.Scream); + const msg = `Crash ${JSON.stringify(code)}`; + log.error({ msg }); + sendToSlack(msg, EmojiIcons.Scream); }); }; diff --git a/server/src/prometheus/middleware.ts b/server/src/prometheus/middleware.ts deleted file mode 100644 index bd349f020..000000000 --- a/server/src/prometheus/middleware.ts +++ /dev/null @@ -1,28 +0,0 @@ -import promBundle from 'express-prom-bundle'; -import { register } from 'prom-client'; -import { NAIS_NAMESPACE } from '@app/config/env'; -import { VERSION } from '@app/config/version'; -import { normalizePath } from './normalize-path'; - -const labels: Record = { - app_version: VERSION.substring(0, 7), - namespace: NAIS_NAMESPACE, -}; - -export const metricsMiddleware = promBundle({ - includeMethod: true, - includePath: true, - includeStatusCode: true, - includeUp: true, - excludeRoutes: ['/metrics', '/isAlive', '/isReady'], - normalizePath: ({ originalUrl }) => normalizePath(originalUrl), - customLabels: labels, - promRegistry: register, - formatStatusCode: ({ statusCode }) => { - if (statusCode >= 200 && statusCode < 400) { - return '2xx (3xx)'; - } - - return statusCode; - }, -}); diff --git a/server/src/prometheus/types.ts b/server/src/prometheus/types.ts index ac49e8eac..a9be55cad 100644 --- a/server/src/prometheus/types.ts +++ b/server/src/prometheus/types.ts @@ -1,10 +1,11 @@ +import { PROXY_VERSION } from '@app/config/config'; +import { NAIS_NAMESPACE, POD_NAME } from '@app/config/env'; import { register } from 'prom-client'; -import { NAIS_NAMESPACE } from '@app/config/env'; -import { VERSION } from '@app/config/version'; register.setDefaultLabels({ - app_version: VERSION.substring(0, 7), + app_version: PROXY_VERSION.substring(0, 7), namespace: NAIS_NAMESPACE, + pod_name: POD_NAME, }); -export const registers = [register]; +export const proxyRegister = register; diff --git a/server/src/routes/app-handler.ts b/server/src/routes/app-handler.ts deleted file mode 100644 index 482026d6c..000000000 --- a/server/src/routes/app-handler.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Handler } from 'express'; -import { getLogger } from '@app/logger/logger'; -import { indexFile } from './index-file'; - -const log = getLogger('static-routes'); - -export const appHandler: Handler = (_, res) => { - try { - res.setHeader('Content-Type', 'text/html'); - res.send(indexFile.indexFile); - } catch (error) { - log.error({ error, message: 'HTTP 500 - Failed to send index file' }); - res.status(500).send('

500 Internal Server Error

'); - } -}; diff --git a/server/src/routes/error-report.ts b/server/src/routes/error-report.ts deleted file mode 100644 index 2ffba0111..000000000 --- a/server/src/routes/error-report.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Request, Router, json } from 'express'; -import { SerializableObject, getLogger } from '@app/logger/logger'; - -const router = Router(); - -const log = getLogger('frontend-error-reporter'); - -export const errorReporter = () => { - router.post('/error-report', json(), (req: Request, res) => { - log.warn({ message: 'Error report', data: req.body }); - res.status(200).send(); - }); - - return router; -}; diff --git a/server/src/routes/frontend-log.ts b/server/src/routes/frontend-log.ts deleted file mode 100644 index 711760974..000000000 --- a/server/src/routes/frontend-log.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Request, Router, json } from 'express'; -import { Level, SerializableObject, getLogger } from '@app/logger/logger'; -import { FrontendEventTypes, FrontendLogEvent } from '@app/logger/types'; - -const router = Router(); - -const log = getLogger('frontend-log'); - -export const frontendLog = () => { - router.post('/frontend-log', json(), (req: Request, res) => { - if (!isFrontendLog(req.body)) { - return res.status(400).send(); - } - - const { level, message, ...data } = req.body; - - log[level]({ message, data }); - - res.status(200).send(); - }); - - return router; -}; - -const LOG_LEVELS = Object.values(Level); -const EVENT_TYPES = Object.values(FrontendEventTypes); - -const isFrontendLog = (data: unknown): data is FrontendLogEvent => { - if (typeof data !== 'object' || data === null) { - return false; - } - - return ( - 'level' in data && - typeof data.level === 'string' && - LOG_LEVELS.some((level) => level === data.level) && - 'type' in data && - typeof data.type === 'string' && - EVENT_TYPES.some((type) => type === data.type) - ); -}; diff --git a/server/src/routes/index-file.ts b/server/src/routes/index-file.ts deleted file mode 100644 index e03f51273..000000000 --- a/server/src/routes/index-file.ts +++ /dev/null @@ -1,75 +0,0 @@ -import path from 'path'; -import { performance } from 'perf_hooks'; -import { injectDecoratorServerSide } from '@navikt/nav-dekoratoren-moduler/ssr'; -import { frontendDistDirectoryPath } from '@app/config/config'; -import { ENVIRONMENT, isDeployedToProd } from '@app/config/env'; -import { VERSION } from '@app/config/version'; -import { getLogger } from '@app/logger/logger'; -import { EmojiIcons, sendToSlack } from '@app/slack'; - -const log = getLogger('index-file'); - -class IndexFile { - private readonly INDEX_HTML_PATH = path.join(frontendDistDirectoryPath, 'index.html'); - - private _isReady = false; - public get isReady() { - return this._isReady; - } - - private _indexFile = ''; - public get indexFile() { - return this._indexFile; - } - - constructor() { - this.init(); - } - - private async init() { - await this.generateFile(); - this.setReady(); - setInterval(this.generateFile, 60 * 1000); - } - - private setReady = () => { - this._isReady = true; - }; - - private generateFile = async (): Promise => { - try { - const start = performance.now(); - - const indexHtml = await injectDecoratorServerSide({ - env: isDeployedToProd ? 'prod' : 'dev', - filePath: this.INDEX_HTML_PATH, - params: { - simple: true, - chatbot: true, - redirectToApp: true, - logoutUrl: '/oauth2/logout', - context: 'privatperson', - level: 'Level4', - logoutWarning: true, - }, - }); - - const end = performance.now(); - - this._indexFile = indexHtml.replace('{{ENVIRONMENT}}', ENVIRONMENT).replace('{{VERSION}}', VERSION); - - log.debug({ - message: `Successfully updated index.html with Dekoratøren and variables.`, - data: { responseTime: Math.round(end - start) }, - }); - } catch (error) { - log.error({ error, message: 'Failed to update index.html with Dekoratøren and variables' }); - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - sendToSlack(`Error when generating index file: ${errorMessage}`, EmojiIcons.Scream); - } - - return this; - }; -} - -export const indexFile = new IndexFile(); diff --git a/server/src/routes/setup-proxy.ts b/server/src/routes/setup-proxy.ts deleted file mode 100644 index be5d51082..000000000 --- a/server/src/routes/setup-proxy.ts +++ /dev/null @@ -1,84 +0,0 @@ -import http from 'http'; -import { Socket } from 'net'; -import { Router } from 'express'; -import { createProxyMiddleware } from 'http-proxy-middleware'; -import { OBO_CLIENT_IDS, PROXIED_CLIENT_IDS } from '@app/config/config'; -import { setOboToken } from '@app/functions/set-obo'; -import { getLogger } from '@app/logger/logger'; - -const log = getLogger('proxy'); - -export const setupProxy = async () => { - const router = Router(); - - OBO_CLIENT_IDS.forEach((appName) => { - const route = `/api/${appName}`; - - router.use(route, async (req, _, next) => { - await setOboToken(req, appName); - next(); - }); - }); - - PROXIED_CLIENT_IDS.forEach((appName) => { - const proxy = createProxyMiddleware({ - target: `http://${appName}`, - pathRewrite: { - [`^/api/${appName}`]: '', - }, - on: { - error: (error, req, res) => { - if (!isServerResponse(res)) { - log.error({ - message: 'Response is not a ServerResponse.', - error, - data: { appName, url: req.url, method: req.method }, - }); - - return; - } - - if (res.headersSent) { - log.error({ - message: 'Headers already sent.', - error, - data: { - appName, - statusCode: res.statusCode, - url: req.url, - method: req.method, - }, - }); - - return; - } - - res.writeHead(500, { 'Content-Type': 'application/json' }); - const body = JSON.stringify({ error: `Failed to connect to API. Reason: ${error.message}` }); - res.end(body); - log.error({ - message: 'Failed to connect to API.', - error, - data: { appName, url: req.url, method: req.method }, - }); - }, - }, - logger: log, - changeOrigin: true, - }); - - router.use(`/api/${appName}`, proxy); - }); - - return router; -}; - -const isServerResponse = ( - res: http.ServerResponse | Socket, -): res is http.ServerResponse => - 'headersSent' in res && - typeof res.headersSent === 'boolean' && - 'writeHead' in res && - typeof res.writeHead === 'function' && - 'end' in res && - typeof res.end === 'function'; diff --git a/server/src/server.ts b/server/src/server.ts index 3f0fce557..1e01ba685 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -1,84 +1,68 @@ -import compression, { filter } from 'compression'; -import cors from 'cors'; -import express, { NextFunction, Request, Response } from 'express'; -import { DOMAIN, isDeployed, isDeployedToProd } from './config/env'; -import { init } from './init'; -import { getLogger, httpLoggingMiddleware } from './logger/logger'; -import { processErrors } from './process-errors'; -import { metricsMiddleware } from './prometheus/middleware'; -import { indexFile } from './routes/index-file'; -import { EmojiIcons, sendToSlack } from './slack'; +import { API_CLIENT_IDS, PORT } from '@app/config/config'; +import { corsOptions } from '@app/config/cors'; +import { isDeployed } from '@app/config/env'; +import { querystringParser } from '@app/helpers/query-parser'; +import { init } from '@app/init'; +import { getLogger } from '@app/logger'; +import { accessTokenPlugin } from '@app/plugins/access-token'; +import { apiProxyPlugin } from '@app/plugins/api-proxy'; +import { clientVersionPlugin } from '@app/plugins/client-version'; +import { errorReportPlugin } from '@app/plugins/error-report'; +import { frontendLogPlugin } from '@app/plugins/frontend-log/frontend-log'; +import { healthPlugin } from '@app/plugins/health'; +import { httpLoggerPlugin } from '@app/plugins/http-logger'; +import { localDevPlugin } from '@app/plugins/local-dev'; +import { notFoundPlugin } from '@app/plugins/not-found'; +import { oboAccessTokenPlugin } from '@app/plugins/obo-token'; +import { proxyVersionPlugin } from '@app/plugins/proxy-version'; +import { serveIndexPlugin } from '@app/plugins/serve-index/serve-index'; +import { serverTimingPlugin } from '@app/plugins/server-timing'; +import { traceparentPlugin } from '@app/plugins/traceparent/traceparent'; +import { processErrors } from '@app/process-errors'; +import { EmojiIcons, sendToSlack } from '@app/slack'; +import cors from '@fastify/cors'; +import { fastify } from 'fastify'; +import metricsPlugin from 'fastify-metrics'; processErrors(); const log = getLogger('server'); if (isDeployed) { - log.info({ message: 'Started!' }); - sendToSlack('Started!', EmojiIcons.StartStruck); -} - -const server = express(); - -// Add the prometheus middleware to all routes -server.use(metricsMiddleware); - -server.use(httpLoggingMiddleware); + log.info({ msg: 'Starting...' }); -server.set('trust proxy', true); -server.disable('x-powered-by'); - -const shouldCompress = (req: Request, res: Response) => { - if (res.get('Content-Type') === 'text/event-stream' || req.path.endsWith('.map')) { - return false; - } - - return filter(req, res); -}; + sendToSlack('Starting...', EmojiIcons.LoadingDots); +} -server.use(compression({ filter: shouldCompress })); +const bodyLimit = 300 * 1024 * 1024; // 300 MB -server.use((_: Request, res: Response, next: NextFunction) => { - res.header('X-Frame-Options', 'SAMEORIGIN'); - res.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); - res.header('X-Content-Type-Options', 'nosniff'); - res.header('X-XSS-Protection', '1; mode=block'); - res.header('Referrer-Policy', 'no-referrer-when-downgrade'); - next(); -}); +fastify({ trustProxy: true, querystringParser, bodyLimit }) + .register(cors, corsOptions) + .register(healthPlugin) + .register(metricsPlugin, { + endpoint: '/metrics', + routeMetrics: { + routeBlacklist: ['/metrics', '/isAlive', '/isReady', '/swagger', '/swagger.json'], + }, + }) + .register(proxyVersionPlugin) + .register(traceparentPlugin) + .register(clientVersionPlugin) + .register(serverTimingPlugin, { enableAutoTotal: true }) + .register(frontendLogPlugin) + .register(errorReportPlugin) + .register(accessTokenPlugin) + .register(oboAccessTokenPlugin) + .register(apiProxyPlugin, { appNames: API_CLIENT_IDS, prefix: '/api' }) + .register(localDevPlugin) + .register(serveIndexPlugin) + .register(notFoundPlugin) + .register(httpLoggerPlugin) -server.use( - cors({ - credentials: true, - methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE'], - allowedHeaders: [ - 'Accept-Language', - 'Accept', - 'Cache-Control', - 'Connection', - 'Content-Type', - 'Cookie', - 'DNT', - 'Host', - 'Origin', - 'Pragma', - 'Referer', - 'Sec-Fetch-Dest', - 'Sec-Fetch-Mode', - 'Sec-Fetch-Site', - 'User-Agent', - 'X-Forwarded-For', - 'X-Forwarded-Host', - 'X-Forwarded-Proto', - 'X-Requested-With', - ], - origin: isDeployedToProd ? DOMAIN : [DOMAIN, /https?:\/\/localhost:\d{4,}/], - }), -); + // Start server. + .listen({ host: '0.0.0.0', port: PORT }); -server.get('/isAlive', (_, res) => res.status(200).send('Alive')); -server.get('/isReady', (_, res) => - res.status(indexFile.isReady ? 200 : 418).send(indexFile.isReady ? 'Ready' : 'Not ready'), -); +log.info({ msg: `Server listening on port ${PORT}` }); -init(server); +// Initialize. +init(); diff --git a/server/src/slack.ts b/server/src/slack.ts index d595a0f3c..52ac75ca3 100644 --- a/server/src/slack.ts +++ b/server/src/slack.ts @@ -1,46 +1,51 @@ -import fetch from 'node-fetch'; -import { slack } from './config/config'; -import { ENVIRONMENT, isDeployed } from './config/env'; -import { getLogger } from './logger/logger'; +import { ENVIRONMENT, isDeployed, isLocal } from '@app/config/env'; +import { optionalEnvString, requiredEnvString } from '@app/config/env-var'; +import { getLogger } from '@app/logger'; const log = getLogger('slack'); export enum EmojiIcons { - StartStruck = ':star-struck:', + Tada = ':tada:', + LoadingDots = ':loading-dots:', + Broken = ':broken:', + Collision = ':collision:', Scream = ':scream:', } -const { url, channel, messagePrefix } = slack; -const isConfigured = typeof url === 'string' && url.length !== 0; +const url = optionalEnvString('SLACK_URL'); +const channel = '#klage-notifications'; +const messagePrefix = `${requiredEnvString('NAIS_APP_NAME', 'klang-frontend')} frontend NodeJS -`; +const isConfigured = url !== undefined && url.length !== 0; -export const sendToSlack = async (slackMessage: string, icon_emoji: EmojiIcons) => { - const text = `[${ENVIRONMENT}] ${messagePrefix} ${slackMessage}`; +export const sendToSlack = async (message: string, icon_emoji: EmojiIcons) => { + const text = `[${ENVIRONMENT}] ${messagePrefix} ${message}`; - if (!isDeployed || !isConfigured) { + if (!(isDeployed && isConfigured)) { return; } - const body = JSON.stringify({ - channel, - text, - icon_emoji, - }); + const body = JSON.stringify({ channel, text, icon_emoji }); - return fetch(url, { - method: 'POST', - body, - }).catch((error: unknown) => { - const message = `Failed to send message to Slack. Message: '${text}'`; + if (isLocal) { + log.info({ msg: `Sending message to Slack: ${text}` }); + + return; + } + + try { + await fetch(url, { method: 'POST', body }); + } catch (error) { + const msg = `Failed to send message to Slack. Message: '${text}'`; // Don't log the error object since it contains webhook URL if (error instanceof Error) { - log.error({ message: scrubWebhookUrl(`${message} - ${error.name}: ${error.message}`) }); + log.error({ msg: scrubWebhookUrl(`${msg} - ${error.name}: ${error.message}`) }); } - log.error({ message: scrubWebhookUrl(message) }); - }); + log.error({ msg: scrubWebhookUrl(msg) }); + } }; -const URL_REGEXP = new RegExp(url, 'g'); +const URL_REGEXP = url === undefined ? '' : new RegExp(url, 'g'); const scrubWebhookUrl = (errorText: string) => errorText.replace(URL_REGEXP, '[REDACTED]'); diff --git a/server/src/types/http.ts b/server/src/types/http.ts deleted file mode 100644 index 5ffa12f5b..000000000 --- a/server/src/types/http.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { CookieOptions, Request as ExpressRequest, Response as ExpressResponse } from 'express'; - -export type Request = Pick; - -export interface Response extends Pick { - cookie: (name: string, value: string, options: CookieOptions) => void; -} diff --git a/server/tsconfig.json b/server/tsconfig.json index 1ab7a7eb7..979ca1953 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -1,9 +1,7 @@ { "compilerOptions": { - // enable latest features - "lib": [ - "ESNext" - ], + // Enable latest features + "lib": ["ESNext"], "target": "ESNext", "module": "ESNext", "moduleDetection": "force", @@ -17,7 +15,6 @@ "noFallthroughCasesInSwitch": true, // Some stricter flags "useUnknownInCatchVariables": true, - "noPropertyAccessFromIndexSignature": true, "sourceMap": true, "allowJs": false, "esModuleInterop": true, @@ -30,17 +27,11 @@ "outDir": "./dist", "baseUrl": "./src", "paths": { - "@app/*": [ - "*" - ] + "@app/*": ["*"] }, "incremental": true, - "tsBuildInfoFile": ".tsbuildinfo", + "tsBuildInfoFile": ".tsbuildinfo" }, - "include": [ - "src/**/*" - ], - "exclude": [ - "./dist/**" - ] + "include": ["src/**/*"], + "exclude": ["./dist/**"] }