diff --git a/apigw/src/app.ts b/apigw/src/app.ts
index d5698b9d9bb..eddd998893f 100644
--- a/apigw/src/app.ts
+++ b/apigw/src/app.ts
@@ -2,20 +2,23 @@
//
// 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 {
+ createCitizenSuomiFiIntegration,
+ createEmployeeSuomiFiIntegration
+} 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 { createDevEmployeeSfiRouter } from './internal/dev-sfi-auth.js'
+import { createKeycloakEmployeeIntegration } from './internal/keycloak-employee-saml.js'
import {
checkMobileEmployeeIdToken,
devApiE2ESignup,
@@ -32,16 +35,15 @@ 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 { validateRelayStateUrl } from './shared/saml/index.js'
import { sessionSupport } from './shared/session.js'
export function apiRouter(config: Config, redisClient: RedisClient) {
@@ -58,15 +60,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/')) {
@@ -142,95 +142,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 citizenSfiIntegration: 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: '/'
- })
+ citizenSfiIntegration = createCitizenSuomiFiIntegration(
+ citizenSessions,
+ config.sfi.saml,
+ redisClient
)
+ router.use('/citizen/auth/sfi', citizenSfiIntegration.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,48 +178,60 @@ 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') {
+ adIntegration = createSamlAdIntegration(
+ employeeSessions,
+ config.ad,
+ redisClient
+ )
+ router.use('/employee/auth/ad', adIntegration.router)
+ }
+
+ let employeeSfiIntegration: SamlIntegration | undefined
+ if (config.sfi.type === 'mock') {
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'
- })
+ '/employee/auth/sfi',
+ createDevEmployeeSfiRouter(employeeSessions)
+ )
+ } 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(
+ employeeSessions,
+ config.keycloakEmployee,
+ redisClient
+ )
+ router.use('/employee/auth/keycloak', keycloakEmployeeIntegration.router)
+
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'
- })
+ '/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) {
@@ -306,18 +249,74 @@ 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 (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('/')
+ })
+ )
+ 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 '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')
+ })
+ )
+
// 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..28f65b763c2 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 { citizenLogin } from '../shared/service-client.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, employeeSuomiFiLogin } 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 authenticateCitizen = authenticateProfile(
Profile,
async (samlSession, profile) => {
const socialSecurityNumber = profile[SUOMI_FI_SSN_KEY]?.trim()
@@ -44,3 +50,65 @@ export const authenticateSuomiFi = authenticateProfile(
}
}
)
+
+export function createCitizenSuomiFiIntegration(
+ sessions: Sessions<'citizen'>,
+ config: EvakaSamlConfig,
+ redisClient: RedisClient
+) {
+ return createSamlIntegration({
+ sessions,
+ strategyName: 'suomifi',
+ saml: new SAML(
+ createSamlConfig(
+ config,
+ redisCacheProvider(redisClient, { keyPrefix: 'suomifi-saml-resp:' })
+ )
+ ),
+ 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/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/dev-sfi-auth.ts b/apigw/src/internal/dev-sfi-auth.ts
new file mode 100644
index 00000000000..b0695426c66
--- /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 = '060195-966B'
+
+ 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
+
+
+
+ `)
+ },
+ 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/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/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/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/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/apigw/src/shared/routes/saml.ts b/apigw/src/shared/routes/saml.ts
index 380b18ff149..37243b22046 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,19 +54,34 @@ 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
+
+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
+}
+
+export function createSamlIntegration(
endpointConfig: SamlEndpointConfig
-): express.Router {
+): SamlIntegration {
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) {
@@ -77,12 +92,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 +104,253 @@ 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(eventCode('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 +359,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
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),
{
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..3c0ad8c6c8a 100644
--- a/frontend/src/citizen-frontend/navigation/const.ts
+++ b/frontend/src/citizen-frontend/navigation/const.ts
@@ -2,21 +2,15 @@
//
// 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}`
-) =>
- `/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)}`
-
-export const getLogoutUri = (user: User) =>
- `/api/application/auth/${
- user?.authLevel === 'WEAK' ? 'evaka-customer' : 'saml'
- }/logout`
+) => `/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/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/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 257123c2835..f3e5a066e90 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') {
@@ -30,9 +30,9 @@ const redirectUri = (() => {
}${searchParams}${window.location.hash}`
})()
-export function getLoginUrl(type: 'evaka' | 'saml' = 'saml') {
+export function getLoginUrl(type: 'ad' | 'keycloak' | 'sfi' = '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..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'
@@ -39,13 +40,27 @@ function Login({ error }: Props) {
{i18n.login.subtitle}
-
+
{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
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,
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