Skip to content

Commit 389c412

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 9ba6a40 commit 389c412

File tree

8 files changed

+129
-63
lines changed

8 files changed

+129
-63
lines changed

apigw/src/enduser/app.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ app.use(passport.initialize())
3838
app.use(passport.session())
3939
passport.serializeUser<Express.User>((user, done) => done(null, user))
4040
passport.deserializeUser<Express.User>((user, done) => done(null, user))
41-
app.use(refreshLogoutToken('enduser'))
41+
app.use(refreshLogoutToken())
4242
setupLoggingMiddleware(app)
4343

4444
function apiRouter() {

apigw/src/internal/app.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ app.use(passport.initialize())
5050
app.use(passport.session())
5151
passport.serializeUser<Express.User>((user, done) => done(null, user))
5252
passport.deserializeUser<Express.User>((user, done) => done(null, user))
53-
app.use(refreshLogoutToken('employee'))
53+
app.use(refreshLogoutToken())
5454
setupLoggingMiddleware(app)
5555

5656
app.use('/api/csp', csp)
@@ -67,7 +67,7 @@ function internalApiRouter() {
6767
router.all('/system/*', (req, res) => res.sendStatus(404))
6868

6969
router.all('/auth/*', (req: express.Request, res, next) => {
70-
if (req.session?.logoutToken?.idpProvider === 'evaka') {
70+
if (req.session?.idpProvider === 'evaka') {
7171
req.url = req.url.replace('saml', 'evaka')
7272
}
7373
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
@@ -63,6 +63,7 @@ async function verifyProfile(profile: AdProfile): Promise<SamlUser> {
6363
}
6464

6565
export function createSamlConfig(): SamlConfig {
66+
if (devLoginEnabled) return {}
6667
if (!adConfig) throw Error('Missing AD SAML configuration')
6768
return {
6869
callbackUrl: adConfig.callbackUrl,

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

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// SPDX-FileCopyrightText: 2017-2021 City of Espoo
2+
//
3+
// SPDX-License-Identifier: LGPL-2.1-or-later
4+
5+
import { ParsedQs } from 'qs'
6+
7+
// TODO: Remove once using a passport-saml version with types matching the
8+
// actual implementation.
9+
declare module 'passport-saml' {
10+
export class SAML {
11+
constructor(options: SamlConfig)
12+
13+
validateRedirect(
14+
container: ParsedQs,
15+
originalQuery: string,
16+
callback: (
17+
err: Error | null,
18+
profile?: Profile | null,
19+
loggedOut?: boolean
20+
) => void
21+
): void
22+
23+
validatePostRequest(
24+
container: Record<string, string>,
25+
callback: (
26+
err: Error | null,
27+
profile?: Profile | null,
28+
loggedOut?: boolean
29+
) => void
30+
): void
31+
}
32+
}

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 { 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

+76-49
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
//
33
// SPDX-License-Identifier: LGPL-2.1-or-later
44

5-
import express, { CookieOptions } from 'express'
5+
import express, { CookieOptions, Request } from 'express'
66
import session from 'express-session'
77
import connectRedis from 'connect-redis'
88
import {
@@ -17,7 +17,6 @@ import {
1717
useSecureCookies
1818
} from './config'
1919
import redis from 'redis'
20-
import { v4 as uuidv4 } from 'uuid'
2120
import { logError } from './logging'
2221
import {
2322
addMinutes,
@@ -28,6 +27,7 @@ import {
2827
import { toMiddleware } from './express'
2928
import AsyncRedisClient from './async-redis-client'
3029
import { fromCallback } from './promise-utils'
30+
import { SAML, Profile } from 'passport-saml'
3131

3232
export type SessionType = 'enduser' | 'employee'
3333

@@ -63,70 +63,89 @@ const sessionCookieOptions: CookieOptions = {
6363
sameSite: 'lax'
6464
}
6565

66-
const logoutCookieOptions: express.CookieOptions = {
67-
path: '/',
68-
httpOnly: true,
69-
secure: useSecureCookies,
70-
sameSite: useSecureCookies ? 'none' : undefined
71-
}
72-
7366
function cookiePrefix(sessionType: SessionType) {
7467
return sessionType === 'enduser' ? 'evaka.eugw' : 'evaka.employee'
7568
}
7669

77-
function logoutCookie(sessionType: SessionType) {
78-
return `${cookiePrefix(sessionType)}.logout`
79-
}
80-
8170
export function sessionCookie(sessionType: SessionType) {
8271
return `${cookiePrefix(sessionType)}.session`
8372
}
8473

85-
export function refreshLogoutToken(sessionType: SessionType) {
86-
return toMiddleware(async (req, res) => {
74+
export function refreshLogoutToken() {
75+
return toMiddleware(async (req) => {
8776
if (!req.session) return
8877
if (!req.session.logoutToken) return
8978
if (!isDate(req.session.cookie.expires)) return
9079
const sessionExpires = req.session.cookie.expires as Date
9180
const logoutExpires = new Date(req.session.logoutToken.expiresAt)
92-
const cookieToken = req.cookies && req.cookies[logoutCookie(sessionType)]
9381
// Logout token should always expire at least 30 minutes later than the session
94-
if (
95-
differenceInMinutes(logoutExpires, sessionExpires) < 30 ||
96-
cookieToken !== req.session.logoutToken.value
97-
) {
98-
await saveLogoutToken(
99-
req,
100-
res,
101-
sessionType,
102-
req.session.logoutToken.idpProvider
103-
)
82+
if (differenceInMinutes(logoutExpires, sessionExpires) < 30) {
83+
await saveLogoutToken(req, req.session.idpProvider)
10484
}
10585
})
10686
}
10787

88+
function logoutKey(nameID: string, sessionIndex?: string) {
89+
return `slo:${nameID}.${sessionIndex}`
90+
}
91+
92+
async function tryParseProfile(
93+
req: Request,
94+
saml: SAML
95+
): Promise<Profile | null | undefined> {
96+
// NOTE: This duplicate parsing can be removed if passport-saml ever exposes
97+
// an alternative for passport.authenticate() that either lets us hook into
98+
// it before any redirects or separate XML parsing and authentication methods.
99+
if (req.query?.SAMLRequest) {
100+
// Redirects have signatures in the original query parameteru
101+
const dummyOrigin = 'http://evaka'
102+
const originalQuery = new URL(req.url, dummyOrigin).search.replace(
103+
/^\?/,
104+
''
105+
)
106+
return await fromCallback<Profile | null | undefined>((cb) =>
107+
saml.validateRedirect(req.query, originalQuery, cb)
108+
)
109+
} else if (req.body?.SAMLRequest) {
110+
// POST logout callbacks have the signature in the message body directly
111+
return await fromCallback<Profile | null | undefined>((cb) =>
112+
saml.validatePostRequest(req.body, cb)
113+
)
114+
}
115+
}
116+
117+
/**
118+
* Save a logout token for a user session to be consumed during logout.
119+
*
120+
* The token is generated by creating an effective secondary
121+
* index in Redis from SAML session identifiers (nameID and sessionIndex).
122+
* This token can then be used with LogoutRequests without relying
123+
* on 3rd party cookies which are starting to be disabled by default on many
124+
* browsers, enabling Single Logout.
125+
*
126+
* This token can be removed if this passport-saml issue is ever fixed:
127+
* https://github.com/node-saml/passport-saml/issues/419
128+
*/
108129
export async function saveLogoutToken(
109130
req: express.Request,
110-
res: express.Response,
111-
sessionType: SessionType,
112131
strategyName: string | null | undefined
113132
): Promise<void> {
114-
if (!req.session) return
133+
if (!req.session || !req.user?.nameID) return
134+
135+
// Persist in session to allow custom logic per strategy
136+
req.session.idpProvider = strategyName
137+
138+
if (!asyncRedisClient) return
139+
const key = logoutKey(req.user.nameID, req.user.sessionIndex)
140+
115141
const now = new Date()
116142
const expires = addMinutes(now, sessionTimeoutMinutes + 60)
117-
const idpProvider = strategyName
118143
const logoutToken = {
119144
expiresAt: expires.valueOf(),
120-
value: req.session.logoutToken ? req.session.logoutToken.value : uuidv4(),
121-
idpProvider
145+
value: req.session.logoutToken?.value || key
122146
}
123147
req.session.logoutToken = logoutToken
124-
res.cookie(logoutCookie(sessionType), logoutToken.value, {
125-
...logoutCookieOptions,
126-
expires
127-
})
128-
if (!asyncRedisClient) return
129-
const key = `logout:${logoutToken.value}`
148+
130149
const ttlSeconds = differenceInSeconds(expires, now)
131150
// https://redis.io/commands/expire - Set a timeout on key
132151
// Return value:
@@ -140,33 +159,41 @@ export async function saveLogoutToken(
140159
await asyncRedisClient.set(key, req.session.id, 'EX', ttlSeconds)
141160
}
142161

143-
// If a logout token is available, delete it and delete the session it points to
144-
export async function consumeLogoutToken(
162+
async function consumeLogoutRequest(
145163
req: express.Request,
146-
res: express.Response,
147-
sessionType: SessionType
164+
saml: SAML
148165
): Promise<void> {
149-
const token = req.cookies && req.cookies[logoutCookie(sessionType)]
150-
if (!token || !asyncRedisClient) return
151-
res.clearCookie(logoutCookie(sessionType), logoutCookieOptions)
152-
const sid = await asyncRedisClient.get(`logout:${token}`)
166+
if (!asyncRedisClient) return
167+
168+
const profile = await tryParseProfile(req, saml)
169+
// Prefer details from the SAML message (profile) but fall back to details
170+
// from the session in case a) this wasn't a SAMLRequest b) it's malformed
171+
// to ensure the logout token is deleted from the store even in non-SLO cases.
172+
const nameID = profile?.nameID ?? req.user?.nameID
173+
const sessionIndex = profile?.sessionIndex ?? req.user?.sessionIndex
174+
175+
if (!nameID) return // Nothing to consume without a SAMLRequest or session
176+
177+
const key = logoutKey(nameID, sessionIndex)
178+
const sid = await asyncRedisClient.get(key)
153179
if (sid) {
154-
await asyncRedisClient.del(`sess:${sid}`, `logout:${token}`)
180+
await asyncRedisClient.del(`sess:${sid}`, key)
155181
}
156182
}
157183

158184
export async function logoutExpress(
159185
req: express.Request,
160186
res: express.Response,
161-
sessionType: SessionType
187+
sessionType: SessionType,
188+
saml: SAML
162189
) {
163190
req.logout()
191+
await consumeLogoutRequest(req, saml)
164192
if (req.session) {
165193
const session = req.session
166194
await fromCallback((cb) => session.destroy(cb))
167195
}
168196
res.clearCookie(sessionCookie(sessionType))
169-
await consumeLogoutToken(req, res, sessionType)
170197
}
171198

172199
export default (sessionType: SessionType) =>

0 commit comments

Comments
 (0)