diff --git a/src/session/app-session-cookie-store.ts b/src/session/app-session-cookie-store.ts deleted file mode 100644 index 8a0de7d..0000000 --- a/src/session/app-session-cookie-store.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { - AppSession, - AppSessionCookieStoreFactory, - AppSessionKeys, - AppSessionQuery, -} from './types' -import { getSignedCookie } from '../utils' -import { setSignedCookie } from '../utils' - -const toKeys = (keys: AppSessionQuery): AppSessionKeys => { - const { spaceId, userId } = keys - return { - spaceId: typeof spaceId === 'number' ? spaceId : parseInt(spaceId, 10), - userId: typeof userId === 'number' ? userId : parseInt(userId, 10), - } -} - -type AppSessionCookiePayload = { - sessions: AppSession[] -} - -const defaultCookieName = 'sb.auth' - -export const simpleSessionCookieStore: AppSessionCookieStoreFactory = - (params) => (requestParams) => { - const { clientId, clientSecret } = params - const cookieName = params.cookieName ?? defaultCookieName - const { req, res } = requestParams - const getCookie = - getSignedCookie(clientSecret)(cookieName) - const setCookie = - setSignedCookie(clientSecret)(cookieName) - const getSessions = () => (getCookie(req) ?? { sessions: [] }).sessions - return { - get: async (params) => - getSessions().find( - matches({ ...toKeys(params), appClientId: clientId }), - ), - getAll: async () => - getSessions().filter((session) => session.appClientId === clientId), - put: async (session) => { - const filter = matches(session) - const otherSessions = getSessions().filter((s) => !filter(s)) - const allSessions = [...otherSessions, session] - - setCookie({ sessions: allSessions })(res) - return session - }, - remove: async (params) => { - const sessions = getSessions() - const toRemove = sessions.find( - matches({ ...toKeys(params), appClientId: clientId }), - ) - const allOther = sessions.filter((s) => s !== toRemove) - setCookie({ sessions: allOther })(res) - return toRemove - }, - } - } - -const matches = - (a: AppSessionKeys & { appClientId: string }) => - (b: AppSessionKeys & { appClientId: string }) => - a.appClientId === b.appClientId && - a.spaceId === b.spaceId && - a.userId === b.userId diff --git a/src/session/authCookieName.ts b/src/session/authCookieName.ts new file mode 100644 index 0000000..636734c --- /dev/null +++ b/src/session/authCookieName.ts @@ -0,0 +1,5 @@ +import { AuthHandlerParams } from '../storyblok-auth-api' + +const defaultCookieName = 'sb.auth' +export const authCookieName = (params: Pick) => + params.cookieName ?? defaultCookieName diff --git a/src/session/crud/getAllSessions.ts b/src/session/crud/getAllSessions.ts new file mode 100644 index 0000000..3edd8b5 --- /dev/null +++ b/src/session/crud/getAllSessions.ts @@ -0,0 +1,27 @@ +import { AuthHandlerParams } from '../../storyblok-auth-api' +import { AppSession } from '../types' +import { getSignedCookie, GetCookie } from '../../utils' +import { authCookieName } from '../authCookieName' + +export type AppSessionCookiePayload = + | { + sessions: AppSession[] + } + | undefined +export type GetAllSessionsParams = Pick< + AuthHandlerParams, + 'clientSecret' | 'cookieName' | 'clientId' +> +export type GetAllSessions = ( + params: GetAllSessionsParams, + getCookie: GetCookie, +) => AppSession[] +export const getAllSessions: GetAllSessions = (params, getCookie) => { + const signedCookie = getSignedCookie( + params.clientSecret, + getCookie, + authCookieName(params), + ) as AppSessionCookiePayload + // TODO validate at runtime + return signedCookie?.sessions ?? [] +} diff --git a/src/session/crud/getSession.ts b/src/session/crud/getSession.ts new file mode 100644 index 0000000..dada9c7 --- /dev/null +++ b/src/session/crud/getSession.ts @@ -0,0 +1,24 @@ +import { AuthHandlerParams } from '../../storyblok-auth-api' +import { AppSession, AppSessionQuery } from '../types' +import { getAllSessions } from './getAllSessions' +import { keysEquals, keysFromQuery } from './utils' +import { GetCookie } from '../../utils' + +export type GetSessionParams = Pick< + AuthHandlerParams, + 'clientSecret' | 'cookieName' | 'clientId' +> + +export type GetSession = ( + params: GetSessionParams, + getCookie: GetCookie, + query: AppSessionQuery, +) => AppSession | undefined +export const getSession: GetSession = (params, getCookie, query) => { + const keys = { + ...keysFromQuery(query), + appClientId: params.clientId, + } + const areSessionsEqual = keysEquals(keys) + return getAllSessions(params, getCookie).find(areSessionsEqual) +} diff --git a/src/session/crud/index.ts b/src/session/crud/index.ts new file mode 100644 index 0000000..9f1fca9 --- /dev/null +++ b/src/session/crud/index.ts @@ -0,0 +1,4 @@ +export * from './getAllSessions' +export * from './getSession' +export * from './putSession' +export * from './removeSession' diff --git a/src/session/crud/putSession.ts b/src/session/crud/putSession.ts new file mode 100644 index 0000000..01d40d6 --- /dev/null +++ b/src/session/crud/putSession.ts @@ -0,0 +1,31 @@ +import { AppSession } from '../types' +import { AuthHandlerParams } from '../../storyblok-auth-api' +import { setAllSessions } from './setAllSessions' +import { getAllSessions } from './getAllSessions' +import { keysEquals } from './utils' +import { GetCookie, SetCookie } from '../../utils' + +export type PutSessionParams = Pick< + AuthHandlerParams, + 'clientSecret' | 'cookieName' | 'clientId' +> + +export type PutSession = ( + params: PutSessionParams, + getCookie: GetCookie, + setCookie: SetCookie, + session: AppSession, +) => AppSession + +export const putSession: PutSession = ( + params, + getCookie, + setCookie, + newSession, +) => { + const isNotEqual = (otherSession: AppSession) => + !keysEquals(newSession)(otherSession) + const otherSessions = getAllSessions(params, getCookie).filter(isNotEqual) + setAllSessions(params, setCookie, [...otherSessions, newSession]) + return newSession +} diff --git a/src/session/crud/removeSession.ts b/src/session/crud/removeSession.ts new file mode 100644 index 0000000..8df4d2d --- /dev/null +++ b/src/session/crud/removeSession.ts @@ -0,0 +1,35 @@ +import { AppSession, AppSessionQuery } from '../types' +import { AuthHandlerParams } from '../../storyblok-auth-api' +import { setAllSessions } from './setAllSessions' +import { getAllSessions } from './getAllSessions' +import { keysEquals, keysFromQuery } from './utils' +import { SetCookie, GetCookie } from '../../utils' + +export type RemoveSessionParams = Pick< + AuthHandlerParams, + 'clientSecret' | 'cookieName' | 'clientId' +> + +export type RemoveSession = ( + params: RemoveSessionParams, + getCookie: GetCookie, + setCookie: SetCookie, + query: AppSessionQuery, +) => AppSession | undefined +export const removeSession: RemoveSession = ( + params, + getCookie, + setCookie, + query, +) => { + const sessions = getAllSessions(params, getCookie) + const keys = { + ...keysFromQuery(query), + appClientId: params.clientId, + } + const isEqual = keysEquals(keys) + const toRemove = sessions.find(isEqual) + const allOtherSessions = sessions.filter((s) => s !== toRemove) + setAllSessions(params, setCookie, allOtherSessions) + return toRemove +} diff --git a/src/session/crud/setAllSessions.ts b/src/session/crud/setAllSessions.ts new file mode 100644 index 0000000..2a2abd1 --- /dev/null +++ b/src/session/crud/setAllSessions.ts @@ -0,0 +1,19 @@ +import { AppSession } from '../types' +import { setSignedCookie, SetCookie } from '../../utils' +import { authCookieName } from '../authCookieName' +import { AuthHandlerParams } from '../../storyblok-auth-api' + +export type SetAllSessionsParams = Pick< + AuthHandlerParams, + 'clientSecret' | 'cookieName' | 'clientId' +> +export type SetAllSessions = ( + params: SetAllSessionsParams, + setCookie: SetCookie, + sessions: AppSession[], +) => void +export const setAllSessions: SetAllSessions = (params, setCookie, sessions) => { + setSignedCookie(params.clientSecret, setCookie, authCookieName(params), { + sessions, + }) +} diff --git a/src/session/crud/utils/index.ts b/src/session/crud/utils/index.ts new file mode 100644 index 0000000..f116297 --- /dev/null +++ b/src/session/crud/utils/index.ts @@ -0,0 +1,2 @@ +export * from './keysFromQuery' +export * from './keysEquals' diff --git a/src/session/crud/utils/keysEquals.ts b/src/session/crud/utils/keysEquals.ts new file mode 100644 index 0000000..0259b9c --- /dev/null +++ b/src/session/crud/utils/keysEquals.ts @@ -0,0 +1,8 @@ +import { AppSession } from '../../types' + +export const keysEquals = + (a: Pick) => + (b: Pick) => + a.appClientId === b.appClientId && + a.spaceId === b.spaceId && + a.userId === b.userId diff --git a/src/session/crud/utils/keysFromQuery.ts b/src/session/crud/utils/keysFromQuery.ts new file mode 100644 index 0000000..2b6cc14 --- /dev/null +++ b/src/session/crud/utils/keysFromQuery.ts @@ -0,0 +1,9 @@ +import { AppSessionKeys, AppSessionQuery } from '../../types' + +export const keysFromQuery = (keys: AppSessionQuery): AppSessionKeys => { + const { spaceId, userId } = keys + return { + spaceId: typeof spaceId === 'number' ? spaceId : parseInt(spaceId, 10), + userId: typeof userId === 'number' ? userId : parseInt(userId, 10), + } +} diff --git a/src/session/index.ts b/src/session/index.ts index 7141ee1..aed9e26 100644 --- a/src/session/index.ts +++ b/src/session/index.ts @@ -1,3 +1,5 @@ -export * from './sessionCookieStore' -export * from './isAppSessionQuery/isAppSessionQuery' +export * from './isAppSessionQuery' +export * from './crud' export * from './types' +export * from './refreshStoredAppSession' +export * from './sessionCookieStore' diff --git a/src/session/refreshStoredAppSession.ts b/src/session/refreshStoredAppSession.ts new file mode 100644 index 0000000..4458226 --- /dev/null +++ b/src/session/refreshStoredAppSession.ts @@ -0,0 +1,54 @@ +import { AppSession } from './types' +import { shouldRefresh } from './shouldRefresh/shouldRefresh' +import { refreshAppSession } from './refreshAppSession/refreshAppSession' +import { refreshToken } from '../storyblok-auth-api/refreshToken' +import { putSession, removeSession } from './crud' +import { AuthHandlerParams } from '../storyblok-auth-api' +import { GetCookie, SetCookie } from '../utils' + +export type RefreshParams = Pick< + AuthHandlerParams, + 'clientSecret' | 'clientId' | 'baseUrl' | 'endpointPrefix' +> +export type Refresh = ( + params: RefreshParams, + getCookie: GetCookie, + setCookie: SetCookie, + appSession: AppSession | undefined, +) => Promise + +/** + * Given a stored session and getters and setters for mutating the storage, refreshes the session + * @param params + * @param getCookie + * @param setCookie + * @param currentAppSession + */ +export const refreshStoredAppSession: Refresh = async ( + params, + getCookie, + setCookie, + currentAppSession, +) => { + if (!currentAppSession) { + // passed undefined + return undefined + } + if (!shouldRefresh(currentAppSession)) { + // does not need refresh + return currentAppSession + } + + // should refresh + const newAppSession = await refreshAppSession(refreshToken(fetch)(params))( + currentAppSession, + ) + + if (!newAppSession) { + // Refresh failed -> user becomes unauthenticated + removeSession(params, getCookie, setCookie, currentAppSession) + return undefined + } + + return putSession(params, getCookie, setCookie, newAppSession) +} diff --git a/src/session/sessionCookieStore.ts b/src/session/sessionCookieStore.ts index d1417fd..099f6f9 100644 --- a/src/session/sessionCookieStore.ts +++ b/src/session/sessionCookieStore.ts @@ -1,39 +1,30 @@ -import { simpleSessionCookieStore } from './app-session-cookie-store' -import { shouldRefresh } from './shouldRefresh/shouldRefresh' -import { refreshAppSession } from './refreshAppSession/refreshAppSession' -import { refreshToken } from '../storyblok-auth-api/refreshToken' -import { AppSessionCookieStoreFactory } from './types' -import { AppSessionStore } from './types' +import { AppSessionCookieStoreFactory, AppSessionStore } from './types' +import { getAllSessions, getSession, putSession, removeSession } from './crud' +import { refreshStoredAppSession } from './refreshStoredAppSession' +import { + GetCookie, + getCookie as getNodeCookie, + SetCookie, + setCookie as setNodeCookie, +} from '../utils' export const sessionCookieStore: AppSessionCookieStoreFactory = (params) => (requestParams): AppSessionStore => { - const store = simpleSessionCookieStore(params)(requestParams) + const getCookie: GetCookie = (name) => + getNodeCookie(requestParams.req, name) + const setCookie: SetCookie = (name, value) => + setNodeCookie(requestParams.res, name, value) return { - ...store, - get: async (keys, options) => { - const currentSession = await store.get(keys) - if (options?.autoRefresh === false) { - return currentSession - } - if (!currentSession) { - return undefined - } - if (shouldRefresh(currentSession)) { - const newSession = await refreshAppSession( - refreshToken(fetch)(params), - )(currentSession) - - if (!newSession) { - // Refresh failed -> user becomes unauthenticated - await store.remove(currentSession) - return undefined - } - - await store.put(newSession) - return newSession - } - return currentSession - }, + get: async (keys) => + refreshStoredAppSession( + params, + getCookie, + setCookie, + getSession(params, getCookie, keys), + ), + getAll: async () => getAllSessions(params, getCookie), + put: async (session) => putSession(params, getCookie, setCookie, session), + remove: async (keys) => removeSession(params, getCookie, setCookie, keys), } } diff --git a/src/utils/GetCookie.ts b/src/utils/GetCookie.ts new file mode 100644 index 0000000..6550533 --- /dev/null +++ b/src/utils/GetCookie.ts @@ -0,0 +1 @@ +export type GetCookie = (name: string) => string | undefined diff --git a/src/utils/SetCookie.ts b/src/utils/SetCookie.ts new file mode 100644 index 0000000..15ea796 --- /dev/null +++ b/src/utils/SetCookie.ts @@ -0,0 +1 @@ +export type SetCookie = (name: string, value: string) => void diff --git a/src/utils/cookie/getSignedCookie/getSignedCookie.test.ts b/src/utils/cookie/getSignedCookie/getSignedCookie.test.ts index 94b432b..bcb2718 100644 --- a/src/utils/cookie/getSignedCookie/getSignedCookie.test.ts +++ b/src/utils/cookie/getSignedCookie/getSignedCookie.test.ts @@ -1,4 +1,3 @@ -import httpMocks from 'node-mocks-http' import { getSignedCookie } from './getSignedCookie' import { signData } from '../../signData' @@ -16,38 +15,24 @@ const testCookieValue = { const jwtToken = signData(testSecret)(testCookieValue) -const testCookie = `${testCookieName}=${jwtToken}; path=/; samesite=none; secure; httponly` - -const mockRequest = () => - httpMocks.createRequest({ - method: 'POST', - url: '/my-fantastic-endpoint', - headers: { - 'content-type': 'application/json', - accept: 'application/json', - 'content-length': '1', - 'x-forwarded-for': '127.0.0.1', - cookie: testCookie, - }, - }) - describe('getSignedCookie', () => { it('should read the value from the request', () => { - const req = mockRequest() - expect(getSignedCookie(testSecret)(testCookieName)(req)).toEqual( + expect(getSignedCookie(testSecret, () => jwtToken, testCookieName)).toEqual( testCookieValue, ) }) it('should return undefined if the jwtToken is incorrect', () => { - const req = mockRequest() expect( - getSignedCookie('thisIsNotTheRightSecret')(testCookieName)(req), + getSignedCookie( + 'thisIsNotTheRightSecret', + () => jwtToken, + testCookieName, + ), ).toBeUndefined() }) it('should return undefined if the cookie is missing', () => { - const req = mockRequest() expect( - getSignedCookie(testSecret)('nonExistingCookie')(req), + getSignedCookie(testSecret, () => undefined, testCookieName), ).toBeUndefined() }) }) diff --git a/src/utils/cookie/getSignedCookie/getSignedCookie.ts b/src/utils/cookie/getSignedCookie/getSignedCookie.ts index 4e79ff7..e5b1194 100644 --- a/src/utils/cookie/getSignedCookie/getSignedCookie.ts +++ b/src/utils/cookie/getSignedCookie/getSignedCookie.ts @@ -1,14 +1,14 @@ -import http from 'http' -import { getCookie } from '../getCookie' +import { GetCookie } from '../../GetCookie' import { verifyData } from '../../verifyData' -export const getSignedCookie = - (secret: string) => - (name: string) => - (req: http.IncomingMessage): Data | undefined => { - const jwtToken = getCookie(req, name) - if (!jwtToken) { - return undefined - } - return verifyData(secret)(jwtToken) +export const getSignedCookie = ( + secret: string, + getCookie: GetCookie, + name: string, +) => { + const jwtToken = getCookie(name) + if (!jwtToken) { + return undefined } + return verifyData(secret)(jwtToken) +} diff --git a/src/utils/cookie/setSignedCookie/setSignedCookie.test.ts b/src/utils/cookie/setSignedCookie/setSignedCookie.test.ts index 0c906a6..4974293 100644 --- a/src/utils/cookie/setSignedCookie/setSignedCookie.test.ts +++ b/src/utils/cookie/setSignedCookie/setSignedCookie.test.ts @@ -1,8 +1,8 @@ import httpMocks from 'node-mocks-http' import { setSignedCookie } from './setSignedCookie' -import { signData } from '../../signData' import { setCookie } from '../setCookie' import { getSetCookies } from '../../__tests__/get-set-cookies' +import { signData } from '../../signData' const testSecret = 'fkxAHP5whEOjjJh4SFvYvQ9BiqBc8DMqQiX4MMFOcSUx5Qh5xxOI2wqQMRfK53aTOyc5RyEimYQBsA7lWu9kag==' @@ -18,26 +18,27 @@ const testCookieValue = { const jwtToken = signData(testSecret)(testCookieValue) -// const testCookie = `${testCookieName}=${jwtToken}; path=/; samesite=none; secure; httponly` - const mockResponse = () => { const res = httpMocks.createResponse() res.setHeader('Set-Cookie', 'otherCooke=otherCookieValue; httpOnly') setCookie(res, 'firstCookie', 'firstCookieValue') - return res + return { + res, + setCookie: (name: string, value: string) => setCookie(res, name, value), + } } describe('setSignedCookie', () => { it('add a Set-Cookie header', () => { - const res = mockResponse() + const { res, setCookie } = mockResponse() const beforeCount = getSetCookies(res).length - setSignedCookie(testSecret)(testCookieName)(testCookieValue)(res) + setSignedCookie(testSecret, setCookie, testCookieName, testCookieValue) const afterCount = getSetCookies(res).length expect(afterCount).toBe(beforeCount + 1) }) it('add a Set-Cookie header', () => { - const res = mockResponse() - setSignedCookie(testSecret)(testCookieName)(testCookieValue)(res) + const { res, setCookie } = mockResponse() + setSignedCookie(testSecret, setCookie, testCookieName, testCookieValue) const match = getSetCookies(res).some((header) => header.startsWith(`${testCookieName}=${jwtToken}`), ) diff --git a/src/utils/cookie/setSignedCookie/setSignedCookie.ts b/src/utils/cookie/setSignedCookie/setSignedCookie.ts index 22e6284..d858638 100644 --- a/src/utils/cookie/setSignedCookie/setSignedCookie.ts +++ b/src/utils/cookie/setSignedCookie/setSignedCookie.ts @@ -1,10 +1,9 @@ -import http from 'http' +import { SetCookie } from '../../SetCookie' import { signData } from '../../signData' -import { setCookie } from '../setCookie' -export const setSignedCookie = - (secret: string) => - (name: string) => - (data: Data) => - (res: http.ServerResponse): void => - void setCookie(res, name, signData(secret)(data)) +export const setSignedCookie = ( + secret: string, + setCookie: SetCookie, + name: string, + data: unknown, +) => void setCookie(name, signData(secret)(data)) diff --git a/src/utils/index.ts b/src/utils/index.ts index 8e52047..e82eeb6 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,2 +1,4 @@ export * from './cookie' export * from './hasKey' +export * from './GetCookie' +export * from './SetCookie'