Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] Disallow concurrent sessions #687

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion designer/server/src/common/constants/session-names.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,6 @@ export const sessionNames = {
)
},
successNotification: /** @type {const} */ ('successNotification'),
errorList: /** @type {const} */ ('errorList')
errorList: /** @type {const} */ ('errorList'),
forceSignOut: /** @type {const} */ ('forceSignOut')
}
24 changes: 22 additions & 2 deletions designer/server/src/common/helpers/auth/session-cookie.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@ import {
} from '~/src/common/helpers/auth/get-user-session.js'
import { refreshAccessToken } from '~/src/common/helpers/auth/refresh-token.js'
import { createUserSession } from '~/src/common/helpers/auth/user-session.js'
import { createLogger } from '~/src/common/helpers/logging/logger.js'
import config from '~/src/config.js'

const logger = createLogger()

/**
* @type {ServerRegisterPluginObject<void>}
*/
Expand Down Expand Up @@ -44,14 +47,13 @@ const sessionCookie = {
yar.flash(sessionNames.redirectTo, url.pathname)
}

// Redirect to callback route
return '/auth/callback'
},

/**
* Validate session using auth credentials
* @param {Request<{ AuthArtifactsExtra: AuthArtifacts }>} [request]
* @param {{ sessionId: string, user: UserCredentials }} [session] - Session cookie state
* @param {{ sessionId: string, flowId: string, user: UserCredentials }} [session] - Session cookie state
*/
async validate(request, session) {
if (!request) {
Expand All @@ -60,6 +62,20 @@ const sessionCookie = {

let credentials = await getUserSession(request, session)

if (
session &&
credentials &&
session.flowId !== credentials.flowId
) {
logger.debug(
`Found duplicate session for user ${credentials.user?.id}. Dropping old session`
)

request.yar.flash(sessionNames.forceSignOut, true) // so we can detect the force sign out in the redirectTo function

return { isValid: false }
}

if (hasUser(credentials)) {
const { auth } = request

Expand All @@ -68,6 +84,10 @@ const sessionCookie = {

// Refresh session when token has expired
if (hasExpired(credentials)) {
logger.debug(
`User ${credentials.user.id}'s session has expired. Refreshing.`
)

const { body: artifacts } = await refreshAccessToken(request)
credentials = await createUserSession(request, artifacts)
}
Expand Down
2 changes: 2 additions & 0 deletions designer/server/src/common/helpers/auth/user-session.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { DateTime } from 'luxon'
import { v4 as uuidv4 } from 'uuid'

import {
getUserClaims,
Expand Down Expand Up @@ -48,6 +49,7 @@ export async function createUserSession(request, artifacts) {
credentials.idToken = artifacts.id_token
credentials.refreshToken = artifacts.refresh_token
credentials.expiresIn = artifacts.expires_in
credentials.flowId = uuidv4() // a unique ID for the current session

if (!hasAuthenticated(credentials)) {
throw new Error('Missing user authentication tokens')
Expand Down
14 changes: 13 additions & 1 deletion designer/server/src/routes/account/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@ export default [
async handler(request, h) {
const { cookieAuth, yar } = request

const forceSignOut = yar.flash(sessionNames.forceSignOut).at(0) ?? false

if (forceSignOut) {
// If we're forcing them out (e.g. duplicate session), show them an error
// else they might be renewing/refreshing their session, so go to the callback
request.cookieAuth.clear()
return h.redirect('/account/signed-out')
}

// Create user session
const credentials = await createUserSession(request)

Expand All @@ -23,7 +32,10 @@ export default [
}

// Add to authentication cookie for session validation
cookieAuth.set({ sessionId: credentials.user.id })
cookieAuth.set({
sessionId: credentials.user.id,
flowId: credentials.flowId // always store the latest flowId so we can detect stale sessions later
})

const redirect =
yar.flash(sessionNames.redirectTo).at(0) ?? formsLibraryPath
Expand Down
67 changes: 66 additions & 1 deletion designer/server/src/routes/account/auth.test.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { StatusCodes } from 'http-status-codes'

import { createServer } from '~/src/createServer.js'
import * as forms from '~/src/lib/forms.js'
import {
auth,
authGroupsInvalid,
authScopesEmpty
} from '~/test/fixtures/auth.js'
import { forms as formMetadataList } from '~/test/fixtures/forms.js'

jest.mock('~/src/lib/forms.js')

Expand Down Expand Up @@ -52,8 +56,69 @@
})
}
)

it('Should handle stale sessions', async () => {
jest.mocked(forms.list).mockResolvedValueOnce({
data: formMetadataList,
meta: {}
})

// log in with session one
const callbackResponseS1 = await doAuthCallback(server, auth)
const cookiesS1 = callbackResponseS1.headers['set-cookie']
expect(callbackResponseS1.headers.location).toBe('/library')

// log in with session two
// should be allowed, but now S2 is marked as the 'active' session
const callbackResponseS2 = await doAuthCallback(server, auth)
const cookiesS2 = callbackResponseS2.headers['set-cookie']
expect(callbackResponseS2.headers.location).toBe('/library')

// attempt to access a page with session one (now marked as stale)
// this is invalid - session should not be accepted
const libraryResponse2S1 = await doCallLibrary(server, cookiesS1)
expect(libraryResponse2S1.statusCode).toBe(StatusCodes.MOVED_TEMPORARILY)
expect(libraryResponse2S1.headers.location).toBe('/account/signed-out') // redirect to signed out

Check failure on line 81 in designer/server/src/routes/account/auth.test.js

View workflow job for this annotation

GitHub Actions / Unit tests

Authentiation › Should handle stale sessions

expect(received).toBe(expected) // Object.is equality Expected: "/account/signed-out" Received: "/auth/callback" at Object.toBe (src/routes/account/auth.test.js:81:49)

// attempt to access a page with session two
// this is valid as it's the active session
const libraryResponse2S2 = await doCallLibrary(server, cookiesS2)
expect(libraryResponse2S2.statusCode).toBe(StatusCodes.OK) // this remains the active session, so it should continue
})
})

/**
* @import { Server, ServerInjectResponse } from '@hapi/hapi'
* @param {Server<any>} server
* @param {ServerInjectOptions['auth']} auth
*/
function doAuthCallback(server, auth) {
return server.inject({
method: 'get',
url: '/auth/callback',
auth
})
}

/**
* @param {Server<any>} server
* @param {string | string[] | undefined} cookies
*/
function doCallLibrary(server, cookies) {
if (!cookies || typeof cookies === 'string') {
throw new Error('Invalid cookies')
}

const headers = {
cookie: cookies.join('; ')
}

return server.inject({
method: 'get',
url: '/library',
headers
})
}

/**
* @import { Server, ServerInjectOptions, ServerInjectResponse } from '@hapi/hapi'
*/
11 changes: 11 additions & 0 deletions designer/server/src/typings/hapi/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ declare module '@hapi/hapi' {
idToken: string
refreshToken: string
expiresIn: number
flowId: string
}

interface UserCredentials {
Expand Down Expand Up @@ -95,6 +96,7 @@ declare module '@hapi/yar' {
type RedirectToKey = (typeof sessionNames)['redirectTo']
type SuccessNotification = (typeof sessionNames)['successNotification']
type ErrorListKey = (typeof sessionNames)['errorList']
type ForceSignOutKey = (typeof sessionNames)['forceSignOut']

// Export known validation session keys
type ValidationSession = (typeof sessionNames)['validationFailure']
Expand Down Expand Up @@ -144,11 +146,20 @@ declare module '@hapi/yar' {
type: ValidationSession['fileDownload']
): ValidationFailure<{ email: string }>[]

flash(
type: ValidationSession['fileDownload']
): ValidationFailure<{ email: string }>[]

/**
* Get temporary error messages relating to the current page.
*/
flash(type: ErrorListKey): ErrorDetailsItem[]

/**
* Get temporary boolean values from the session
*/
flash(type: ForceSignOutKey): boolean[]

/**
* Get form metadata from the session
*/
Expand Down
3 changes: 2 additions & 1 deletion designer/server/test/fixtures/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@ export function credentials(options) {
token: tokens.access_token,
idToken: tokens.id_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in
expiresIn: tokens.expires_in,
flowId: ''
})
}

Expand Down
35 changes: 35 additions & 0 deletions designer/server/test/fixtures/forms.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
const now = new Date()
const authorId = 'f50ceeed-b7a4-47cf-a498-094efc99f8bc'
const authorDisplayName = 'Enrique Chase'

/**
* @satisfies {import("@defra/forms-model").FormMetadataAuthor}
*/
const author = {
id: authorId,
displayName: authorDisplayName
}

/**
* @satisfies {import("@defra/forms-model").FormMetadata[]}
*/
export const forms = [
{
id: '661e4ca5039739ef2902b214',
slug: 'my-form-slug',
title: 'Test form',
organisation: 'Defra',
teamName: 'Defra Forms',
teamEmail: '[email protected]',
draft: {
createdAt: now,
createdBy: author,
updatedAt: now,
updatedBy: author
},
createdAt: now,
createdBy: author,
updatedAt: now,
updatedBy: author
}
]
30 changes: 23 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,5 +70,8 @@
"engines": {
"node": "^22.11.0",
"npm": "^10.9.0"
},
"dependencies": {
"uuid": "^11.0.5"
}
}
Loading