Skip to content

Commit 1980718

Browse files
committed
APIGW: support SLO without cookies, drop logout cookie
- Many browsers are starting to disable 3rd party cookies by default (even SameSite None) which breaks Single Logout with passport-saml that currently only supports logging out with a cookie - Browser details: https://webkit.org/blog/10218/full-third-party-cookie-blocking-and-more/ and https://blog.google/products/chrome/more-intuitive-privacy-and-security-controls-chrome/ - passport-saml issues: node-saml/passport-saml#419 and node-saml/passport-saml#221 - This is actually a real security issue as passport-saml will give a successful looking response from `passport.authenticate()` in the logout callback which results in IdPs showing "successfully logged out" messages to the user -- even though the user is still fully logged in! - This also causes an usability issue when the user tries to initate SLO from another application, fails to end the eVaka session, attempts a logout from eVaka and gets an ugly JSON error message as a response when APIGW attempts to make a LogoutRequest to the IdP that already ended the session -> will be fixed separately - Other systems like Shibboleth get around the 3rd party cookie issue with Single Logout by not utilizing cookies at all for this but instead use the SAML nameID (and sessionIndex) properties presented in all SAML LogoutRequest messages - Source: https://wiki.shibboleth.net/confluence/display/DEV/IdP+SameSite+Testing#IdPSameSiteTesting-SameSiteandSingleLogout - When logouts are always only done through SAML there's no need for the logout cookie itself but the idea is actually useful as an effective "secondary index" for Redis: - By storing a nameID + sessionIndex keyed item pointing to the session ID, we effectively create a second index that can be used with just the SAML LogoutRequest's nameID and sessionIndex properties
1 parent 21763e8 commit 1980718

File tree

8 files changed

+126
-63
lines changed

8 files changed

+126
-63
lines changed

apigw/src/enduser/app.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ app.use(passport.initialize())
4848
app.use(passport.session())
4949
passport.serializeUser<Express.User>((user, done) => done(null, user))
5050
passport.deserializeUser<Express.User>((user, done) => done(null, user))
51-
app.use(refreshLogoutToken('enduser'))
51+
app.use(refreshLogoutToken())
5252
setupLoggingMiddleware(app)
5353

5454
function apiRouter() {

apigw/src/internal/app.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ app.use(passport.initialize())
5252
app.use(passport.session())
5353
passport.serializeUser<Express.User>((user, done) => done(null, user))
5454
passport.deserializeUser<Express.User>((user, done) => done(null, user))
55-
app.use(refreshLogoutToken('employee'))
55+
app.use(refreshLogoutToken())
5656
setupLoggingMiddleware(app)
5757

5858
app.use('/api/csp', csp)
@@ -69,7 +69,7 @@ function internalApiRouter() {
6969
router.all('/system/*', (req, res) => res.sendStatus(404))
7070

7171
router.all('/auth/*', (req: express.Request, res, next) => {
72-
if (req.session?.logoutToken?.idpProvider === 'evaka') {
72+
if (req.session?.idpProvider === 'evaka') {
7373
req.url = req.url.replace('saml', 'evaka')
7474
}
7575
next()

apigw/src/internal/routes/auth-status.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
} from '../../shared/service-client'
1111
import { logoutExpress } from '../../shared/session'
1212
import { fromCallback } from '../../shared/promise-utils'
13+
import { SAML } from 'passport-saml'
1314

1415
export default toRequestHandler(async (req, res) => {
1516
const user = req.user
@@ -28,8 +29,8 @@ export default toRequestHandler(async (req, res) => {
2829
roles: [...globalRoles, ...allScopedRoles]
2930
})
3031
} else {
31-
// device has been removed
32-
await logoutExpress(req, res, 'employee')
32+
// device has been removed (SAML config not relevant)
33+
await logoutExpress(req, res, 'employee', new SAML({}))
3334
res.status(200).json({ loggedIn: false })
3435
}
3536
} else {

apigw/src/shared/auth/ad-saml.ts

+1
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ async function verifyProfile(profile: AdProfile): Promise<SamlUser> {
6565
}
6666

6767
export function createSamlConfig(redisClient?: RedisClient): SamlConfig {
68+
if (devLoginEnabled) return {}
6869
if (!adConfig) throw Error('Missing AD SAML configuration')
6970
return {
7071
acceptedClockSkewMs: 0,

apigw/src/shared/express.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ export interface LogoutToken {
1111
// milliseconds value of a Date. Not an actual Date because it will be JSONified
1212
expiresAt: number
1313
value: string
14-
idpProvider?: string | null
1514
}
1615

1716
export type AsyncRequestHandler = (
@@ -52,6 +51,7 @@ export class InvalidRequest extends BaseError {}
5251
// Use TS interface merging to add fields to express req.session
5352
declare module 'express-session' {
5453
interface SessionData {
54+
idpProvider?: string | null
5555
logoutToken?: LogoutToken
5656
}
5757
}

apigw/src/shared/passport-saml.d.ts

+27
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
declare module 'passport-saml' {
1313
import type passport from 'passport'
1414
import type express from 'express'
15+
import { ParsedQs } from 'qs'
1516

1617
export interface CacheItem {
1718
createdAt: number
@@ -177,4 +178,30 @@ declare module 'passport-saml' {
177178
callback: (err: Error | null, metadata?: string) => void
178179
): string
179180
}
181+
182+
// SPDX-FileCopyrightText: 2017-2021 City of Espoo
183+
//
184+
// SPDX-License-Identifier: LGPL-2.1-or-later
185+
export class SAML {
186+
constructor(options: SamlConfig)
187+
188+
validateRedirect(
189+
container: ParsedQs,
190+
originalQuery: string,
191+
callback: (
192+
err: Error | null,
193+
profile?: Profile | null,
194+
loggedOut?: boolean
195+
) => void
196+
): void
197+
198+
validatePostRequest(
199+
container: Record<string, string>,
200+
callback: (
201+
err: Error | null,
202+
profile?: Profile | null,
203+
loggedOut?: boolean
204+
) => void
205+
): void
206+
}
180207
}

apigw/src/shared/routes/auth/saml/index.ts

+13-8
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { logoutExpress, saveLogoutToken } from '../../../session'
1313
import { fromCallback } from '../../../promise-utils'
1414
import { getEmployees } from '../../../dev-api'
1515
import _ from 'lodash'
16-
import type { AuthenticateOptions } from 'passport-saml'
16+
import { AuthenticateOptions, SAML } from 'passport-saml'
1717

1818
const urlencodedParser = urlencoded({ extended: false })
1919

@@ -53,8 +53,7 @@ function getRedirectUrl(req: express.Request): string {
5353
}
5454

5555
function createLoginHandler({
56-
strategyName,
57-
sessionType
56+
strategyName
5857
}: SamlEndpointConfig): express.RequestHandler {
5958
return (req, res, next) => {
6059
logAuditEvent(
@@ -93,7 +92,7 @@ function createLoginHandler({
9392
req,
9493
'User logged in successfully'
9594
)
96-
await saveLogoutToken(req, res, sessionType, strategyName)
95+
await saveLogoutToken(req, strategyName)
9796
const redirectUrl = getRedirectUrl(req)
9897
logDebug(`Redirecting to ${redirectUrl}`, req, { redirectUrl })
9998
return res.redirect(redirectUrl)
@@ -115,10 +114,14 @@ function createLoginHandler({
115114
}
116115

117116
function createLogoutHandler({
117+
samlConfig,
118118
strategy,
119119
strategyName,
120120
sessionType
121121
}: SamlEndpointConfig): express.RequestHandler {
122+
// For parsing SAML messages outside the strategy
123+
const saml = new SAML(samlConfig)
124+
122125
return toRequestHandler(async (req, res) => {
123126
logAuditEvent(
124127
`evaka.saml.${strategyName}.sign_out_requested`,
@@ -131,7 +134,7 @@ function createLogoutHandler({
131134
strategy.logout(req, cb)
132135
)
133136
logDebug('Logging user out from passport.', req)
134-
await logoutExpress(req, res, sessionType)
137+
await logoutExpress(req, res, sessionType, saml)
135138
return res.redirect(redirectUrl)
136139
} catch (err) {
137140
logAuditEvent(
@@ -148,7 +151,7 @@ function createLogoutHandler({
148151
'User signed out'
149152
)
150153
logDebug('Logging user out from passport.', req)
151-
await logoutExpress(req, res, sessionType)
154+
await logoutExpress(req, res, sessionType, saml)
152155
res.redirect(getRedirectUrl(req))
153156
}
154157
})
@@ -161,7 +164,9 @@ function createLogoutHandler({
161164
// * HTTP redirect: the browser makes a GET request with query parameters
162165
// * HTTP POST: the browser makes a POST request with URI-encoded form body
163166
export default function createSamlRouter(config: SamlEndpointConfig): Router {
164-
const { strategyName, strategy, pathIdentifier } = config
167+
const { strategyName, strategy, samlConfig, pathIdentifier } = config
168+
// For parsing SAML messages outside the strategy
169+
const saml = new SAML(samlConfig)
165170

166171
passport.use(strategyName, strategy)
167172

@@ -173,7 +178,7 @@ export default function createSamlRouter(config: SamlEndpointConfig): Router {
173178
req,
174179
'Logout callback called'
175180
)
176-
await logoutExpress(req, res, config.sessionType)
181+
await logoutExpress(req, res, config.sessionType, saml)
177182
})
178183

179184
const router = Router()

apigw/src/shared/session.ts

+78-49
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@ import {
99
differenceInSeconds,
1010
isDate
1111
} from 'date-fns'
12-
import express, { CookieOptions } from 'express'
12+
import express, { CookieOptions, Request } from 'express'
1313
import session from 'express-session'
14+
import { Profile, SAML } from 'passport-saml'
1415
import { RedisClient } from 'redis'
15-
import { v4 as uuidv4 } from 'uuid'
1616
import AsyncRedisClient from './async-redis-client'
1717
import { cookieSecret, sessionTimeoutMinutes, useSecureCookies } from './config'
1818
import { toMiddleware } from './express'
@@ -31,70 +31,89 @@ const sessionCookieOptions: CookieOptions = {
3131
sameSite: 'lax'
3232
}
3333

34-
const logoutCookieOptions: express.CookieOptions = {
35-
path: '/',
36-
httpOnly: true,
37-
secure: useSecureCookies,
38-
sameSite: useSecureCookies ? 'none' : undefined
39-
}
40-
4134
function cookiePrefix(sessionType: SessionType) {
4235
return sessionType === 'enduser' ? 'evaka.eugw' : 'evaka.employee'
4336
}
4437

45-
function logoutCookie(sessionType: SessionType) {
46-
return `${cookiePrefix(sessionType)}.logout`
47-
}
48-
4938
export function sessionCookie(sessionType: SessionType) {
5039
return `${cookiePrefix(sessionType)}.session`
5140
}
5241

53-
export function refreshLogoutToken(sessionType: SessionType) {
54-
return toMiddleware(async (req, res) => {
42+
export function refreshLogoutToken() {
43+
return toMiddleware(async (req) => {
5544
if (!req.session) return
5645
if (!req.session.logoutToken) return
5746
if (!isDate(req.session.cookie.expires)) return
5847
const sessionExpires = req.session.cookie.expires as Date
5948
const logoutExpires = new Date(req.session.logoutToken.expiresAt)
60-
const cookieToken = req.cookies && req.cookies[logoutCookie(sessionType)]
6149
// Logout token should always expire at least 30 minutes later than the session
62-
if (
63-
differenceInMinutes(logoutExpires, sessionExpires) < 30 ||
64-
cookieToken !== req.session.logoutToken.value
65-
) {
66-
await saveLogoutToken(
67-
req,
68-
res,
69-
sessionType,
70-
req.session.logoutToken.idpProvider
71-
)
50+
if (differenceInMinutes(logoutExpires, sessionExpires) < 30) {
51+
await saveLogoutToken(req, req.session.idpProvider)
7252
}
7353
})
7454
}
7555

56+
function logoutKey(nameID: string, sessionIndex?: string) {
57+
return `slo:${nameID}.${sessionIndex}`
58+
}
59+
60+
async function tryParseProfile(
61+
req: Request,
62+
saml: SAML
63+
): Promise<Profile | null | undefined> {
64+
// NOTE: This duplicate parsing can be removed if passport-saml ever exposes
65+
// an alternative for passport.authenticate() that either lets us hook into
66+
// it before any redirects or separate XML parsing and authentication methods.
67+
if (req.query?.SAMLRequest) {
68+
// Redirects have signatures in the original query parameteru
69+
const dummyOrigin = 'http://evaka'
70+
const originalQuery = new URL(req.url, dummyOrigin).search.replace(
71+
/^\?/,
72+
''
73+
)
74+
return await fromCallback<Profile | null | undefined>((cb) =>
75+
saml.validateRedirect(req.query, originalQuery, cb)
76+
)
77+
} else if (req.body?.SAMLRequest) {
78+
// POST logout callbacks have the signature in the message body directly
79+
return await fromCallback<Profile | null | undefined>((cb) =>
80+
saml.validatePostRequest(req.body, cb)
81+
)
82+
}
83+
}
84+
85+
/**
86+
* Save a logout token for a user session to be consumed during logout.
87+
*
88+
* The token is generated by creating an effective secondary
89+
* index in Redis from SAML session identifiers (nameID and sessionIndex).
90+
* This token can then be used with LogoutRequests without relying
91+
* on 3rd party cookies which are starting to be disabled by default on many
92+
* browsers, enabling Single Logout.
93+
*
94+
* This token can be removed if this passport-saml issue is ever fixed:
95+
* https://github.com/node-saml/passport-saml/issues/419
96+
*/
7697
export async function saveLogoutToken(
7798
req: express.Request,
78-
res: express.Response,
79-
sessionType: SessionType,
8099
strategyName: string | null | undefined
81100
): Promise<void> {
82-
if (!req.session) return
101+
if (!req.session || !req.user?.nameID) return
102+
103+
// Persist in session to allow custom logic per strategy
104+
req.session.idpProvider = strategyName
105+
106+
if (!asyncRedisClient) return
107+
const key = logoutKey(req.user.nameID, req.user.sessionIndex)
108+
83109
const now = new Date()
84110
const expires = addMinutes(now, sessionTimeoutMinutes + 60)
85-
const idpProvider = strategyName
86111
const logoutToken = {
87112
expiresAt: expires.valueOf(),
88-
value: req.session.logoutToken ? req.session.logoutToken.value : uuidv4(),
89-
idpProvider
113+
value: req.session.logoutToken?.value || key
90114
}
91115
req.session.logoutToken = logoutToken
92-
res.cookie(logoutCookie(sessionType), logoutToken.value, {
93-
...logoutCookieOptions,
94-
expires
95-
})
96-
if (!asyncRedisClient) return
97-
const key = `logout:${logoutToken.value}`
116+
98117
const ttlSeconds = differenceInSeconds(expires, now)
99118
// https://redis.io/commands/expire - Set a timeout on key
100119
// Return value:
@@ -108,33 +127,43 @@ export async function saveLogoutToken(
108127
await asyncRedisClient.set(key, req.session.id, 'EX', ttlSeconds)
109128
}
110129

111-
// If a logout token is available, delete it and delete the session it points to
112-
export async function consumeLogoutToken(
130+
async function consumeLogoutRequest(
113131
req: express.Request,
114-
res: express.Response,
115-
sessionType: SessionType
132+
saml: SAML
116133
): Promise<void> {
117-
const token = req.cookies && req.cookies[logoutCookie(sessionType)]
118-
if (!token || !asyncRedisClient) return
119-
res.clearCookie(logoutCookie(sessionType), logoutCookieOptions)
120-
const sid = await asyncRedisClient.get(`logout:${token}`)
134+
if (!asyncRedisClient) return
135+
136+
const profile = await tryParseProfile(req, saml)
137+
// Prefer details from the SAML message (profile) but fall back to details
138+
// from the session in case a) this wasn't a SAMLRequest b) it's malformed
139+
// to ensure the logout token is deleted from the store even in non-SLO cases.
140+
const nameID = profile?.nameID ?? req.user?.nameID
141+
const sessionIndex = profile?.sessionIndex ?? req.user?.sessionIndex
142+
143+
if (!nameID) return // Nothing to consume without a SAMLRequest or session
144+
145+
const key = logoutKey(nameID, sessionIndex)
146+
const sid = await asyncRedisClient.get(key)
121147
if (sid) {
122-
await asyncRedisClient.del(`sess:${sid}`, `logout:${token}`)
148+
// Ensure both session and logout keys are cleared in case no cookies were
149+
// available -> no req.session was available to be deleted.
150+
await asyncRedisClient.del(`sess:${sid}`, key)
123151
}
124152
}
125153

126154
export async function logoutExpress(
127155
req: express.Request,
128156
res: express.Response,
129-
sessionType: SessionType
157+
sessionType: SessionType,
158+
saml: SAML
130159
) {
131160
req.logout()
161+
await consumeLogoutRequest(req, saml)
132162
if (req.session) {
133163
const session = req.session
134164
await fromCallback((cb) => session.destroy(cb))
135165
}
136166
res.clearCookie(sessionCookie(sessionType))
137-
await consumeLogoutToken(req, res, sessionType)
138167
}
139168

140169
export default (sessionType: SessionType, redisClient?: RedisClient) => {

0 commit comments

Comments
 (0)