From 84c94d181cca1bf6f918149dab6cee9b496e96ab Mon Sep 17 00:00:00 2001 From: Joonas Javanainen Date: Mon, 9 Dec 2024 12:17:56 +0200 Subject: [PATCH 1/9] Minor SAML integration and logout redesign --- apigw/src/app.ts | 194 +++----- apigw/src/enduser/keycloak-citizen-saml.ts | 29 +- apigw/src/enduser/suomi-fi-saml.ts | 29 +- apigw/src/internal/ad-saml.ts | 73 ++- apigw/src/internal/keycloak-employee-saml.ts | 29 +- apigw/src/shared/auth/dev-auth.ts | 1 + apigw/src/shared/config.ts | 12 +- apigw/src/shared/routes/saml.ts | 484 +++++++++---------- apigw/src/shared/saml/index.ts | 6 +- 9 files changed, 445 insertions(+), 412 deletions(-) diff --git a/apigw/src/app.ts b/apigw/src/app.ts index d5698b9d9bb..4a80d966695 100644 --- a/apigw/src/app.ts +++ b/apigw/src/app.ts @@ -2,20 +2,19 @@ // // SPDX-License-Identifier: LGPL-2.1-or-later -import { SAML } from '@node-saml/node-saml' import cookieParser from 'cookie-parser' import express from 'express' import expressBasicAuth from 'express-basic-auth' import { createDevSfiRouter } from './enduser/dev-sfi-auth.js' -import { authenticateKeycloakCitizen } from './enduser/keycloak-citizen-saml.js' +import { createKeycloakCitizenIntegration } from './enduser/keycloak-citizen-saml.js' import mapRoutes from './enduser/mapRoutes.js' import { citizenAuthStatus } from './enduser/routes/auth-status.js' import { authWeakLogin } from './enduser/routes/auth-weak-login.js' -import { authenticateSuomiFi } from './enduser/suomi-fi-saml.js' -import { authenticateAd } from './internal/ad-saml.js' +import { createSuomiFiIntegration } from './enduser/suomi-fi-saml.js' +import { createSamlAdIntegration } from './internal/ad-saml.js' import { createDevAdRouter } from './internal/dev-ad-auth.js' -import { authenticateKeycloakEmployee } from './internal/keycloak-employee-saml.js' +import { createKeycloakEmployeeIntegration } from './internal/keycloak-employee-saml.js' import { checkMobileEmployeeIdToken, devApiE2ESignup, @@ -32,16 +31,14 @@ import { enableDevApi, titaniaConfig } from './shared/config.js' -import { toMiddleware } from './shared/express.js' +import { toRequestHandler } from './shared/express.js' import { cacheControl } from './shared/middleware/cache-control.js' import { csrf } from './shared/middleware/csrf.js' import { errorHandler } from './shared/middleware/error-handler.js' import { createProxy } from './shared/proxy-utils.js' import { RedisClient } from './shared/redis-client.js' import { handleCspReport } from './shared/routes/csp.js' -import createSamlRouter from './shared/routes/saml.js' -import { createSamlConfig } from './shared/saml/index.js' -import redisCacheProvider from './shared/saml/node-saml-cache-redis.js' +import { SamlIntegration } from './shared/routes/saml.js' import { sessionSupport } from './shared/session.js' export function apiRouter(config: Config, redisClient: RedisClient) { @@ -142,95 +139,26 @@ export function apiRouter(config: Config, redisClient: RedisClient) { getUserHeader: (req) => employeeMobileSessions.getUserHeader(req) }) - router.use('/citizen/auth/logout', citizenSessions.middleware) - router.use('/employee/auth/logout', employeeSessions.middleware) - router.use( - toMiddleware(async (req, res) => { - if (req.path === '/citizen/auth/logout') { - const user = citizenSessions.getUser(req) - switch (user?.authType) { - case 'sfi': - req.url = req.url.replace( - '/citizen/auth/logout', - '/citizen/auth/sfi/logout' - ) - break - case 'keycloak-citizen': - req.url = req.url.replace( - '/citizen/auth/logout', - '/citizen/auth/keycloak/logout' - ) - break - default: - await citizenSessions.destroy(req, res) - res.redirect('/citizen') - } - } else if (req.path === '/employee/auth/logout') { - const user = employeeSessions.getUser(req) - switch (user?.authType) { - case 'ad': - req.url = req.url.replace( - '/employee/auth/logout', - '/employee/auth/ad/logout' - ) - break - case 'keycloak-employee': - req.url = req.url.replace( - '/employee/auth/logout', - '/employee/auth/keycloak/logout' - ) - break - default: - await employeeSessions.destroy(req, res) - res.redirect('/employee') - } - } - }) - ) - + let sfiIntegration: SamlIntegration | undefined if (config.sfi.type === 'mock') { - router.use( - '/citizen/auth/sfi/', - citizenSessions.middleware, - createDevSfiRouter(citizenSessions) - ) + router.use('/citizen/auth/sfi', createDevSfiRouter(citizenSessions)) } else if (config.sfi.type === 'saml') { - router.use( - '/citizen/auth/sfi/', - citizenSessions.middleware, - createSamlRouter({ - sessions: citizenSessions, - strategyName: 'suomifi', - saml: new SAML( - createSamlConfig( - config.sfi.saml, - redisCacheProvider(redisClient, { keyPrefix: 'suomifi-saml-resp:' }) - ) - ), - authenticate: authenticateSuomiFi, - defaultPageUrl: '/' - }) + sfiIntegration = createSuomiFiIntegration( + citizenSessions, + config.sfi.saml, + redisClient ) + router.use('/citizen/auth/sfi', sfiIntegration.router) } if (!config.keycloakCitizen) throw new Error('Missing Keycloak SAML configuration (citizen)') - router.use( - '/citizen/auth/keycloak/', - citizenSessions.middleware, - createSamlRouter({ - sessions: citizenSessions, - strategyName: 'evaka-customer', - saml: new SAML( - createSamlConfig( - config.keycloakCitizen, - redisCacheProvider(redisClient, { keyPrefix: 'customer-saml-resp:' }) - ) - ), - authenticate: authenticateKeycloakCitizen, - defaultPageUrl: '/' - }) + const keycloakCitizenIntegration = createKeycloakCitizenIntegration( + citizenSessions, + config.keycloakCitizen, + redisClient ) + router.use('/citizen/auth/keycloak', keycloakCitizenIntegration.router) router.all( '/employee/auth/ad/*', @@ -247,49 +175,26 @@ export function apiRouter(config: Config, redisClient: RedisClient) { } ) + let adIntegration: SamlIntegration | undefined if (config.ad.type === 'mock') { - router.use( - '/employee/auth/ad/', - employeeSessions.middleware, - createDevAdRouter(employeeSessions) - ) + router.use('/employee/auth/ad', createDevAdRouter(employeeSessions)) } else if (config.ad.type === 'saml') { - router.use( - '/employee/auth/ad/', - employeeSessions.middleware, - createSamlRouter({ - sessions: employeeSessions, - strategyName: 'ead', - saml: new SAML( - createSamlConfig( - config.ad.saml, - redisCacheProvider(redisClient, { keyPrefix: 'ad-saml-resp:' }) - ) - ), - authenticate: authenticateAd(config.ad), - defaultPageUrl: '/employee' - }) + adIntegration = createSamlAdIntegration( + employeeSessions, + config.ad, + redisClient ) + router.use('/employee/auth/ad', adIntegration.router) } if (!config.keycloakEmployee) throw new Error('Missing Keycloak SAML configuration (employee)') - router.use( - '/employee/auth/keycloak/', - employeeSessions.middleware, - createSamlRouter({ - sessions: employeeSessions, - strategyName: 'evaka', - saml: new SAML( - createSamlConfig( - config.keycloakEmployee, - redisCacheProvider(redisClient, { keyPrefix: 'keycloak-saml-resp:' }) - ) - ), - authenticate: authenticateKeycloakEmployee, - defaultPageUrl: '/employee' - }) + const keycloakEmployeeIntegration = createKeycloakEmployeeIntegration( + employeeSessions, + config.keycloakEmployee, + redisClient ) + router.use('/employee/auth/keycloak', keycloakEmployeeIntegration.router) if (enableDevApi) { router.get( @@ -306,18 +211,51 @@ export function apiRouter(config: Config, redisClient: RedisClient) { ) } + router.get( + '/citizen/auth/logout', + citizenSessions.middleware, + toRequestHandler(async (req, res) => { + const user = citizenSessions.getUser(req) + switch (user?.authType) { + case 'sfi': + if (sfiIntegration) return await sfiIntegration.logout(req, res) + break + case 'keycloak-citizen': + return keycloakCitizenIntegration.logout(req, res) + } + await citizenSessions.destroy(req, res) + res.redirect('/') + }) + ) + router.get( + '/employee/auth/logout', + employeeSessions.middleware, + toRequestHandler(async (req, res) => { + const user = employeeSessions.getUser(req) + switch (user?.authType) { + case 'ad': + if (adIntegration) return adIntegration.logout(req, res) + break + case 'keycloak-employee': + return keycloakEmployeeIntegration.logout(req, res) + } + await employeeSessions.destroy(req, res) + res.redirect('/employee') + }) + ) + // CSRF checks apply to all the API endpoints that frontend uses router.use(csrf) - router.use('/citizen/', citizenSessions.middleware) - router.use('/citizen/public/map-api/', mapRoutes) - router.all('/citizen/public/*', citizenProxy) + router.use('/citizen', citizenSessions.middleware) router.get('/citizen/auth/status', citizenAuthStatus(citizenSessions)) router.post( '/citizen/auth/weak-login', express.json(), authWeakLogin(citizenSessions, redisClient) ) + router.use('/citizen/public/map-api', mapRoutes) + router.all('/citizen/public/*', citizenProxy) router.all('/citizen/*', citizenSessions.requireAuthentication, citizenProxy) const internalSessions = sessionSupport( diff --git a/apigw/src/enduser/keycloak-citizen-saml.ts b/apigw/src/enduser/keycloak-citizen-saml.ts index 69aac779246..148118e97f4 100644 --- a/apigw/src/enduser/keycloak-citizen-saml.ts +++ b/apigw/src/enduser/keycloak-citizen-saml.ts @@ -2,10 +2,16 @@ // // SPDX-License-Identifier: LGPL-2.1-or-later +import { SAML } from '@node-saml/node-saml' import { z } from 'zod' -import { authenticateProfile } from '../shared/saml/index.js' +import { EvakaSamlConfig } from '../shared/config.js' +import { RedisClient } from '../shared/redis-client.js' +import { createSamlIntegration } from '../shared/routes/saml.js' +import { authenticateProfile, createSamlConfig } from '../shared/saml/index.js' +import redisCacheProvider from '../shared/saml/node-saml-cache-redis.js' import { citizenLogin } from '../shared/service-client.js' +import { Sessions } from '../shared/session.js' const Profile = z.object({ socialSecurityNumber: z.string(), @@ -14,7 +20,7 @@ const Profile = z.object({ email: z.string() }) -export const authenticateKeycloakCitizen = authenticateProfile( +export const authenticate = authenticateProfile( Profile, async (samlSession, profile) => { const socialSecurityNumber = profile.socialSecurityNumber @@ -36,3 +42,22 @@ export const authenticateKeycloakCitizen = authenticateProfile( } } ) + +export function createKeycloakCitizenIntegration( + sessions: Sessions<'citizen'>, + config: EvakaSamlConfig, + redisClient: RedisClient +) { + return createSamlIntegration({ + sessions, + strategyName: 'evaka-customer', + saml: new SAML( + createSamlConfig( + config, + redisCacheProvider(redisClient, { keyPrefix: 'customer-saml-resp:' }) + ) + ), + authenticate, + defaultPageUrl: '/' + }) +} diff --git a/apigw/src/enduser/suomi-fi-saml.ts b/apigw/src/enduser/suomi-fi-saml.ts index 070965f7204..0f8e2f03975 100644 --- a/apigw/src/enduser/suomi-fi-saml.ts +++ b/apigw/src/enduser/suomi-fi-saml.ts @@ -2,11 +2,17 @@ // // SPDX-License-Identifier: LGPL-2.1-or-later +import { SAML } from '@node-saml/node-saml' import { z } from 'zod' +import { EvakaSamlConfig } from '../shared/config.js' import { logWarn } from '../shared/logging.js' -import { authenticateProfile } from '../shared/saml/index.js' +import { RedisClient } from '../shared/redis-client.js' +import { createSamlIntegration } from '../shared/routes/saml.js' +import { authenticateProfile, createSamlConfig } from '../shared/saml/index.js' +import redisCacheProvider from '../shared/saml/node-saml-cache-redis.js' import { citizenLogin } from '../shared/service-client.js' +import { Sessions } from '../shared/session.js' // Suomi.fi e-Identification – Attributes transmitted on an identified user: // https://esuomi.fi/suomi-fi-services/suomi-fi-e-identification/14247-2/?lang=en @@ -23,7 +29,7 @@ const Profile = z.object({ const ssnRegex = /^[0-9]{6}[-+ABCDEFUVWXY][0-9]{3}[0-9ABCDEFHJKLMNPRSTUVWXY]$/ -export const authenticateSuomiFi = authenticateProfile( +const authenticate = authenticateProfile( Profile, async (samlSession, profile) => { const socialSecurityNumber = profile[SUOMI_FI_SSN_KEY]?.trim() @@ -44,3 +50,22 @@ export const authenticateSuomiFi = authenticateProfile( } } ) + +export function createSuomiFiIntegration( + sessions: Sessions<'citizen'>, + config: EvakaSamlConfig, + redisClient: RedisClient +) { + return createSamlIntegration({ + sessions, + strategyName: 'suomifi', + saml: new SAML( + createSamlConfig( + config, + redisCacheProvider(redisClient, { keyPrefix: 'suomifi-saml-resp:' }) + ) + ), + authenticate, + defaultPageUrl: '/' + }) +} diff --git a/apigw/src/internal/ad-saml.ts b/apigw/src/internal/ad-saml.ts index b8d6f82e059..8f6be02761f 100755 --- a/apigw/src/internal/ad-saml.ts +++ b/apigw/src/internal/ad-saml.ts @@ -2,14 +2,16 @@ // // SPDX-License-Identifier: LGPL-2.1-or-later +import { SAML } from '@node-saml/node-saml' import { z } from 'zod' -import { Config } from '../shared/config.js' -import { - authenticateProfile, - AuthenticateProfile -} from '../shared/saml/index.js' +import { EvakaSamlConfig } from '../shared/config.js' +import { RedisClient } from '../shared/redis-client.js' +import { createSamlIntegration } from '../shared/routes/saml.js' +import { authenticateProfile, createSamlConfig } from '../shared/saml/index.js' +import redisCacheProvider from '../shared/saml/node-saml-cache-redis.js' import { employeeLogin } from '../shared/service-client.js' +import { Sessions } from '../shared/session.js' const AD_GIVEN_NAME_KEY = 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname' @@ -29,23 +31,48 @@ const Profile = z }) .passthrough() -export const authenticateAd = (config: Config['ad']): AuthenticateProfile => - authenticateProfile(Profile, async (samlSession, profile) => { - const aad = profile[config.userIdKey] - if (!aad || typeof aad !== 'string') throw Error('No user ID in SAML data') - const person = await employeeLogin({ - externalId: `${config.externalIdPrefix}:${aad}`, - firstName: profile[AD_GIVEN_NAME_KEY] ?? '', - lastName: profile[AD_FAMILY_NAME_KEY] ?? '', - email: profile[AD_EMAIL_KEY], - employeeNumber: profile[AD_EMPLOYEE_NUMBER_KEY] - }) - return { - id: person.id, - authType: 'ad', - userType: 'EMPLOYEE', - globalRoles: person.globalRoles, - allScopedRoles: person.allScopedRoles, - samlSession +export function createSamlAdIntegration( + sessions: Sessions<'employee'>, + config: { + externalIdPrefix: string + userIdKey: string + saml: EvakaSamlConfig + }, + redisClient: RedisClient +) { + const authenticate = authenticateProfile( + Profile, + async (samlSession, profile) => { + const aad = profile[config.userIdKey] + if (!aad || typeof aad !== 'string') + throw Error('No user ID in SAML data') + const person = await employeeLogin({ + externalId: `${config.externalIdPrefix}:${aad}`, + firstName: profile[AD_GIVEN_NAME_KEY] ?? '', + lastName: profile[AD_FAMILY_NAME_KEY] ?? '', + email: profile[AD_EMAIL_KEY], + employeeNumber: profile[AD_EMPLOYEE_NUMBER_KEY] + }) + return { + id: person.id, + authType: 'ad', + userType: 'EMPLOYEE', + globalRoles: person.globalRoles, + allScopedRoles: person.allScopedRoles, + samlSession + } } + ) + return createSamlIntegration({ + sessions, + strategyName: 'ead', + saml: new SAML( + createSamlConfig( + config.saml, + redisCacheProvider(redisClient, { keyPrefix: 'ad-saml-resp:' }) + ) + ), + authenticate, + defaultPageUrl: '/employee' }) +} diff --git a/apigw/src/internal/keycloak-employee-saml.ts b/apigw/src/internal/keycloak-employee-saml.ts index d008440c142..c05bebb5400 100644 --- a/apigw/src/internal/keycloak-employee-saml.ts +++ b/apigw/src/internal/keycloak-employee-saml.ts @@ -2,10 +2,16 @@ // // SPDX-License-Identifier: LGPL-2.1-or-later +import { SAML } from '@node-saml/node-saml' import { z } from 'zod' -import { authenticateProfile } from '../shared/saml/index.js' +import { EvakaSamlConfig } from '../shared/config.js' +import { RedisClient } from '../shared/redis-client.js' +import { createSamlIntegration } from '../shared/routes/saml.js' +import { authenticateProfile, createSamlConfig } from '../shared/saml/index.js' +import redisCacheProvider from '../shared/saml/node-saml-cache-redis.js' import { employeeLogin } from '../shared/service-client.js' +import { Sessions } from '../shared/session.js' const Profile = z.object({ id: z.string(), @@ -14,7 +20,7 @@ const Profile = z.object({ lastName: z.string() }) -export const authenticateKeycloakEmployee = authenticateProfile( +const authenticate = authenticateProfile( Profile, async (samlSession, profile) => { const id = profile.id @@ -35,3 +41,22 @@ export const authenticateKeycloakEmployee = authenticateProfile( } } ) + +export function createKeycloakEmployeeIntegration( + sessions: Sessions<'employee'>, + config: EvakaSamlConfig, + redisClient: RedisClient +) { + return createSamlIntegration({ + sessions, + strategyName: 'evaka', + saml: new SAML( + createSamlConfig( + config, + redisCacheProvider(redisClient, { keyPrefix: 'keycloak-saml-resp:' }) + ) + ), + authenticate, + defaultPageUrl: '/employee' + }) +} diff --git a/apigw/src/shared/auth/dev-auth.ts b/apigw/src/shared/auth/dev-auth.ts index 8b24a436335..5fd778382bc 100644 --- a/apigw/src/shared/auth/dev-auth.ts +++ b/apigw/src/shared/auth/dev-auth.ts @@ -27,6 +27,7 @@ export function createDevAuthRouter({ }: DevAuthRouterOptions): express.Router { const router = express.Router() + router.use(sessions.middleware) router.get('/login', toRequestHandler(loginFormHandler)) router.post( `/login/callback`, diff --git a/apigw/src/shared/config.ts b/apigw/src/shared/config.ts index 90c97c2b714..f3183326fb7 100644 --- a/apigw/src/shared/config.ts +++ b/apigw/src/shared/config.ts @@ -458,16 +458,14 @@ function createLocalDevelopmentOverrides(): Partial { export interface Config { citizen: SessionConfig employee: SessionConfig - ad: { - externalIdPrefix: string - userIdKey: string - } & ( + ad: | { type: 'mock' | 'disabled' } | { type: 'saml' + externalIdPrefix: string + userIdKey: string saml: EvakaSamlConfig } - ) sfi: { type: 'mock' | 'disabled' } | { type: 'saml'; saml: EvakaSamlConfig } keycloakEmployee: EvakaSamlConfig | undefined keycloakCitizen: EvakaSamlConfig | undefined @@ -626,12 +624,12 @@ export function configFromEnv(): Config { optional('DEV_LOGIN', parseBoolean) ?? required('AD_MOCK', parseBoolean) const adType = adMock ? 'mock' : 'saml' const ad: Config['ad'] = { - externalIdPrefix: required('AD_SAML_EXTERNAL_ID_PREFIX', unchanged), - userIdKey: required('AD_USER_ID_KEY', unchanged), ...(adType !== 'saml' ? { type: adType } : { type: adType, + externalIdPrefix: required('AD_SAML_EXTERNAL_ID_PREFIX', unchanged), + userIdKey: required('AD_USER_ID_KEY', unchanged), saml: { callbackUrl: required('AD_SAML_CALLBACK_URL', unchanged), entryPoint: required('AD_SAML_ENTRYPOINT_URL', unchanged), diff --git a/apigw/src/shared/routes/saml.ts b/apigw/src/shared/routes/saml.ts index 380b18ff149..6e22eb5f00d 100755 --- a/apigw/src/shared/routes/saml.ts +++ b/apigw/src/shared/routes/saml.ts @@ -10,7 +10,7 @@ import express from 'express' import _ from 'lodash' import { createLogoutToken } from '../auth/index.js' -import { toRequestHandler } from '../express.js' +import { AsyncRequestHandler, toRequestHandler } from '../express.js' import { logAuditEvent, logDebug } from '../logging.js' import { parseDescriptionFromSamlError, @@ -54,15 +54,21 @@ export class SamlError extends Error { } } -// Returns an Express router for handling SAML-related requests. -// -// We support two SAML "bindings", which define how data is passed by the -// browser to the SP (us) and the IDP. -// * HTTP redirect: the browser makes a GET request with query parameters -// * HTTP POST: the browser makes a POST request with URI-encoded form body -export default function createSamlRouter( +const samlRequestOptions = (req: express.Request): AuthOptions => { + const locale = req.query.locale + return typeof locale === 'string' ? { additionalParams: { locale } } : {} +} + +const isSamlPostRequest = (req: express.Request) => 'SAMLRequest' in req.body + +export interface SamlIntegration { + router: express.Router + logout: AsyncRequestHandler +} + +export function createSamlIntegration( endpointConfig: SamlEndpointConfig -): express.Router { +): SamlIntegration { const { sessions, strategyName, saml, defaultPageUrl, authenticate } = endpointConfig @@ -77,12 +83,6 @@ export default function createSamlRouter( ? `${defaultPageUrl}?loginError=true&errorCode=${encodeURIComponent(errorCode)}` : `${defaultPageUrl}?loginError=true` } - const samlRequestOptions = (req: express.Request): AuthOptions => { - const locale = req.query.locale - return typeof locale === 'string' ? { additionalParams: { locale } } : {} - } - - const isSamlPostRequest = (req: express.Request) => 'SAMLRequest' in req.body const validateSamlLoginResponse = async ( req: express.Request @@ -95,237 +95,257 @@ export default function createSamlRouter( return samlMessage.profile } - const router = express.Router() + const login: AsyncRequestHandler = async (req, res) => { + logAuditEvent(eventCode('sign_in_started'), req, 'Login endpoint called') + try { + const idpLoginUrl = await saml.getAuthorizeUrlAsync( + // no need for validation here, because the value only matters in the login callback request and is validated there + getRawUnvalidatedRelayState(req) ?? '', + undefined, + samlRequestOptions(req) + ) + return res.redirect(idpLoginUrl) + } catch (err) { + logAuditEvent( + eventCode('sign_in_failed'), + req, + `Error logging user in. ${err?.toString()}` + ) + throw new SamlError('Login failed', { + redirectUrl: errorRedirectUrl(err), + cause: err + }) + } + } + const loginCallback: AsyncRequestHandler = async (req, res) => { + logAuditEvent(eventCode('sign_in'), req, 'Login callback endpoint called') + let profile: Profile + try { + profile = await validateSamlLoginResponse(req) + } catch (err) { + if (err instanceof Error && err.message === 'InResponseTo is not valid') + // These errors can happen for example when the user browses back to the login callback after login + throw new SamlError('Login failed', { + redirectUrl: sessions.isAuthenticated(req) + ? (validateRelayStateUrl(req)?.toString() ?? defaultPageUrl) + : errorRedirectUrl(err), + cause: err, + // just ignore without logging to reduce noise in logs + silent: true + }) - // Our application directs the browser to this endpoint to start the login - // flow. We generate a LoginRequest. - router.get( - `/login`, - toRequestHandler(async (req, res) => { - logAuditEvent(eventCode('sign_in_started'), req, 'Login endpoint called') - try { - const idpLoginUrl = await saml.getAuthorizeUrlAsync( - // no need for validation here, because the value only matters in the login callback request and is validated there - getRawUnvalidatedRelayState(req) ?? '', - undefined, - samlRequestOptions(req) - ) - return res.redirect(idpLoginUrl) - } catch (err) { + const samlError = samlErrorSchema.safeParse(err) + if (samlError.success) { + const description = + parseDescriptionFromSamlError(samlError.data, req) ?? + 'Could not parse SAML message' logAuditEvent( eventCode('sign_in_failed'), req, - `Error logging user in. ${err?.toString()}` + `Failed to authenticate user. Description: ${description}. ${err?.toString()}` ) throw new SamlError('Login failed', { redirectUrl: errorRedirectUrl(err), - cause: err + cause: err, + // just ignore without logging to reduce noise in logs + silent: true }) - } - }) - ) - // The IDP makes the browser POST to this callback during login flow, and - // a SAML LoginResponse is included in the request. - router.post( - `/login/callback`, - urlencodedParser, - toRequestHandler(async (req, res) => { - logAuditEvent(eventCode('sign_in'), req, 'Login callback endpoint called') - let profile: Profile - try { - profile = await validateSamlLoginResponse(req) - } catch (err) { - if (err instanceof Error && err.message === 'InResponseTo is not valid') - // These errors can happen for example when the user browses back to the login callback after login - throw new SamlError('Login failed', { - redirectUrl: sessions.isAuthenticated(req) - ? (validateRelayStateUrl(req)?.toString() ?? defaultPageUrl) - : errorRedirectUrl(err), - cause: err, - // just ignore without logging to reduce noise in logs - silent: true - }) - - const samlError = samlErrorSchema.safeParse(err) - if (samlError.success) { - const description = - parseDescriptionFromSamlError(samlError.data, req) ?? - 'Could not parse SAML message' - logAuditEvent( - eventCode('sign_in_failed'), - req, - `Failed to authenticate user. Description: ${description}. ${err?.toString()}` - ) - throw new SamlError('Login failed', { - redirectUrl: errorRedirectUrl(err), - cause: err, - // just ignore without logging to reduce noise in logs - silent: true - }) - } else { - logAuditEvent( - eventCode('sign_in_failed'), - req, - `Failed to authenticate user. ${err?.toString()}` - ) - throw new SamlError('Login failed', { - redirectUrl: errorRedirectUrl(err), - cause: err - }) - } - } - try { - const user = await authenticate(profile) - await sessions.login(req, user) - logAuditEvent( - `evaka.saml.${strategyName}.sign_in`, - req, - 'User logged in successfully' - ) - - // Persist in session to allow custom logic per strategy - req.session.idpProvider = strategyName - await sessions.saveLogoutToken(req, createLogoutToken(profile)) - - const redirectUrl = - validateRelayStateUrl(req)?.toString() ?? defaultPageUrl - logDebug(`Redirecting to ${redirectUrl}`, req, { redirectUrl }) - return res.redirect(redirectUrl) - } catch (err) { + } else { logAuditEvent( eventCode('sign_in_failed'), req, - `Error logging user in. ${err?.toString()}` + `Failed to authenticate user. ${err?.toString()}` ) throw new SamlError('Login failed', { redirectUrl: errorRedirectUrl(err), cause: err }) } - }) - ) + } + try { + const user = await authenticate(profile) + await sessions.login(req, user) + logAuditEvent( + `evaka.saml.${strategyName}.sign_in`, + req, + 'User logged in successfully' + ) - // Our application directs the browser to one of these endpoints to start - // the logout flow. We generate a LogoutRequest. - router.get( - `/logout`, - toRequestHandler(async (req, res) => { + // Persist in session to allow custom logic per strategy + req.session.idpProvider = strategyName + await sessions.saveLogoutToken(req, createLogoutToken(profile)) + + const redirectUrl = + validateRelayStateUrl(req)?.toString() ?? defaultPageUrl + logDebug(`Redirecting to ${redirectUrl}`, req, { redirectUrl }) + return res.redirect(redirectUrl) + } catch (err) { + logAuditEvent( + eventCode('sign_in_failed'), + req, + `Error logging user in. ${err?.toString()}` + ) + throw new SamlError('Login failed', { + redirectUrl: errorRedirectUrl(err), + cause: err + }) + } + } + + const logout: AsyncRequestHandler = async (req, res) => { + logAuditEvent( + eventCode('sign_out_requested'), + req, + 'Logout endpoint called' + ) + try { + const user = sessions.getUser(req) + const samlSession = SamlSessionSchema.safeParse(user) + let url: string + if (samlSession.success) { + url = await saml.getLogoutUrlAsync( + samlSession.data, + // no need for validation here, because the value only matters in the logout callback request and is validated there + getRawUnvalidatedRelayState(req) ?? '', + samlRequestOptions(req) + ) + } else { + url = defaultPageUrl + } + await sessions.destroy(req, res) + return res.redirect(url) + } catch (err) { logAuditEvent( - eventCode('sign_out_requested'), + eventCode('sign_out_failed'), req, - 'Logout endpoint called' + `Logout failed. ${err?.toString()}.` ) - try { - const user = sessions.getUser(req) - const samlSession = SamlSessionSchema.safeParse(user) - let url: string - if (samlSession.success) { - url = await saml.getLogoutUrlAsync( - samlSession.data, - // no need for validation here, because the value only matters in the logout callback request and is validated there - getRawUnvalidatedRelayState(req) ?? '', - samlRequestOptions(req) - ) + throw new SamlError('Logout failed', { + redirectUrl: defaultPageUrl, + cause: err + }) + } + } + const logoutCallback: AsyncRequestHandler = async (req, res) => { + logAuditEvent(eventCode('sign_out'), req, 'Logout callback called') + + let samlMessage: { profile: Profile | null; loggedOut: boolean } + if (req.method === 'GET') { + const originalQuery = url.parse(req.url).query ?? '' + samlMessage = await saml.validateRedirectAsync(req.query, originalQuery) + } else if (req.method === 'POST') { + samlMessage = isSamlPostRequest(req) + ? // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + await saml.validatePostRequestAsync(req.body) + : // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + await saml.validatePostResponseAsync(req.body) + } else { + throw new SamlError(`Unsupported HTTP method ${req.method}`) + } + if (!samlMessage.loggedOut) { + throw new SamlError( + 'Invalid SAML message type: expected logout request/response' + ) + } + + try { + let url: string + // There are two scenarios: + // 1. IDP-initiated logout, and we've just received a logout request -> profile is not null, the SAML transaction + // is still in progress, and we should redirect the user back to the IDP + // 2. SP-initiated logout, and we've just received a logout response -> profile is null, the SAML transaction + // is complete, and we should redirect the user to some meaningful page + if (samlMessage.profile) { + let user: unknown + const sessionUser = sessions.getUser(req) + if (sessionUser) { + const userId = SamlProfileIdSchema.safeParse(sessionUser) + user = userId.success ? userId.data : undefined + + await sessions.destroy(req, res) } else { - url = defaultPageUrl + // We're possibly doing SLO without a real session (e.g. browser has + // 3rd party cookies disabled) + const logoutToken = createLogoutToken(samlMessage.profile) + const sessionUser = await sessions.logoutWithToken(logoutToken) + const userId = SamlProfileIdSchema.safeParse(sessionUser) + user = userId.success ? userId.data : undefined } - await sessions.destroy(req, res) - return res.redirect(url) - } catch (err) { + const profileId = SamlProfileIdSchema.safeParse(samlMessage.profile) + const success = profileId.success && _.isEqual(user, profileId.data) + + url = await saml.getLogoutResponseUrlAsync( + samlMessage.profile, + // not validated, because the value and its format are specified by the IDP and we're supposed to + // just pass it back + getRawUnvalidatedRelayState(req) ?? '', + samlRequestOptions(req), + success + ) + } else { + url = validateRelayStateUrl(req)?.toString() ?? defaultPageUrl + } + return res.redirect(url) + } catch (err) { + if (err instanceof Error && err.message === 'InResponseTo is not valid') + throw new SamlError('Logout failed', { + redirectUrl: validateRelayStateUrl(req)?.toString() ?? defaultPageUrl, + cause: err, + // just ignore without logging to reduce noise in logs + silent: true + }) + + const samlError = samlErrorSchema.safeParse(err) + if (samlError.success) { + const description = + parseDescriptionFromSamlError(samlError.data, req) ?? + 'Could not parse SAML message' + logAuditEvent( + eventCode('sign_out_failed'), + req, + `Logout failed. Description: ${description}. ${err?.toString()}` + ) + throw new SamlError('Logout failed', { + redirectUrl: validateRelayStateUrl(req)?.toString() ?? defaultPageUrl, + cause: err, + // just ignore without logging to reduce noise in logs + silent: true + }) + } else { logAuditEvent( eventCode('sign_out_failed'), req, - `Logout failed. ${err?.toString()}.` + `Logout failed. ${err?.toString()}` ) throw new SamlError('Logout failed', { - redirectUrl: defaultPageUrl, + redirectUrl: validateRelayStateUrl(req)?.toString() ?? defaultPageUrl, cause: err }) } - }) - ) - const logoutCallback = ( - parseLogoutMessage: (req: express.Request) => Promise - ) => - toRequestHandler(async (req, res) => { - logAuditEvent(eventCode('sign_out'), req, 'Logout callback called') - try { - const profile = await parseLogoutMessage(req) - let url: string - // There are two scenarios: - // 1. IDP-initiated logout, and we've just received a logout request -> profile is not null, the SAML transaction - // is still in progress, and we should redirect the user back to the IDP - // 2. SP-initiated logout, and we've just received a logout response -> profile is null, the SAML transaction - // is complete, and we should redirect the user to some meaningful page - if (profile) { - let user: unknown - const sessionUser = sessions.getUser(req) - if (sessionUser) { - const userId = SamlProfileIdSchema.safeParse(sessionUser) - user = userId.success ? userId.data : undefined - - await sessions.destroy(req, res) - } else { - // We're possibly doing SLO without a real session (e.g. browser has - // 3rd party cookies disabled) - const logoutToken = createLogoutToken(profile) - const sessionUser = await sessions.logoutWithToken(logoutToken) - const userId = SamlProfileIdSchema.safeParse(sessionUser) - user = userId.success ? userId.data : undefined - } - const profileId = SamlProfileIdSchema.safeParse(profile) - const success = profileId.success && _.isEqual(user, profileId.data) - - url = await saml.getLogoutResponseUrlAsync( - profile, - // not validated, because the value and its format are specified by the IDP and we're supposed to - // just pass it back - getRawUnvalidatedRelayState(req) ?? '', - samlRequestOptions(req), - success - ) - } else { - url = validateRelayStateUrl(req)?.toString() ?? defaultPageUrl - } - return res.redirect(url) - } catch (err) { - if (err instanceof Error && err.message === 'InResponseTo is not valid') - throw new SamlError('Logout failed', { - redirectUrl: validateRelayStateUrl(req) ?? defaultPageUrl, - cause: err, - // just ignore without logging to reduce noise in logs - silent: true - }) + } + } - const samlError = samlErrorSchema.safeParse(err) - if (samlError.success) { - const description = - parseDescriptionFromSamlError(samlError.data, req) ?? - 'Could not parse SAML message' - logAuditEvent( - eventCode('sign_out_failed'), - req, - `Logout failed. Description: ${description}. ${err?.toString()}` - ) - throw new SamlError('Logout failed', { - redirectUrl: validateRelayStateUrl(req) ?? defaultPageUrl, - cause: err, - // just ignore without logging to reduce noise in logs - silent: true - }) - } else { - logAuditEvent( - eventCode('sign_out_failed'), - req, - `Logout failed. ${err?.toString()}` - ) - throw new SamlError('Logout failed', { - redirectUrl: validateRelayStateUrl(req) ?? defaultPageUrl, - cause: err - }) - } - } - }) + // Returns an Express router for handling SAML-related requests. + // + // We support two SAML "bindings", which define how data is passed by the + // browser to the SP (us) and the IDP. + // * HTTP redirect: the browser makes a GET request with query parameters + // * HTTP POST: the browser makes a POST request with URI-encoded form body + const router = express.Router() + router.use(sessions.middleware) + // Our application directs the browser to this endpoint to start the login + // flow. We generate a LoginRequest. + router.get(`/login`, toRequestHandler(login)) + // The IDP makes the browser POST to this callback during login flow, and + // a SAML LoginResponse is included in the request. + router.post( + `/login/callback`, + urlencodedParser, + toRequestHandler(loginCallback) + ) + // Our application directs the browser to one of these endpoints to start + // the logout flow. We generate a LogoutRequest. + router.get(`/logout`, toRequestHandler(logout)) // The IDP makes the browser either GET or POST one of these endpoints in two // separate logout flows. // 1. SP-initiated logout. In this case the logout flow started from us @@ -334,39 +354,15 @@ export default function createSamlRouter( // 2. IDP-initiated logout (= SAML single logout). In this case the logout // flow started from the IDP, and a SAML LogoutRequest is included in the // request. - router.get( - `/logout/callback`, - logoutCallback(async (req) => { - const originalQuery = url.parse(req.url).query ?? '' - const { profile, loggedOut } = await saml.validateRedirectAsync( - req.query, - originalQuery - ) - if (!loggedOut) { - throw new SamlError( - 'Invalid SAML message type: expected logout response' - ) - } - return profile - }) - ) + router.get(`/logout/callback`, toRequestHandler(logoutCallback)) router.post( `/logout/callback`, urlencodedParser, - logoutCallback(async (req) => { - const { profile, loggedOut } = isSamlPostRequest(req) - ? // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - await saml.validatePostRequestAsync(req.body) - : // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - await saml.validatePostResponseAsync(req.body) - if (!loggedOut) { - throw new SamlError( - 'Invalid SAML message type: expected logout request/response' - ) - } - return profile - }) + toRequestHandler(logoutCallback) ) - return router + return { + router, + logout + } } diff --git a/apigw/src/shared/saml/index.ts b/apigw/src/shared/saml/index.ts index ab1e8483b69..b8e0063d99c 100644 --- a/apigw/src/shared/saml/index.ts +++ b/apigw/src/shared/saml/index.ts @@ -110,13 +110,11 @@ export function getRawUnvalidatedRelayState( // redirected to after the SAML transaction is complete. Since the RelayState // is not signed or encrypted, we must make sure the URL points to our application // and not to some 3rd party domain -export function validateRelayStateUrl( - req: express.Request -): string | undefined { +export function validateRelayStateUrl(req: express.Request): URL | undefined { const relayState = getRawUnvalidatedRelayState(req) if (relayState) { const url = parseUrlWithOrigin(evakaBaseUrl, relayState) - if (url) return url.toString() + if (url) return url logError('Invalid RelayState in request', req) } return undefined From 531b069f23e5308264e9bc4f3828ff0d5d741ccf Mon Sep 17 00:00:00 2001 From: Joonas Javanainen Date: Mon, 9 Dec 2024 13:30:22 +0200 Subject: [PATCH 2/9] Start using new logout endpoints --- .../src/citizen-frontend/navigation/DesktopNav.tsx | 13 +++++-------- .../src/citizen-frontend/navigation/MobileNav.tsx | 8 ++------ frontend/src/citizen-frontend/navigation/const.ts | 7 +------ frontend/src/employee-frontend/api/auth.ts | 2 +- 4 files changed, 9 insertions(+), 21 deletions(-) diff --git a/frontend/src/citizen-frontend/navigation/DesktopNav.tsx b/frontend/src/citizen-frontend/navigation/DesktopNav.tsx index 1a165976cdc..32a23ca9f26 100644 --- a/frontend/src/citizen-frontend/navigation/DesktopNav.tsx +++ b/frontend/src/citizen-frontend/navigation/DesktopNav.tsx @@ -20,23 +20,23 @@ import { desktopMin, desktopSmall } from 'lib-components/breakpoints' import { FixedSpaceRow } from 'lib-components/layout/flex-helpers' import { fontWeights } from 'lib-components/typography' import useCloseOnOutsideClick from 'lib-components/utils/useCloseOnOutsideClick' -import { Gap, defaultMargins } from 'lib-components/white-space' +import { defaultMargins, Gap } from 'lib-components/white-space' import colors from 'lib-customizations/common' import { faLockAlt, - faSignIn, farBars, farSignOut, farXmark, fasChevronDown, - fasChevronUp + fasChevronUp, + faSignIn } from 'lib-icons' import { AuthContext, User } from '../auth/state' import { useTranslation } from '../localization' import AttentionIndicator from './AttentionIndicator' -import { getLogoutUri } from './const' +import { logoutUrl } from './const' import { CircledChar, DropDown, @@ -417,10 +417,7 @@ const SubNavigationMenu = React.memo(function SubNavigationMenu({ )} - + {t.header.logout} diff --git a/frontend/src/citizen-frontend/navigation/MobileNav.tsx b/frontend/src/citizen-frontend/navigation/MobileNav.tsx index 944e0824781..8eb894ee5ff 100644 --- a/frontend/src/citizen-frontend/navigation/MobileNav.tsx +++ b/frontend/src/citizen-frontend/navigation/MobileNav.tsx @@ -38,11 +38,7 @@ import { langs, useLang, useTranslation } from '../localization' import { unreadMessagesCountQuery } from '../messages/queries' import AttentionIndicator from './AttentionIndicator' -import { - getLogoutUri, - headerHeightMobile, - mobileBottomNavHeight -} from './const' +import { headerHeightMobile, logoutUrl, mobileBottomNavHeight } from './const' import { CircledChar, DropDownInfo, @@ -411,7 +407,7 @@ const Menu = React.memo(function Menu({ )} - + {t.header.logout} diff --git a/frontend/src/citizen-frontend/navigation/const.ts b/frontend/src/citizen-frontend/navigation/const.ts index c4a1a8484c2..9559b66f127 100644 --- a/frontend/src/citizen-frontend/navigation/const.ts +++ b/frontend/src/citizen-frontend/navigation/const.ts @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: LGPL-2.1-or-later -import { User } from '../auth/state' +export const logoutUrl = `/api/citizen/auth/logout?RelayState=/` export const getWeakLoginUri = ( url = `${window.location.pathname}${window.location.search}${window.location.hash}` @@ -13,11 +13,6 @@ export const getStrongLoginUri = ( url = `${window.location.pathname}${window.location.search}${window.location.hash}` ) => `/api/application/auth/saml/login?RelayState=${encodeURIComponent(url)}` -export const getLogoutUri = (user: User) => - `/api/application/auth/${ - user?.authLevel === 'WEAK' ? 'evaka-customer' : 'saml' - }/logout` - export const headerHeightDesktop = 80 export const headerHeightMobile = 60 export const mobileBottomNavHeight = 60 diff --git a/frontend/src/employee-frontend/api/auth.ts b/frontend/src/employee-frontend/api/auth.ts index 257123c2835..014f8310aff 100755 --- a/frontend/src/employee-frontend/api/auth.ts +++ b/frontend/src/employee-frontend/api/auth.ts @@ -13,7 +13,7 @@ import { JsonOf } from 'lib-common/json' import { client } from './client' -export const logoutUrl = `/api/internal/auth/saml/logout?RelayState=/employee/login` +export const logoutUrl = `/api/employee/auth/logout?RelayState=/employee/login` const redirectUri = (() => { if (window.location.pathname === '/employee/login') { From b6bf5d3307b564a9ae8a9b13b1c3e8a2ff2fbb5e Mon Sep 17 00:00:00 2001 From: Joonas Javanainen Date: Tue, 10 Dec 2024 09:16:33 +0200 Subject: [PATCH 3/9] Add support for employee Suomi.fi login --- apigw/src/app.ts | 75 ++++++++++++++++++++++--- apigw/src/enduser/suomi-fi-saml.ts | 51 +++++++++++++++-- apigw/src/shared/auth/index.ts | 2 +- apigw/src/shared/test/gateway-tester.ts | 2 +- 4 files changed, 115 insertions(+), 15 deletions(-) diff --git a/apigw/src/app.ts b/apigw/src/app.ts index 4a80d966695..6220cfd48ea 100644 --- a/apigw/src/app.ts +++ b/apigw/src/app.ts @@ -11,7 +11,10 @@ import { createKeycloakCitizenIntegration } from './enduser/keycloak-citizen-sam import mapRoutes from './enduser/mapRoutes.js' import { citizenAuthStatus } from './enduser/routes/auth-status.js' import { authWeakLogin } from './enduser/routes/auth-weak-login.js' -import { createSuomiFiIntegration } from './enduser/suomi-fi-saml.js' +import { + createCitizenSuomiFiIntegration, + createEmployeeSuomiFiIntegration +} from './enduser/suomi-fi-saml.js' import { createSamlAdIntegration } from './internal/ad-saml.js' import { createDevAdRouter } from './internal/dev-ad-auth.js' import { createKeycloakEmployeeIntegration } from './internal/keycloak-employee-saml.js' @@ -39,6 +42,7 @@ import { createProxy } from './shared/proxy-utils.js' import { RedisClient } from './shared/redis-client.js' import { handleCspReport } from './shared/routes/csp.js' import { SamlIntegration } from './shared/routes/saml.js' +import { validateRelayStateUrl } from './shared/saml/index.js' import { sessionSupport } from './shared/session.js' export function apiRouter(config: Config, redisClient: RedisClient) { @@ -55,15 +59,13 @@ export function apiRouter(config: Config, redisClient: RedisClient) { '/application/map-api/', '/citizen/public/map-api/' ) - } else if (req.url.startsWith('/application/auth/saml/')) { - req.url = req.url.replace('/application/auth/saml/', '/citizen/auth/sfi/') } else if (req.url.startsWith('/application/auth/evaka-customer/')) { req.url = req.url.replace( '/application/auth/evaka-customer/', '/citizen/auth/keycloak/' ) - } else if (req.url.startsWith('/application/auth/')) { - req.url = req.url.replace('/application/auth/', '/citizen/auth/') + } else if (req.url === '/application/auth/status') { + req.url = '/citizen/auth/status' } else if (req.url.startsWith('/internal/employee/')) { req.url = req.url.replace('/internal/employee/', '/employee/') } else if (req.url.startsWith('/internal/employee-mobile/')) { @@ -139,16 +141,16 @@ export function apiRouter(config: Config, redisClient: RedisClient) { getUserHeader: (req) => employeeMobileSessions.getUserHeader(req) }) - let sfiIntegration: SamlIntegration | undefined + let citizenSfiIntegration: SamlIntegration | undefined if (config.sfi.type === 'mock') { router.use('/citizen/auth/sfi', createDevSfiRouter(citizenSessions)) } else if (config.sfi.type === 'saml') { - sfiIntegration = createSuomiFiIntegration( + citizenSfiIntegration = createCitizenSuomiFiIntegration( citizenSessions, config.sfi.saml, redisClient ) - router.use('/citizen/auth/sfi', sfiIntegration.router) + router.use('/citizen/auth/sfi', citizenSfiIntegration.router) } if (!config.keycloakCitizen) @@ -187,6 +189,18 @@ export function apiRouter(config: Config, redisClient: RedisClient) { router.use('/employee/auth/ad', adIntegration.router) } + let employeeSfiIntegration: SamlIntegration | undefined + if (config.sfi.type === 'mock') { + // TODO + } else if (config.sfi.type === 'saml') { + employeeSfiIntegration = createEmployeeSuomiFiIntegration( + employeeSessions, + config.sfi.saml, + redisClient + ) + router.use('/employee/auth/sfi', employeeSfiIntegration.router) + } + if (!config.keycloakEmployee) throw new Error('Missing Keycloak SAML configuration (employee)') const keycloakEmployeeIntegration = createKeycloakEmployeeIntegration( @@ -196,6 +210,26 @@ export function apiRouter(config: Config, redisClient: RedisClient) { ) router.use('/employee/auth/keycloak', keycloakEmployeeIntegration.router) + router.use( + '/application/auth/saml', + express.urlencoded({ extended: false }), + (req, res, next) => { + const relayStateUrl = validateRelayStateUrl(req) + const hasEmployeeRelayStateUrl = + relayStateUrl?.pathname === '/employee' || + relayStateUrl?.pathname.startsWith('/employee/') + + if (hasEmployeeRelayStateUrl) { + if (employeeSfiIntegration) + return employeeSfiIntegration.router(req, res, next) + } else { + if (citizenSfiIntegration) + return citizenSfiIntegration?.router(req, res, next) + } + res.sendStatus(404) + } + ) + if (enableDevApi) { router.get( '/dev-api/auth/mobile-e2e-signup', @@ -218,10 +252,21 @@ export function apiRouter(config: Config, redisClient: RedisClient) { const user = citizenSessions.getUser(req) switch (user?.authType) { case 'sfi': - if (sfiIntegration) return await sfiIntegration.logout(req, res) + if (citizenSfiIntegration) + return citizenSfiIntegration.logout(req, res) break case 'keycloak-citizen': return keycloakCitizenIntegration.logout(req, res) + case 'citizen-weak': + case 'dev': + case undefined: + // no need for special handling + break + case 'ad': + case 'keycloak-employee': + case 'employee-mobile': + // should not happen, but we'll still destroy the session normally + break } await citizenSessions.destroy(req, res) res.redirect('/') @@ -236,8 +281,20 @@ export function apiRouter(config: Config, redisClient: RedisClient) { case 'ad': if (adIntegration) return adIntegration.logout(req, res) break + case 'sfi': + if (employeeSfiIntegration) + return employeeSfiIntegration.logout(req, res) + break case 'keycloak-employee': return keycloakEmployeeIntegration.logout(req, res) + case 'dev': + // no need for special handling + break + case 'citizen-weak': + case 'employee-mobile': + case 'keycloak-citizen': + // should not happen, but we'll still destroy the session normally + break } await employeeSessions.destroy(req, res) res.redirect('/employee') diff --git a/apigw/src/enduser/suomi-fi-saml.ts b/apigw/src/enduser/suomi-fi-saml.ts index 0f8e2f03975..28f65b763c2 100644 --- a/apigw/src/enduser/suomi-fi-saml.ts +++ b/apigw/src/enduser/suomi-fi-saml.ts @@ -11,7 +11,7 @@ import { RedisClient } from '../shared/redis-client.js' import { createSamlIntegration } from '../shared/routes/saml.js' import { authenticateProfile, createSamlConfig } from '../shared/saml/index.js' import redisCacheProvider from '../shared/saml/node-saml-cache-redis.js' -import { citizenLogin } from '../shared/service-client.js' +import { citizenLogin, employeeSuomiFiLogin } from '../shared/service-client.js' import { Sessions } from '../shared/session.js' // Suomi.fi e-Identification – Attributes transmitted on an identified user: @@ -29,7 +29,7 @@ const Profile = z.object({ const ssnRegex = /^[0-9]{6}[-+ABCDEFUVWXY][0-9]{3}[0-9ABCDEFHJKLMNPRSTUVWXY]$/ -const authenticate = authenticateProfile( +const authenticateCitizen = authenticateProfile( Profile, async (samlSession, profile) => { const socialSecurityNumber = profile[SUOMI_FI_SSN_KEY]?.trim() @@ -51,7 +51,7 @@ const authenticate = authenticateProfile( } ) -export function createSuomiFiIntegration( +export function createCitizenSuomiFiIntegration( sessions: Sessions<'citizen'>, config: EvakaSamlConfig, redisClient: RedisClient @@ -65,7 +65,50 @@ export function createSuomiFiIntegration( redisCacheProvider(redisClient, { keyPrefix: 'suomifi-saml-resp:' }) ) ), - authenticate, + authenticate: authenticateCitizen, defaultPageUrl: '/' }) } + +const authenticateEmployee = authenticateProfile( + Profile, + async (samlSession, profile) => { + const socialSecurityNumber = profile[SUOMI_FI_SSN_KEY]?.trim() + if (!socialSecurityNumber) throw Error('No SSN in SAML data') + if (!ssnRegex.test(socialSecurityNumber)) { + logWarn('Invalid SSN received from Suomi.fi login') + } + const person = await employeeSuomiFiLogin({ + ssn: profile[SUOMI_FI_SSN_KEY], + firstName: profile[SUOMI_FI_GIVEN_NAME_KEY], + lastName: profile[SUOMI_FI_SURNAME_KEY] + }) + return { + id: person.id, + authType: 'sfi', + userType: 'EMPLOYEE', + globalRoles: person.globalRoles, + allScopedRoles: person.allScopedRoles, + samlSession + } + } +) + +export function createEmployeeSuomiFiIntegration( + sessions: Sessions<'employee'>, + config: EvakaSamlConfig, + redisClient: RedisClient +) { + return createSamlIntegration({ + sessions, + strategyName: 'suomifi', + saml: new SAML( + createSamlConfig( + config, + redisCacheProvider(redisClient, { keyPrefix: 'employee-sfi:' }) + ) + ), + authenticate: authenticateEmployee, + defaultPageUrl: '/employee' + }) +} diff --git a/apigw/src/shared/auth/index.ts b/apigw/src/shared/auth/index.ts index ef2b0e1cf0a..858df0c14ea 100644 --- a/apigw/src/shared/auth/index.ts +++ b/apigw/src/shared/auth/index.ts @@ -25,7 +25,7 @@ export type CitizenSessionUser = export type EmployeeSessionUser = | { id: string - authType: 'ad' | 'keycloak-employee' + authType: 'ad' | 'keycloak-employee' | 'sfi' userType: 'EMPLOYEE' samlSession: SamlSession globalRoles: string[] diff --git a/apigw/src/shared/test/gateway-tester.ts b/apigw/src/shared/test/gateway-tester.ts index c459b9e6e86..e339752acbe 100644 --- a/apigw/src/shared/test/gateway-tester.ts +++ b/apigw/src/shared/test/gateway-tester.ts @@ -128,7 +128,7 @@ export class GatewayTester { postData = postData !== undefined ? postData : { preset: 'dummy' } this.nockScope.post('/system/citizen-login').reply(200, user) await this.client.post( - '/api/application/auth/saml/login/callback', + '/api/citizen/auth/sfi/login/callback', // eslint-disable-next-line @typescript-eslint/no-unsafe-argument new URLSearchParams(postData), { From 8f8959c52dd4204f5d95f976141a6d6b0e1960ae Mon Sep 17 00:00:00 2001 From: Joonas Javanainen Date: Wed, 11 Dec 2024 13:59:36 +0200 Subject: [PATCH 4/9] Use new SAML login paths in frontend and e2e-tests --- frontend/src/citizen-frontend/navigation/const.ts | 5 ++--- frontend/src/e2e-test/config.ts | 3 +-- frontend/src/e2e-test/utils/user.ts | 4 ++-- frontend/src/employee-frontend/api/auth.ts | 4 ++-- .../src/employee-frontend/components/login-page/Login.tsx | 4 ++-- 5 files changed, 9 insertions(+), 11 deletions(-) diff --git a/frontend/src/citizen-frontend/navigation/const.ts b/frontend/src/citizen-frontend/navigation/const.ts index 9559b66f127..3c0ad8c6c8a 100644 --- a/frontend/src/citizen-frontend/navigation/const.ts +++ b/frontend/src/citizen-frontend/navigation/const.ts @@ -6,12 +6,11 @@ export const logoutUrl = `/api/citizen/auth/logout?RelayState=/` export const getWeakLoginUri = ( url = `${window.location.pathname}${window.location.search}${window.location.hash}` -) => - `/api/application/auth/evaka-customer/login?RelayState=${encodeURIComponent(url)}` +) => `/api/citizen/auth/keycloak/login?RelayState=${encodeURIComponent(url)}` export const getStrongLoginUri = ( url = `${window.location.pathname}${window.location.search}${window.location.hash}` -) => `/api/application/auth/saml/login?RelayState=${encodeURIComponent(url)}` +) => `/api/citizen/auth/sfi/login?RelayState=${encodeURIComponent(url)}` export const headerHeightDesktop = 80 export const headerHeightMobile = 60 diff --git a/frontend/src/e2e-test/config.ts b/frontend/src/e2e-test/config.ts index 6a0e58d2cef..b98f45dfa93 100755 --- a/frontend/src/e2e-test/config.ts +++ b/frontend/src/e2e-test/config.ts @@ -60,8 +60,7 @@ const config = { env('BROWSER', parseEnum(['chromium', 'firefox', 'webkit'] as const)) ?? 'chromium' }, - apiUrl: `${browserUrl}/api/internal`, - citizenApiUrl: `${browserUrl}/api/application`, + apiUrl: `${browserUrl}/api`, adminUrl: `${browserUrl}/employee/applications`, employeeUrl: `${browserUrl}/employee`, employeeLoginUrl: `${browserUrl}/employee/login`, diff --git a/frontend/src/e2e-test/utils/user.ts b/frontend/src/e2e-test/utils/user.ts index 6116a65d159..3273bbfb47d 100644 --- a/frontend/src/e2e-test/utils/user.ts +++ b/frontend/src/e2e-test/utils/user.ts @@ -12,7 +12,7 @@ export async function enduserLogin(page: Page, person: DevPerson) { throw new Error('Person does not have an SSN: cannot login') } - const authUrl = `${config.citizenApiUrl}/auth/saml/login/callback?RelayState=%2Fapplications` + const authUrl = `${config.apiUrl}/citizen/auth/sfi/login/callback?RelayState=%2Fapplications` if (!page.url.startsWith(config.enduserUrl)) { // We must be in the correct domain to be able to fetch() await page.goto(config.enduserLoginUrl) @@ -85,7 +85,7 @@ export async function employeeLogin( email?: string | null } ) { - const authUrl = `${config.apiUrl}/auth/saml/login/callback?RelayState=%2Femployee` + const authUrl = `${config.apiUrl}/employee/auth/ad/login/callback?RelayState=%2Femployee` const preset = JSON.stringify({ externalId, firstName, diff --git a/frontend/src/employee-frontend/api/auth.ts b/frontend/src/employee-frontend/api/auth.ts index 014f8310aff..b75dc5b9c9f 100755 --- a/frontend/src/employee-frontend/api/auth.ts +++ b/frontend/src/employee-frontend/api/auth.ts @@ -30,9 +30,9 @@ const redirectUri = (() => { }${searchParams}${window.location.hash}` })() -export function getLoginUrl(type: 'evaka' | 'saml' = 'saml') { +export function getLoginUrl(type: 'ad' | 'keycloak' = 'ad') { const relayState = encodeURIComponent(redirectUri) - return `/api/internal/auth/${type}/login?RelayState=${relayState}` + return `/api/employee/auth/${type}/login?RelayState=${relayState}` } export async function getAuthStatus(): Promise> { diff --git a/frontend/src/employee-frontend/components/login-page/Login.tsx b/frontend/src/employee-frontend/components/login-page/Login.tsx index 60bf8e64bb4..8b4b78ebb1b 100755 --- a/frontend/src/employee-frontend/components/login-page/Login.tsx +++ b/frontend/src/employee-frontend/components/login-page/Login.tsx @@ -39,11 +39,11 @@ function Login({ error }: Props) { {i18n.login.subtitle}
- + {i18n.login.loginAD} - + {i18n.login.loginEvaka}
From 4f2cf8d7302979a27b92444841acf3e69e0b7c23 Mon Sep 17 00:00:00 2001 From: Joonas Javanainen Date: Thu, 12 Dec 2024 09:02:48 +0200 Subject: [PATCH 5/9] Add employee Suomi.fi login button --- frontend/src/employee-frontend/api/auth.ts | 2 +- .../components/login-page/Login.tsx | 21 ++++++++++++++++--- .../lib-customizations/espoo/featureFlags.tsx | 9 +++++--- frontend/src/lib-customizations/types.d.ts | 4 ++++ 4 files changed, 29 insertions(+), 7 deletions(-) diff --git a/frontend/src/employee-frontend/api/auth.ts b/frontend/src/employee-frontend/api/auth.ts index b75dc5b9c9f..f3e5a066e90 100755 --- a/frontend/src/employee-frontend/api/auth.ts +++ b/frontend/src/employee-frontend/api/auth.ts @@ -30,7 +30,7 @@ const redirectUri = (() => { }${searchParams}${window.location.hash}` })() -export function getLoginUrl(type: 'ad' | 'keycloak' = 'ad') { +export function getLoginUrl(type: 'ad' | 'keycloak' | 'sfi' = 'ad') { const relayState = encodeURIComponent(redirectUri) return `/api/employee/auth/${type}/login?RelayState=${relayState}` } diff --git a/frontend/src/employee-frontend/components/login-page/Login.tsx b/frontend/src/employee-frontend/components/login-page/Login.tsx index 8b4b78ebb1b..94b32122c99 100755 --- a/frontend/src/employee-frontend/components/login-page/Login.tsx +++ b/frontend/src/employee-frontend/components/login-page/Login.tsx @@ -9,6 +9,7 @@ import Title from 'lib-components/atoms/Title' import LinkButton from 'lib-components/atoms/buttons/LinkButton' import { Container, ContentArea } from 'lib-components/layout/Container' import { Gap } from 'lib-components/white-space' +import { featureFlags } from 'lib-customizations/employee' import { getLoginUrl } from '../../api/auth' import { useTranslation } from '../../state/i18n' @@ -43,9 +44,23 @@ function Login({ error }: Props) { {i18n.login.loginAD} - - {i18n.login.loginEvaka} - + {featureFlags.employeeSfiLogin ? ( + <> + + {i18n.login.loginEvaka} (Keycloak) + + + + {i18n.login.loginEvaka} (Suomi.fi) + + + ) : ( + <> + + {i18n.login.loginEvaka} + + + )} diff --git a/frontend/src/lib-customizations/espoo/featureFlags.tsx b/frontend/src/lib-customizations/espoo/featureFlags.tsx index f7dd7e8e8f4..2b19b1506e7 100644 --- a/frontend/src/lib-customizations/espoo/featureFlags.tsx +++ b/frontend/src/lib-customizations/espoo/featureFlags.tsx @@ -45,7 +45,8 @@ const features: Features = { invoiceDisplayAccountNumber: true, serviceApplications: true, multiSelectDeparture: true, - weakLogin: true + weakLogin: true, + employeeSfiLogin: true }, staging: { environmentLabel: 'Staging', @@ -81,7 +82,8 @@ const features: Features = { invoiceDisplayAccountNumber: true, serviceApplications: true, multiSelectDeparture: true, - weakLogin: true + weakLogin: true, + employeeSfiLogin: true }, prod: { environmentLabel: null, @@ -116,7 +118,8 @@ const features: Features = { invoiceDisplayAccountNumber: true, serviceApplications: false, multiSelectDeparture: false, - weakLogin: false + weakLogin: false, + employeeSfiLogin: false } } diff --git a/frontend/src/lib-customizations/types.d.ts b/frontend/src/lib-customizations/types.d.ts index 3ee9bfa9d99..2d12fc6341a 100644 --- a/frontend/src/lib-customizations/types.d.ts +++ b/frontend/src/lib-customizations/types.d.ts @@ -284,6 +284,10 @@ interface BaseFeatureFlags { * Enable support for citizen weak login */ weakLogin?: boolean + /** + * Enable support for employee Suomi.fi login + */ + employeeSfiLogin?: boolean } export type FeatureFlags = DeepReadonly From d6f76d01b51f2354101dac17574603fb5c32bcbd Mon Sep 17 00:00:00 2001 From: Joonas Javanainen Date: Thu, 12 Dec 2024 09:19:49 +0200 Subject: [PATCH 6/9] Add employee Suomi.fi dev login --- apigw/src/app.ts | 6 +- apigw/src/internal/dev-sfi-auth.ts | 75 +++++++++++++++++++ apigw/src/shared/dev-api.ts | 11 +++ .../src/e2e-test/generated/api-clients.ts | 20 +++++ frontend/src/e2e-test/generated/api-types.ts | 9 +++ .../fi/espoo/evaka/shared/dev/DevApi.kt | 17 +++++ 6 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 apigw/src/internal/dev-sfi-auth.ts diff --git a/apigw/src/app.ts b/apigw/src/app.ts index 6220cfd48ea..65c7277cad6 100644 --- a/apigw/src/app.ts +++ b/apigw/src/app.ts @@ -17,6 +17,7 @@ import { } from './enduser/suomi-fi-saml.js' import { createSamlAdIntegration } from './internal/ad-saml.js' import { createDevAdRouter } from './internal/dev-ad-auth.js' +import { createDevEmployeeSfiRouter } from './internal/dev-sfi-auth.js' import { createKeycloakEmployeeIntegration } from './internal/keycloak-employee-saml.js' import { checkMobileEmployeeIdToken, @@ -191,7 +192,10 @@ export function apiRouter(config: Config, redisClient: RedisClient) { let employeeSfiIntegration: SamlIntegration | undefined if (config.sfi.type === 'mock') { - // TODO + router.use( + '/employee/auth/sfi', + createDevEmployeeSfiRouter(employeeSessions) + ) } else if (config.sfi.type === 'saml') { employeeSfiIntegration = createEmployeeSuomiFiIntegration( employeeSessions, diff --git a/apigw/src/internal/dev-sfi-auth.ts b/apigw/src/internal/dev-sfi-auth.ts new file mode 100644 index 00000000000..fa7502ec75e --- /dev/null +++ b/apigw/src/internal/dev-sfi-auth.ts @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: 2017-2024 City of Espoo +// +// SPDX-License-Identifier: LGPL-2.1-or-later + +import { Router } from 'express' +import _ from 'lodash' + +import { createDevAuthRouter } from '../shared/auth/dev-auth.js' +import { getVtjPersons } from '../shared/dev-api.js' +import { assertStringProp } from '../shared/express.js' +import { employeeSuomiFiLogin } from '../shared/service-client.js' +import { Sessions } from '../shared/session.js' + +export function createDevEmployeeSfiRouter( + sessions: Sessions<'employee'> +): Router { + return createDevAuthRouter({ + sessions, + root: '/employee', + loginFormHandler: async (req, res) => { + const defaultSsn = '070644-937X' + + const persons = _.orderBy( + await getVtjPersons(), + [({ ssn }) => defaultSsn === ssn, ({ ssn }) => ssn], + ['desc', 'asc'] + ) + const inputs = persons + .map(({ ssn, firstName, lastName }) => { + if (!ssn) return '' + const checked = ssn === defaultSsn ? 'checked' : '' + return `
` + }) + .filter((line) => !!line) + + const formQuery = + typeof req.query.RelayState === 'string' + ? `?RelayState=${encodeURIComponent(req.query.RelayState)}` + : '' + const formUri = `${req.baseUrl}/login/callback${formQuery}` + + res.contentType('text/html').send(` + + +

Devausympäristön Suomi.fi-kirjautuminen

+
+
+ +
+ ${inputs.join('\n')} +
+ + + `) + }, + verifyUser: async (req) => { + const ssn = assertStringProp(req.body, 'preset') + const persons = await getVtjPersons() + const person = persons.find((c) => c.ssn === ssn) + if (!person) throw new Error(`No VTJ person found with SSN ${ssn}`) + const employee = await employeeSuomiFiLogin({ + ssn, + firstName: person.firstName, + lastName: person.lastName + }) + return { + id: employee.id, + authType: 'dev', + userType: 'EMPLOYEE', + globalRoles: employee.globalRoles, + allScopedRoles: employee.allScopedRoles + } + } + }) +} diff --git a/apigw/src/shared/dev-api.ts b/apigw/src/shared/dev-api.ts index cb8cf20d235..8e401345ad5 100644 --- a/apigw/src/shared/dev-api.ts +++ b/apigw/src/shared/dev-api.ts @@ -28,3 +28,14 @@ export async function getEmployees(): Promise { const { data } = await client.get(`/dev-api/employee`) return data } + +export async function getVtjPersons(): Promise { + const { data } = await client.get(`/dev-api/vtj-person`) + return data +} + +export interface VtjPersonSummary { + ssn: string + firstName: string + lastName: string +} diff --git a/frontend/src/e2e-test/generated/api-clients.ts b/frontend/src/e2e-test/generated/api-clients.ts index 172fad09119..5fc3ad6dd59 100644 --- a/frontend/src/e2e-test/generated/api-clients.ts +++ b/frontend/src/e2e-test/generated/api-clients.ts @@ -120,6 +120,7 @@ import { StaffAttendancePlanId } from './api-types' import { StaffAttendanceRealtimeId } from 'lib-common/generated/api-types/shared' import { StaffMemberAttendance } from 'lib-common/generated/api-types/attendance' import { VoucherValueDecision } from './api-types' +import { VtjPersonSummary } from './api-types' import { createUrlSearchParams } from 'lib-common/api' import { deserializeJsonAbsence } from 'lib-common/generated/api-types/absence' import { deserializeJsonApplicationDetails } from 'lib-common/generated/api-types/application' @@ -1888,6 +1889,25 @@ export async function getStaffAttendances( } +/** +* Generated from fi.espoo.evaka.shared.dev.DevApi.getVtjPersons +*/ +export async function getVtjPersons( + options?: { mockedTime?: HelsinkiDateTime } +): Promise { + try { + const { data: json } = await devClient.request>({ + url: uri`/vtj-person`.toString(), + method: 'GET', + headers: { EvakaMockedTime: options?.mockedTime?.formatIso() } + }) + return json + } catch (e) { + throw new DevApiError(e) + } +} + + /** * Generated from fi.espoo.evaka.shared.dev.DevApi.healthCheck */ diff --git a/frontend/src/e2e-test/generated/api-types.ts b/frontend/src/e2e-test/generated/api-types.ts index 2653e13368f..b44c12c88a5 100644 --- a/frontend/src/e2e-test/generated/api-types.ts +++ b/frontend/src/e2e-test/generated/api-types.ts @@ -1150,6 +1150,15 @@ export interface VoucherValueDecisionPlacement { unitId: DaycareId } +/** +* Generated from fi.espoo.evaka.shared.dev.VtjPersonSummary +*/ +export interface VtjPersonSummary { + firstName: string + lastName: string + ssn: string +} + export function deserializeJsonCaretaker(json: JsonOf): Caretaker { return { diff --git a/service/src/main/kotlin/fi/espoo/evaka/shared/dev/DevApi.kt b/service/src/main/kotlin/fi/espoo/evaka/shared/dev/DevApi.kt index 8cbdf136d31..e0d7985b5c6 100755 --- a/service/src/main/kotlin/fi/espoo/evaka/shared/dev/DevApi.kt +++ b/service/src/main/kotlin/fi/espoo/evaka/shared/dev/DevApi.kt @@ -653,6 +653,12 @@ UPDATE placement SET end_date = ${bind(req.endDate)}, termination_requested_date .filter { it.guardians.isEmpty() } .map(Citizen::from) + @GetMapping("/vtj-person") + fun getVtjPersons(): List = + MockPersonDetailsService.getAllPersons() + .filter { it.guardians.isEmpty() } + .map(VtjPersonSummary::from) + @PostMapping("/guardian") fun insertGuardians(db: Database, @RequestBody guardians: List) { db.connect { dbc -> dbc.transaction { tx -> guardians.forEach { tx.insert(it) } } } @@ -2320,6 +2326,17 @@ data class Citizen( } } +data class VtjPersonSummary(val ssn: String, val firstName: String, val lastName: String) { + companion object { + fun from(vtjPerson: VtjPerson) = + VtjPersonSummary( + ssn = vtjPerson.socialSecurityNumber, + firstName = vtjPerson.firstNames, + lastName = vtjPerson.lastName, + ) + } +} + data class DevAssistanceFactor( val id: AssistanceFactorId = AssistanceFactorId(UUID.randomUUID()), val childId: ChildId, From 174a8ddf9b2b7030d0ec2fef0860b3e76a378f81 Mon Sep 17 00:00:00 2001 From: Joonas Javanainen Date: Thu, 12 Dec 2024 09:53:59 +0200 Subject: [PATCH 7/9] Add a more realistic test user --- apigw/src/internal/dev-sfi-auth.ts | 2 +- service/src/main/resources/dev-data/employees.sql | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/apigw/src/internal/dev-sfi-auth.ts b/apigw/src/internal/dev-sfi-auth.ts index fa7502ec75e..b0695426c66 100644 --- a/apigw/src/internal/dev-sfi-auth.ts +++ b/apigw/src/internal/dev-sfi-auth.ts @@ -18,7 +18,7 @@ export function createDevEmployeeSfiRouter( sessions, root: '/employee', loginFormHandler: async (req, res) => { - const defaultSsn = '070644-937X' + const defaultSsn = '060195-966B' const persons = _.orderBy( await getVtjPersons(), diff --git a/service/src/main/resources/dev-data/employees.sql b/service/src/main/resources/dev-data/employees.sql index 03a9a3ae3ea..f82a96d4eec 100644 --- a/service/src/main/resources/dev-data/employees.sql +++ b/service/src/main/resources/dev-data/employees.sql @@ -16,6 +16,8 @@ INSERT INTO employee (id, first_name, last_name, email, external_id, active) VAL ('00000000-0000-4000-8005-000000000000', 'Kaisa', 'Kasvattaja', 'kaisa.kasvattaja@espoo.fi', 'espoo-ad:00000000-0000-4000-8005-000000000000', TRUE), ('00000000-0000-4000-8005-000000000001', 'Kalle', 'Kasvattaja', 'kalle.kasvattaja@espoo.fi', 'espoo-ad:00000000-0000-4000-8005-000000000001', TRUE), ('00000000-0000-4000-8006-000000000000', 'Erkki', 'Erityisopettaja', 'erkki.erityisopettaja@espoo.fi', 'espoo-ad:00000000-0000-4000-8006-000000000000', TRUE); +INSERT INTO employee (id, first_name, last_name, social_security_number, active) VALUES + ('fdbf9276-c5a9-4092-87f8-6a27521d940a', 'Hannele', 'Finström', '060195-966B', true); INSERT INTO evaka_user (id, type, employee_id, name) SELECT id, 'EMPLOYEE', id, last_name || ' ' || first_name @@ -27,7 +29,8 @@ INSERT INTO daycare_acl (daycare_id, employee_id, role) VALUES ('2dcf0fc0-788e-11e9-bd12-db78e886e666', '00000000-0000-4000-8006-000000000000', 'SPECIAL_EDUCATION_TEACHER'), ('2dd6e5f6-788e-11e9-bd72-9f1cfe2d8405', '00000000-0000-4000-8004-000000000001', 'UNIT_SUPERVISOR'), ('2dd6e5f6-788e-11e9-bd72-9f1cfe2d8405', '00000000-0000-4000-8005-000000000001', 'STAFF'), - ('2dd6e5f6-788e-11e9-bd72-9f1cfe2d8405', '00000000-0000-4000-8006-000000000000', 'SPECIAL_EDUCATION_TEACHER'); + ('2dd6e5f6-788e-11e9-bd72-9f1cfe2d8405', '00000000-0000-4000-8006-000000000000', 'SPECIAL_EDUCATION_TEACHER'), + ('2dd6e5f6-788e-11e9-bd72-9f1cfe999999', 'fdbf9276-c5a9-4092-87f8-6a27521d940a', 'UNIT_SUPERVISOR'); INSERT INTO message_account (employee_id, type) SELECT id, 'PERSONAL'::message_account_type AS type From 5d7c4997285b025ce0ba9d2e21b82e99835e8d8e Mon Sep 17 00:00:00 2001 From: Joonas Javanainen Date: Thu, 19 Dec 2024 14:08:04 +0200 Subject: [PATCH 8/9] Remove extra null check --- apigw/src/app.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apigw/src/app.ts b/apigw/src/app.ts index 65c7277cad6..eddd998893f 100644 --- a/apigw/src/app.ts +++ b/apigw/src/app.ts @@ -228,7 +228,7 @@ export function apiRouter(config: Config, redisClient: RedisClient) { return employeeSfiIntegration.router(req, res, next) } else { if (citizenSfiIntegration) - return citizenSfiIntegration?.router(req, res, next) + return citizenSfiIntegration.router(req, res, next) } res.sendStatus(404) } From 3f009cb9a3663ad1af818f2f1d26cc8172f20a88 Mon Sep 17 00:00:00 2001 From: Joonas Javanainen Date: Thu, 19 Dec 2024 14:10:14 +0200 Subject: [PATCH 9/9] Better SAML audit events --- apigw/src/shared/routes/saml.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/apigw/src/shared/routes/saml.ts b/apigw/src/shared/routes/saml.ts index 6e22eb5f00d..37243b22046 100755 --- a/apigw/src/shared/routes/saml.ts +++ b/apigw/src/shared/routes/saml.ts @@ -61,6 +61,14 @@ const samlRequestOptions = (req: express.Request): AuthOptions => { const isSamlPostRequest = (req: express.Request) => 'SAMLRequest' in req.body +type SamlAuditEvent = + | 'sign_in_started' + | 'sign_in' + | 'sign_in_failed' + | 'sign_out_requested' + | 'sign_out' + | 'sign_out_failed' + export interface SamlIntegration { router: express.Router logout: AsyncRequestHandler @@ -72,7 +80,8 @@ export function createSamlIntegration( const { sessions, strategyName, saml, defaultPageUrl, authenticate } = endpointConfig - const eventCode = (name: string) => `evaka.saml.${strategyName}.${name}` + const eventCode = (name: SamlAuditEvent) => + `evaka.saml.${strategyName}.${name}` const errorRedirectUrl = (err: unknown) => { let errorCode: string | undefined = undefined if (err instanceof AxiosError) { @@ -165,11 +174,7 @@ export function createSamlIntegration( try { const user = await authenticate(profile) await sessions.login(req, user) - logAuditEvent( - `evaka.saml.${strategyName}.sign_in`, - req, - 'User logged in successfully' - ) + logAuditEvent(eventCode('sign_in'), req, 'User logged in successfully') // Persist in session to allow custom logic per strategy req.session.idpProvider = strategyName