Skip to content

Commit 08d6dc1

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 b6d613f commit 08d6dc1

File tree

8 files changed

+124
-62
lines changed

8 files changed

+124
-62
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

+71-48
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,87 @@ 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: As of writing this function, passport-saml is yet to release a proper
97+
// profile parsing method so we pretty much duplicate passport.authenticate()
98+
// functionality BUT skip the automatic redirect on SAMLRequest (we want
99+
// to handle the request ourselves first).
100+
if (req.query?.SAMLRequest) {
101+
// Redirects have signatures in the original query parameteru
102+
const dummyBase = 'http://'
103+
const originalQuery = new URL(req.url, dummyBase).search.replace(/^\?/, '')
104+
return await fromCallback<Profile | null | undefined>((cb) =>
105+
saml.validateRedirect(req.query, originalQuery, cb)
106+
)
107+
} else if (req.body?.SAMLRequest) {
108+
// POST logout callbacks have the signature in the message body directly
109+
return await fromCallback<Profile | null | undefined>((cb) =>
110+
saml.validatePostRequest(req.body, cb)
111+
)
112+
}
113+
}
114+
115+
/**
116+
* Save a logout token for a user session to be consumed during logout.
117+
*
118+
* The token is generated by creating an effective secondary
119+
* index in Redis from SAML session identifiers (nameID and sessionIndex).
120+
* This token can then be used with LogoutRequests without relying
121+
* on 3rd party cookies which are starting to be disabled by default on many
122+
* browsers, enabling Single Logout.
123+
*
124+
* This token can be removed if this passport-saml issue is ever fixed:
125+
* https://github.com/node-saml/passport-saml/issues/419
126+
*/
108127
export async function saveLogoutToken(
109128
req: express.Request,
110-
res: express.Response,
111-
sessionType: SessionType,
112129
strategyName: string | null | undefined
113130
): Promise<void> {
114-
if (!req.session) return
131+
if (!req.session || !req.user?.nameID) return
132+
133+
// Persist in session to allow custom logic per strategy
134+
req.session.idpProvider = strategyName
135+
136+
if (!asyncRedisClient) return
137+
const key = logoutKey(req.user.nameID, req.user.sessionIndex)
138+
115139
const now = new Date()
116140
const expires = addMinutes(now, sessionTimeoutMinutes + 60)
117-
const idpProvider = strategyName
118141
const logoutToken = {
119142
expiresAt: expires.valueOf(),
120-
value: req.session.logoutToken ? req.session.logoutToken.value : uuidv4(),
121-
idpProvider
143+
value: req.session.logoutToken?.value || key
122144
}
123145
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}`
146+
130147
const ttlSeconds = differenceInSeconds(expires, now)
131148
// https://redis.io/commands/expire - Set a timeout on key
132149
// Return value:
@@ -141,32 +158,38 @@ export async function saveLogoutToken(
141158
}
142159

143160
// If a logout token is available, delete it and delete the session it points to
144-
export async function consumeLogoutToken(
161+
async function consumeLogoutToken(
145162
req: express.Request,
146-
res: express.Response,
147-
sessionType: SessionType
163+
saml: SAML
148164
): 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}`)
165+
if (!asyncRedisClient) return
166+
167+
const profile = await tryParseProfile(req, saml)
168+
if (!profile?.nameID) {
169+
// Not a SAMLRequest, just ensure the session is cleared
170+
return void (await asyncRedisClient.del(`sess:${req.session.id}`))
171+
}
172+
173+
const key = logoutKey(profile.nameID, profile.sessionIndex)
174+
const sid = await asyncRedisClient.get(key)
153175
if (sid) {
154-
await asyncRedisClient.del(`sess:${sid}`, `logout:${token}`)
176+
await asyncRedisClient.del(`sess:${sid}`, key)
155177
}
156178
}
157179

158180
export async function logoutExpress(
159181
req: express.Request,
160182
res: express.Response,
161-
sessionType: SessionType
183+
sessionType: SessionType,
184+
saml: SAML
162185
) {
163186
req.logout()
164187
if (req.session) {
165188
const session = req.session
166189
await fromCallback((cb) => session.destroy(cb))
167190
}
168191
res.clearCookie(sessionCookie(sessionType))
169-
await consumeLogoutToken(req, res, sessionType)
192+
await consumeLogoutToken(req, saml)
170193
}
171194

172195
export default (sessionType: SessionType) =>

0 commit comments

Comments
 (0)