diff --git a/README.md b/README.md index 5a4eb7919..21e2172bb 100644 --- a/README.md +++ b/README.md @@ -59,10 +59,12 @@ The provided Docker command example includes several environment variables. Here 4. **TEMPORARY_JWT_SECRET**: Similar to `JWT_SECRET`, this is also used for JWT token operations. It may be used as a secondary key for temporary tokens or during token rotation processes. 5. **APP_DOMAIN_ADDRESS** (optional): This sets the domain address of your application. The format is a URL, such as `https://rocketadmin.yourcompany.internal`. This address is used for internal links or email messages. -6. **EMAIL_CONFIG_STRING**: Settings for connecting to an SMTP server for email sending in an application. It follows the format `smtps://username:password@smtp.example.com/?pool=true`, where smtps:// indicates a secure SMTP connection using SSL/TLS encryption. username and password are the credentials for the SMTP server, smtp.example.com is the server address, and the pool=true query parameter enables connection pooling for efficient management of multiple email transmissions. More information about the parameters you can find in the nodemailer npm module documentation. Use `smtp://` with port 587 (STARTTLS), e.g. `smtp://user:pass@email-smtp.us-east-1.amazonaws.com:587`. If no SMTP server is configured, no emails are sent: invitation and confirmation links are printed to the backend logs instead (search for `Invitation link:`), and so are send failures. -7. **EMAIL_FROM** (optional, but required by most SMTP providers): The sender address. It must be an address your SMTP server is allowed to send from (for Amazon SES — a verified identity); the default Rocketadmin support address will be rejected. -8. **OPENAI_API_KEY** (optional): This API key is used to authenticate requests to the OpenAI service. It allows the application to access OpenAI's language models and other AI functionalities. You can obtain this key by signing up for an API key on the OpenAI website. If this key is not provided, any features relying on OpenAI services will be disabled. +6. **ROCKETADMIN_COOKIE_DOMAIN** (optional): The `Domain` attribute of the session cookie, as a comma-separated list of registrable domains, for example `rocketadmin.com,sitenova.com`. The first entry that equals the request hostname, or that is a parent domain of it, becomes the cookie domain. Leave it unset for a single host. In a SaaS deployment the SaaS service must carry the same value. +7. **EMAIL_CONFIG_STRING**: Settings for connecting to an SMTP server for email sending in an application. It follows the format `smtps://username:password@smtp.example.com/?pool=true`, where smtps:// indicates a secure SMTP connection using SSL/TLS encryption. username and password are the credentials for the SMTP server, smtp.example.com is the server address, and the pool=true query parameter enables connection pooling for efficient management of multiple email transmissions. More information about the parameters you can find in the nodemailer npm module documentation. Use `smtp://` with port 587 (STARTTLS), e.g. `smtp://user:pass@email-smtp.us-east-1.amazonaws.com:587`. If no SMTP server is configured, no emails are sent: invitation and confirmation links are printed to the backend logs instead (search for `Invitation link:`), and so are send failures. +8. **EMAIL_FROM** (optional, but required by most SMTP providers): The sender address. It must be an address your SMTP server is allowed to send from (for Amazon SES — a verified identity); the default Rocketadmin support address will be rejected. + +9. **OPENAI_API_KEY** (optional): This API key is used to authenticate requests to the OpenAI service. It allows the application to access OpenAI's language models and other AI functionalities. You can obtain this key by signing up for an API key on the OpenAI website. If this key is not provided, any features relying on OpenAI services will be disabled. Each of these environment variables plays a crucial role in the configuration and security of the RocketAdmin application. It's important to ensure that sensitive information like `JWT_SECRET`, `PRIVATE_KEY`, and `TEMPORARY_JWT_SECRET` are kept secure and are not exposed in publicly accessible areas of your code or repositories. Additionally, the `DATABASE_URL` should be set correctly to ensure that the application can successfully connect to your database. diff --git a/backend/src/authorization/auth-with-api.middleware.ts b/backend/src/authorization/auth-with-api.middleware.ts index 3dfb1ce1b..e9b5c6a13 100644 --- a/backend/src/authorization/auth-with-api.middleware.ts +++ b/backend/src/authorization/auth-with-api.middleware.ts @@ -16,11 +16,11 @@ import { UserEntity } from '../entities/user/user.entity.js'; import { assertTokenScopeAllowed } from '../entities/user/utils/assert-token-scope-allowed.js'; import { EncryptionAlgorithmEnum } from '../enums/encryption-algorithm.enum.js'; import { Messages } from '../exceptions/text/messages.js'; -import { Constants } from '../helpers/constants/constants.js'; import { Encryptor } from '../helpers/encryption/encryptor.js'; import { isObjectEmpty } from '../helpers/is-object-empty.js'; import { appConfig } from '../shared/config/app-config.js'; import { IRequestWithCognitoInfo } from './cognito-decoded.interface.js'; +import { getSessionTokenFromRequest } from './utils/session-token.js'; @Injectable() export class AuthWithApiMiddleware implements NestMiddleware { @@ -52,7 +52,7 @@ export class AuthWithApiMiddleware implements NestMiddleware { } private getTokenFromCookie(req: IRequestWithCognitoInfo): string | undefined { - return req.cookies?.[Constants.JWT_COOKIE_KEY_NAME]; + return getSessionTokenFromRequest(req); } private handleAuthenticationError(error: any): void { diff --git a/backend/src/authorization/auth.middleware.ts b/backend/src/authorization/auth.middleware.ts index 5255dc25f..176ac4ca6 100644 --- a/backend/src/authorization/auth.middleware.ts +++ b/backend/src/authorization/auth.middleware.ts @@ -16,10 +16,10 @@ import { UserEntity } from '../entities/user/user.entity.js'; import { assertTokenScopeAllowed } from '../entities/user/utils/assert-token-scope-allowed.js'; import { Messages } from '../exceptions/text/messages.js'; import { isTest } from '../helpers/app/is-test.js'; -import { Constants } from '../helpers/constants/constants.js'; import { isObjectEmpty } from '../helpers/is-object-empty.js'; import { appConfig } from '../shared/config/app-config.js'; import { IRequestWithCognitoInfo } from './cognito-decoded.interface.js'; +import { getSessionTokenFromRequest } from './utils/session-token.js'; @Injectable() export class AuthMiddleware implements NestMiddleware { @@ -32,7 +32,7 @@ export class AuthMiddleware implements NestMiddleware { async use(req: IRequestWithCognitoInfo, _res: Response, next: NextFunction): Promise { let token: string | undefined; try { - token = req.cookies[Constants.JWT_COOKIE_KEY_NAME]; + token = getSessionTokenFromRequest(req); } catch (_e) { if (!isTest()) { throw new UnauthorizedException('JWT verification failed'); diff --git a/backend/src/authorization/non-scoped-auth.middleware.ts b/backend/src/authorization/non-scoped-auth.middleware.ts index 67887ae30..12437e497 100644 --- a/backend/src/authorization/non-scoped-auth.middleware.ts +++ b/backend/src/authorization/non-scoped-auth.middleware.ts @@ -13,10 +13,10 @@ import { Repository } from 'typeorm'; import { LogOutEntity } from '../entities/log-out/log-out.entity.js'; import { Messages } from '../exceptions/text/messages.js'; import { isTest } from '../helpers/app/is-test.js'; -import { Constants } from '../helpers/constants/constants.js'; import { isObjectEmpty } from '../helpers/is-object-empty.js'; import { appConfig } from '../shared/config/app-config.js'; import { IRequestWithCognitoInfo } from './cognito-decoded.interface.js'; +import { getSessionTokenFromRequest } from './utils/session-token.js'; @Injectable() export class NonScopedAuthMiddleware implements NestMiddleware { @@ -28,7 +28,7 @@ export class NonScopedAuthMiddleware implements NestMiddleware { console.log(`auth middleware triggered ->: ${new Date().toISOString()}`); let token: string | undefined; try { - token = req.cookies[Constants.JWT_COOKIE_KEY_NAME]; + token = getSessionTokenFromRequest(req); } catch (_e) { if (!isTest()) { throw new UnauthorizedException('JWT verification failed'); diff --git a/backend/src/authorization/public-or-auth.middleware.ts b/backend/src/authorization/public-or-auth.middleware.ts index 3d2071794..888c90faf 100644 --- a/backend/src/authorization/public-or-auth.middleware.ts +++ b/backend/src/authorization/public-or-auth.middleware.ts @@ -16,11 +16,11 @@ import { UserEntity } from '../entities/user/user.entity.js'; import { assertTokenScopeAllowed } from '../entities/user/utils/assert-token-scope-allowed.js'; import { EncryptionAlgorithmEnum } from '../enums/encryption-algorithm.enum.js'; import { Messages } from '../exceptions/text/messages.js'; -import { Constants } from '../helpers/constants/constants.js'; import { Encryptor } from '../helpers/encryption/encryptor.js'; import { isObjectEmpty } from '../helpers/is-object-empty.js'; import { appConfig } from '../shared/config/app-config.js'; import { IRequestWithCognitoInfo } from './cognito-decoded.interface.js'; +import { getSessionTokenFromRequest } from './utils/session-token.js'; /** * Authentication middleware that ALSO allows anonymous ("public") requests through. @@ -42,7 +42,7 @@ export class PublicOrAuthMiddleware implements NestMiddleware { async use(req: IRequestWithCognitoInfo, _res: Response, next: NextFunction): Promise { try { - const tokenFromCookie = req.cookies?.[Constants.JWT_COOKIE_KEY_NAME]; + const tokenFromCookie = getSessionTokenFromRequest(req); let apiKey = req.headers?.['x-api-key']; if (Array.isArray(apiKey)) { apiKey = apiKey[0]; diff --git a/backend/src/authorization/temporary-auth.middleware.ts b/backend/src/authorization/temporary-auth.middleware.ts index d0853c6d3..d234fae7f 100644 --- a/backend/src/authorization/temporary-auth.middleware.ts +++ b/backend/src/authorization/temporary-auth.middleware.ts @@ -14,10 +14,10 @@ import { LogOutEntity } from '../entities/log-out/log-out.entity.js'; import { UserEntity } from '../entities/user/user.entity.js'; import { Messages } from '../exceptions/text/messages.js'; import { isTest } from '../helpers/app/is-test.js'; -import { Constants } from '../helpers/constants/constants.js'; import { isObjectEmpty } from '../helpers/is-object-empty.js'; import { appConfig } from '../shared/config/app-config.js'; import { IRequestWithCognitoInfo } from './cognito-decoded.interface.js'; +import { getSessionTokenFromRequest } from './utils/session-token.js'; @Injectable() export class TemporaryAuthMiddleware implements NestMiddleware { @@ -30,7 +30,7 @@ export class TemporaryAuthMiddleware implements NestMiddleware { console.log(`temporary auth middleware triggered ->: ${new Date().toISOString()}`); let token: string | undefined; try { - token = req.cookies[Constants.JWT_COOKIE_KEY_NAME]; + token = getSessionTokenFromRequest(req); } catch (_e) { if (!isTest()) { throw new UnauthorizedException('JWT verification failed'); diff --git a/backend/src/authorization/utils/session-token.ts b/backend/src/authorization/utils/session-token.ts new file mode 100644 index 000000000..d52f65925 --- /dev/null +++ b/backend/src/authorization/utils/session-token.ts @@ -0,0 +1,111 @@ +import { Constants } from '../../helpers/constants/constants.js'; + +interface RequestWithCookies { + headers?: { cookie?: string | Array }; + cookies?: Record; +} + +/** + * Every value of one cookie name, in the order the browser sent them. + * + * Express `cookie-parser` keeps only the first value of a duplicated name. A browser can hold a + * host-only copy and a domain copy of the session cookie (plan 84), so the middlewares read the + * raw header here. + */ +export function extractSessionTokens( + cookieHeader: string | Array | undefined, + name: string = Constants.JWT_COOKIE_KEY_NAME, +): Array { + const raw = Array.isArray(cookieHeader) ? cookieHeader.join('; ') : cookieHeader; + if (!raw) { + return []; + } + const tokens: Array = []; + for (const pair of raw.split(';')) { + const separator = pair.indexOf('='); + if (separator === -1) { + continue; + } + if (pair.slice(0, separator).trim() !== name) { + continue; + } + let value = pair.slice(separator + 1).trim(); + if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) { + value = value.slice(1, -1); + } + if (!value) { + continue; + } + tokens.push(safeDecode(value)); + } + return tokens; +} + +/** The `iat` claim of a JWT, read without a signature check. Null for anything unreadable. */ +export function readIssuedAt(token: string): number | null { + const parts = token.split('.'); + if (parts.length < 2) { + return null; + } + try { + const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); + return typeof payload?.iat === 'number' ? payload.iat : null; + } catch (_e) { + return null; + } +} + +/** + * The token to authenticate when the browser sent more than one session cookie (plan 84). + * + * The newest `iat` wins: the latest login states the user's intent. A tie keeps the browser + * order. A token without a readable `iat` ranks last. + */ +export function selectSessionToken(tokens: Array): string | undefined { + if (tokens.length <= 1) { + return tokens[0]; + } + let best = tokens[0]; + let bestIssuedAt = readIssuedAt(best) ?? -1; + for (const token of tokens.slice(1)) { + const issuedAt = readIssuedAt(token) ?? -1; + if (issuedAt > bestIssuedAt) { + best = token; + bestIssuedAt = issuedAt; + } + } + return best; +} + +/** + * All session tokens of a request, without duplicates. The raw `Cookie` header is the source. + * A request without the header falls back to the parsed `cookies` object, which the unit tests + * build directly. + */ +export function getSessionTokensFromRequest( + req: RequestWithCookies, + name: string = Constants.JWT_COOKIE_KEY_NAME, +): Array { + const fromHeader = extractSessionTokens(req.headers?.cookie, name); + if (fromHeader.length > 0) { + return Array.from(new Set(fromHeader)); + } + const parsed = req.cookies?.[name]; + return parsed ? [parsed] : []; +} + +/** The one session token a request authenticates with, or undefined. */ +export function getSessionTokenFromRequest( + req: RequestWithCookies, + name: string = Constants.JWT_COOKIE_KEY_NAME, +): string | undefined { + return selectSessionToken(getSessionTokensFromRequest(req, name)); +} + +function safeDecode(value: string): string { + try { + return decodeURIComponent(value); + } catch (_e) { + return value; + } +} diff --git a/backend/src/entities/company-info/company-info.controller.ts b/backend/src/entities/company-info/company-info.controller.ts index 06d8580fb..d4b52875b 100644 --- a/backend/src/entities/company-info/company-info.controller.ts +++ b/backend/src/entities/company-info/company-info.controller.ts @@ -37,6 +37,7 @@ import { TurnstileService } from '../../shared/services/turnstile.service.js'; import { SimpleFoundUserInCompanyInfoDs } from '../user/dto/found-user.dto.js'; import { ITokenExp } from '../user/utils/generate-gwt-token.js'; import { getCookieDomainOptions } from '../user/utils/get-cookie-domain-options.js'; +import { clearSessionCookies } from '../user/utils/session-cookies.js'; import { FoundUserCompanyInfoDs, FoundUserEmailCompaniesInfoDs, @@ -398,15 +399,7 @@ export class CompanyInfoController { ): Promise { const deleteResult = await this.deleteCompanyUseCase.execute(userId, InTransactionEnum.ON); - response.cookie(Constants.JWT_COOKIE_KEY_NAME, '', { - ...getCookieDomainOptions(request.hostname), - expires: new Date(0), - }); - response.cookie(Constants.ROCKETADMIN_AUTHENTICATED_COOKIE, 1, { - expires: new Date(0), - httpOnly: false, - ...getCookieDomainOptions(request.hostname), - }); + clearSessionCookies(response, request.hostname); return deleteResult; } diff --git a/backend/src/entities/user/user.controller.ts b/backend/src/entities/user/user.controller.ts index da092f69a..680fcbdcf 100644 --- a/backend/src/entities/user/user.controller.ts +++ b/backend/src/entities/user/user.controller.ts @@ -18,6 +18,7 @@ import { ApiBearerAuth, ApiBody, ApiOperation, ApiQuery, ApiResponse, ApiTags } import { Throttle } from '@nestjs/throttler'; import { Request, Response } from 'express'; import validator from 'validator'; +import { getSessionTokensFromRequest } from '../../authorization/utils/session-token.js'; import { UseCaseType } from '../../common/data-injection.tokens.js'; import { BodyEmail } from '../../decorators/body-email.decorator.js'; import { GCLlId } from '../../decorators/gclid-decorator.js'; @@ -77,6 +78,7 @@ import { } from './use-cases/user-use-cases.interfaces.js'; import { ITokenExp, TokenExpDs } from './utils/generate-gwt-token.js'; import { getCookieDomainOptions } from './utils/get-cookie-domain-options.js'; +import { clearSessionCookies } from './utils/session-cookies.js'; @UseInterceptors(SentryInterceptor) @Timeout() @@ -193,8 +195,8 @@ export class UserController { }) @Post('user/logout/') async logOut(@Req() request: Request, @Res({ passthrough: true }) response: Response): Promise { - const token = request.cookies[Constants.JWT_COOKIE_KEY_NAME]; - if (!token) { + const tokens = getSessionTokensFromRequest(request); + if (tokens.length === 0) { throw new HttpException( { message: Messages.TOKEN_MISSING, @@ -202,16 +204,13 @@ export class UserController { HttpStatus.BAD_REQUEST, ); } - response.cookie(Constants.JWT_COOKIE_KEY_NAME, '', { - expires: new Date(0), - ...getCookieDomainOptions(request.hostname), - }); - response.cookie(Constants.ROCKETADMIN_AUTHENTICATED_COOKIE, 1, { - expires: new Date(0), - httpOnly: false, - ...getCookieDomainOptions(request.hostname), - }); - return await this.logOutUseCase.execute(token, InTransactionEnum.ON); + clearSessionCookies(response, request.hostname); + // Revoke every token the browser sent, not only the one the middleware selected (plan 84). + let revoked = true; + for (const token of tokens) { + revoked = (await this.logOutUseCase.execute(token, InTransactionEnum.ON)) && revoked; + } + return revoked; } @ApiOperation({ summary: 'Change user password' }) @@ -392,15 +391,7 @@ export class UserController { const { reason, message } = deletingAccountReasonData; const slackMessage = Messages.USER_DELETED_ACCOUNT(deleteResult.email, reason, message); - response.cookie(Constants.JWT_COOKIE_KEY_NAME, '', { - ...getCookieDomainOptions(request.hostname), - expires: new Date(0), - }); - response.cookie(Constants.ROCKETADMIN_AUTHENTICATED_COOKIE, 1, { - expires: new Date(0), - httpOnly: false, - ...getCookieDomainOptions(request.hostname), - }); + clearSessionCookies(response, request.hostname); await slackPostMessage(slackMessage); return deleteResult; diff --git a/backend/src/entities/user/utils/get-cookie-domain-options.ts b/backend/src/entities/user/utils/get-cookie-domain-options.ts index 78904368d..ff9fc1a9d 100644 --- a/backend/src/entities/user/utils/get-cookie-domain-options.ts +++ b/backend/src/entities/user/utils/get-cookie-domain-options.ts @@ -1,9 +1,45 @@ import { appConfig } from '../../../shared/config/app-config.js'; -export function getCookieDomainOptions(requestHostname: string): { domain: string } | undefined { - const cookieDomain = appConfig.app.cookieDomain; - if (cookieDomain && requestHostname?.includes(cookieDomain)) { - return { domain: cookieDomain }; +/** + * The registrable domains from `ROCKETADMIN_COOKIE_DOMAIN`, a comma-separated list such as + * `rocketadmin.com,sitenova.com`. A single value is a list of one. The parser drops a leading dot. + */ +export function parseCookieDomains(raw: string | null | undefined): Array { + if (!raw) { + return []; } - return undefined; + return raw + .split(',') + .map((domain) => domain.trim().toLowerCase().replace(/^\./, '')) + .filter((domain) => domain.length > 0); +} + +/** + * The first configured domain that equals the request hostname or that is a dotted suffix of it. + * `app.sitenova.com` matches `sitenova.com`. `sitenova.com.evil.net` matches nothing. + */ +export function matchCookieDomain( + requestHostname: string | null | undefined, + domains: Array, +): string | undefined { + const hostname = requestHostname?.trim().toLowerCase(); + if (!hostname) { + return undefined; + } + return domains.find((domain) => hostname === domain || hostname.endsWith(`.${domain}`)); +} + +/** + * The `Domain` attribute of the session cookie for a request hostname. + * + * The saas keeps an identical copy of this rule. Both services set the same cookie on the same + * hosts, so they must agree. Otherwise a browser on `rocket.sitenova.com` holds two cookies with + * the same name and a logout clears only one of them (plan 84). + */ +export function getCookieDomainOptions( + requestHostname: string, + domains: Array = parseCookieDomains(appConfig.app.cookieDomain), +): { domain: string } | undefined { + const domain = matchCookieDomain(requestHostname, domains); + return domain ? { domain } : undefined; } diff --git a/backend/src/entities/user/utils/session-cookies.ts b/backend/src/entities/user/utils/session-cookies.ts new file mode 100644 index 000000000..b98d71e81 --- /dev/null +++ b/backend/src/entities/user/utils/session-cookies.ts @@ -0,0 +1,44 @@ +import { Response } from 'express'; +import { Constants } from '../../../helpers/constants/constants.js'; +import { appConfig } from '../../../shared/config/app-config.js'; +import { matchCookieDomain, parseCookieDomains } from './get-cookie-domain-options.js'; + +const EXPIRED = new Date(0); + +/** + * Clear the session cookie pair in every scope the platform can have used (plan 84). + * + * A cookie identity is the name, the domain, the path and the host-only flag. The core and the + * saas set the pair on the same hosts, so a browser can hold a host-only copy and a domain copy. + * One expired `Set-Cookie` per scope removes both. The attributes mirror the login, so the browser + * matches the cookie it holds. On a configured domain the response also carries + * `Clear-Site-Data: "cookies"`, which removes stale copies on sibling hosts of that domain. + * + * WARNING: `Clear-Site-Data` removes every cookie of the registrable domain. The helper sends it + * only for a matched configured domain. A self-hosted instance without `ROCKETADMIN_COOKIE_DOMAIN` + * and a white-label host never send it. + */ +export function clearSessionCookies( + response: Response, + requestHostname: string, + domains: Array = parseCookieDomains(appConfig.app.cookieDomain), +): void { + const domain = matchCookieDomain(requestHostname, domains); + const scopes: Array<{ domain?: string }> = domain ? [{}, { domain }] : [{}]; + for (const scope of scopes) { + response.cookie(Constants.JWT_COOKIE_KEY_NAME, '', { + httpOnly: true, + secure: true, + expires: EXPIRED, + ...scope, + }); + response.cookie(Constants.ROCKETADMIN_AUTHENTICATED_COOKIE, '', { + httpOnly: false, + expires: EXPIRED, + ...scope, + }); + } + if (domain) { + response.setHeader('Clear-Site-Data', '"cookies"'); + } +} diff --git a/backend/test/ava-tests/non-saas-tests/non-saas-saas-microservice-logout-e2e.test.ts b/backend/test/ava-tests/non-saas-tests/non-saas-saas-microservice-logout-e2e.test.ts index 458662461..07a52486c 100644 --- a/backend/test/ava-tests/non-saas-tests/non-saas-saas-microservice-logout-e2e.test.ts +++ b/backend/test/ava-tests/non-saas-tests/non-saas-saas-microservice-logout-e2e.test.ts @@ -165,3 +165,56 @@ test.serial(`${currentTest} rejects unauthenticated calls (401)`, async (t) => { .set('Accept', 'application/json'); t.is(response.status, 401); }); + +currentTest = 'POST /user/logout/ (browser session cookies, plan 84)'; + +// Two distinct logins at least one second apart, so the two tokens carry different `iat` claims. +async function loginTwiceAndReturnTokens(): Promise<{ older: string; newer: string }> { + const older = await loginAndReturnCookieToken(); + await new Promise((resolve) => setTimeout(resolve, 1100)); + const newer = await loginAndReturnCookieToken(); + return { older, newer }; +} + +function userRequestWithCookies(cookies: Array): request.Test { + return request(app.getHttpServer()) + .get('/user') + .set('Cookie', cookies.map((token) => `rocketadmin_cookie=${token}`).join('; ')) + .set('Accept', 'application/json'); +} + +test.serial(`${currentTest} authenticates with the newest session cookie when the browser sends two`, async (t) => { + const { older, newer } = await loginTwiceAndReturnTokens(); + const newerId = (jwt.decode(newer) as jwt.JwtPayload).id; + + const olderFirst = await userRequestWithCookies([older, newer]); + t.is(olderFirst.status, 200); + t.is(JSON.parse(olderFirst.text).id, newerId); + + const newerFirst = await userRequestWithCookies([newer, older]); + t.is(newerFirst.status, 200); + t.is(JSON.parse(newerFirst.text).id, newerId); +}); + +test.serial(`${currentTest} revokes every token the browser sent and expires the cookies`, async (t) => { + const { older, newer } = await loginTwiceAndReturnTokens(); + + const logoutResult = await request(app.getHttpServer()) + .post('/user/logout/') + .set('Cookie', `rocketadmin_cookie=${older}; rocketadmin_cookie=${newer}`) + .set('Accept', 'application/json'); + t.is(logoutResult.status, 201); + + const setCookie: Array = logoutResult.get('Set-Cookie') ?? []; + const jwtClears = setCookie.filter((cookie) => cookie.startsWith('rocketadmin_cookie=;')); + const flagClears = setCookie.filter((cookie) => cookie.startsWith('rocketadmin_authenticated=;')); + t.true(jwtClears.length >= 1); + t.true(flagClears.length >= 1); + t.true(setCookie.every((cookie) => /Expires=Thu, 01 Jan 1970/.test(cookie))); + // The test environment sets no cookie domain, so the clear stays host-only and the response + // carries no Clear-Site-Data header. + t.is(logoutResult.headers['clear-site-data'], undefined); + + t.is((await userRequestWithCookies([older])).status, 401); + t.is((await userRequestWithCookies([newer])).status, 401); +}); diff --git a/backend/test/ava-tests/unit-tests/session-cookies.test.ts b/backend/test/ava-tests/unit-tests/session-cookies.test.ts new file mode 100644 index 000000000..18070f40c --- /dev/null +++ b/backend/test/ava-tests/unit-tests/session-cookies.test.ts @@ -0,0 +1,178 @@ +import test from 'ava'; +import { + extractSessionTokens, + getSessionTokenFromRequest, + getSessionTokensFromRequest, + readIssuedAt, + selectSessionToken, +} from '../../../src/authorization/utils/session-token.js'; +import { + getCookieDomainOptions, + matchCookieDomain, + parseCookieDomains, +} from '../../../src/entities/user/utils/get-cookie-domain-options.js'; +import { clearSessionCookies } from '../../../src/entities/user/utils/session-cookies.js'; + +// Plan 84: one session cookie identity per browser, and a logout that removes every copy. + +function fakeJwt(payload: Record): string { + const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url'); + const body = Buffer.from(JSON.stringify(payload)).toString('base64url'); + return `${header}.${body}.signature`; +} + +const DOMAINS = ['rocketadmin.com', 'sitenova.com']; + +test('parseCookieDomains reads a single value and a comma-separated list', (t) => { + t.deepEqual(parseCookieDomains(undefined), []); + t.deepEqual(parseCookieDomains(''), []); + t.deepEqual(parseCookieDomains('rocketadmin.com'), ['rocketadmin.com']); + t.deepEqual(parseCookieDomains(' .Rocketadmin.com , sitenova.com,'), DOMAINS); +}); + +test('matchCookieDomain matches the hostname itself and dotted subdomains only', (t) => { + t.is(matchCookieDomain('app.sitenova.com', DOMAINS), 'sitenova.com'); + t.is(matchCookieDomain('rocket.sitenova.com', DOMAINS), 'sitenova.com'); + t.is(matchCookieDomain('sitenova.com', DOMAINS), 'sitenova.com'); + t.is(matchCookieDomain('APP.ROCKETADMIN.COM', DOMAINS), 'rocketadmin.com'); + t.is(matchCookieDomain('sitenova.com.evil.net', DOMAINS), undefined); + t.is(matchCookieDomain('notsitenova.com', DOMAINS), undefined); + t.is(matchCookieDomain('localhost', DOMAINS), undefined); + t.is(matchCookieDomain(undefined, DOMAINS), undefined); + t.is(matchCookieDomain('app.sitenova.com', []), undefined); +}); + +test('getCookieDomainOptions returns the Domain option or undefined', (t) => { + t.deepEqual(getCookieDomainOptions('rocket.sitenova.com', DOMAINS), { domain: 'sitenova.com' }); + t.is(getCookieDomainOptions('rocket.sitenova.com', []), undefined); + t.is(getCookieDomainOptions('rocket.sitenova.com', ['rocketadmin.com']), undefined); +}); + +test('extractSessionTokens returns every value of the session cookie in header order', (t) => { + t.deepEqual(extractSessionTokens(undefined), []); + t.deepEqual(extractSessionTokens(''), []); + t.deepEqual(extractSessionTokens('a=1; rocketadmin_cookie=A; b=2; rocketadmin_cookie=B'), ['A', 'B']); + t.deepEqual(extractSessionTokens('rocketadmin_cookie=; rocketadmin_cookie=B'), ['B']); + t.deepEqual(extractSessionTokens('rocketadmin_cookie="quoted"'), ['quoted']); + t.deepEqual(extractSessionTokens('rocketadmin_cookie=a%2Eb'), ['a.b']); + t.deepEqual(extractSessionTokens('rocketadmin_cookie_other=X; rocketadmin_cookie=A'), ['A']); + t.deepEqual(extractSessionTokens(['rocketadmin_cookie=A', 'rocketadmin_cookie=B']), ['A', 'B']); +}); + +test('readIssuedAt reads the iat claim without a signature check', (t) => { + t.is(readIssuedAt(fakeJwt({ iat: 1700000000 })), 1700000000); + t.is(readIssuedAt(fakeJwt({ sub: 'x' })), null); + t.is(readIssuedAt('not-a-jwt'), null); + t.is(readIssuedAt('a.!!!.c'), null); +}); + +test('selectSessionToken prefers the newest iat whatever the browser order', (t) => { + const older = fakeJwt({ iat: 1700000000 }); + const newer = fakeJwt({ iat: 1700000500 }); + t.is(selectSessionToken([]), undefined); + t.is(selectSessionToken([older]), older); + t.is(selectSessionToken([older, newer]), newer); + t.is(selectSessionToken([newer, older]), newer); +}); + +test('selectSessionToken keeps the browser order on a tie and ranks unreadable tokens last', (t) => { + const first = fakeJwt({ iat: 1700000000, n: 1 }); + const second = fakeJwt({ iat: 1700000000, n: 2 }); + t.is(selectSessionToken([first, second]), first); + t.is(selectSessionToken(['garbage', second]), second); + t.is(selectSessionToken([second, 'garbage']), second); + t.is(selectSessionToken(['garbage-1', 'garbage-2']), 'garbage-1'); +}); + +test('getSessionTokensFromRequest reads the raw header, drops duplicates and falls back to req.cookies', (t) => { + const older = fakeJwt({ iat: 1 }); + const newer = fakeJwt({ iat: 2 }); + t.deepEqual( + getSessionTokensFromRequest({ + headers: { cookie: `rocketadmin_cookie=${older}; rocketadmin_cookie=${newer}; rocketadmin_cookie=${older}` }, + cookies: { rocketadmin_cookie: older }, + }), + [older, newer], + ); + t.deepEqual(getSessionTokensFromRequest({ cookies: { rocketadmin_cookie: older } }), [older]); + t.deepEqual(getSessionTokensFromRequest({ headers: {}, cookies: {} }), []); + t.deepEqual(getSessionTokensFromRequest({}), []); +}); + +test('getSessionTokenFromRequest authenticates with the newest token', (t) => { + const older = fakeJwt({ iat: 1 }); + const newer = fakeJwt({ iat: 2 }); + t.is( + getSessionTokenFromRequest({ + headers: { cookie: `rocketadmin_cookie=${older}; rocketadmin_cookie=${newer}` }, + }), + newer, + ); + t.is(getSessionTokenFromRequest({ cookies: { rocketadmin_cookie: older } }), older); + t.is(getSessionTokenFromRequest({}), undefined); +}); + +interface CookieCall { + name: string; + value: unknown; + options: Record; +} + +function fakeResponse(): { cookies: CookieCall[]; headers: Record; response: unknown } { + const cookies: CookieCall[] = []; + const headers: Record = {}; + const response = { + cookie(name: string, value: unknown, options: Record) { + cookies.push({ name, value, options }); + return response; + }, + setHeader(name: string, value: string) { + headers[name] = value; + }, + }; + return { cookies, headers, response }; +} + +test('clearSessionCookies expires both cookies in both scopes and sends Clear-Site-Data on a matched host', (t) => { + const { cookies, headers, response } = fakeResponse(); + clearSessionCookies(response as never, 'rocket.sitenova.com', DOMAINS); + + t.is(cookies.length, 4); + t.deepEqual( + cookies.map((c) => [c.name, c.options.domain]), + [ + ['rocketadmin_cookie', undefined], + ['rocketadmin_authenticated', undefined], + ['rocketadmin_cookie', 'sitenova.com'], + ['rocketadmin_authenticated', 'sitenova.com'], + ], + ); + for (const c of cookies) { + t.is(c.value, ''); + t.is((c.options.expires as Date).getTime(), 0); + } + const jwtClears = cookies.filter((c) => c.name === 'rocketadmin_cookie'); + t.true(jwtClears.every((c) => c.options.httpOnly === true && c.options.secure === true)); + t.is(headers['Clear-Site-Data'], '"cookies"'); +}); + +test('clearSessionCookies stays host-only and sends no Clear-Site-Data on an unmatched host', (t) => { + const { cookies, headers, response } = fakeResponse(); + clearSessionCookies(response as never, 'admin.customer.internal', DOMAINS); + + t.deepEqual( + cookies.map((c) => [c.name, c.options.domain]), + [ + ['rocketadmin_cookie', undefined], + ['rocketadmin_authenticated', undefined], + ], + ); + t.is(headers['Clear-Site-Data'], undefined); +}); + +test('clearSessionCookies without a configured domain (self-hosted) never sends Clear-Site-Data', (t) => { + const { cookies, headers, response } = fakeResponse(); + clearSessionCookies(response as never, 'rocket.sitenova.com', []); + t.is(cookies.length, 2); + t.is(headers['Clear-Site-Data'], undefined); +}); diff --git a/frontend/src/app/app.component.spec.ts b/frontend/src/app/app.component.spec.ts index df1785487..0bdb8fdd5 100644 --- a/frontend/src/app/app.component.spec.ts +++ b/frontend/src/app/app.component.spec.ts @@ -9,7 +9,7 @@ import { By, DomSanitizer } from '@angular/platform-browser'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { RouterTestingModule } from '@angular/router/testing'; import { Angulartics2Module } from 'angulartics2'; -import { of, Subject } from 'rxjs'; +import { EMPTY, of, Subject } from 'rxjs'; import { type Mock, vi } from 'vitest'; import { AppComponent } from './app.component'; import { AuthService } from './services/auth.service'; @@ -325,4 +325,19 @@ describe('AppComponent', () => { expect(app.logOut).toHaveBeenCalledWith(true); expect(app.router.navigate).toHaveBeenCalledWith(['/login']); }); + + it('clears the local session when the logout request fails (plan 84)', () => { + localStorage.setItem('token_expiration', new Date(Date.now() + 60_000).toISOString()); + mockAuthService.setAuthenticated(true); + app.userLoggedIn = true; + // logOutUser() maps a failed request to EMPTY: it completes without a value. + mockAuthService.logOutUser.mockReturnValueOnce(EMPTY); + + app.logOut(true); + + expect(localStorage.getItem('token_expiration')).toBeNull(); + expect(mockAuthService.isAuthenticated()).toBe(false); + expect(app.userLoggedIn).toBeNull(); + expect(app.router.navigate).toHaveBeenCalledWith(['/login']); + }); }); diff --git a/frontend/src/app/app.component.ts b/frontend/src/app/app.component.ts index 152bc2cf7..798beaba5 100644 --- a/frontend/src/app/app.component.ts +++ b/frontend/src/app/app.component.ts @@ -17,10 +17,11 @@ import { Angulartics2Amplitude, Angulartics2OnModule } from 'angulartics2'; import { differenceInMilliseconds } from 'date-fns'; import posthog from 'posthog-js'; import { Subject } from 'rxjs'; -import { filter } from 'rxjs/operators'; +import { filter, finalize } from 'rxjs/operators'; import { environment } from '../environments/environment'; import { FeatureNotificationComponent } from './components/feature-notification/feature-notification.component'; import { ViewAsBannerComponent } from './components/view-as-banner/view-as-banner.component'; +import { postLogoutUrl } from './lib/post-logout-url'; import { Connection } from './models/connection'; import { AuthService } from './services/auth.service'; import { ConnectionsService } from './services/connections.service'; @@ -330,16 +331,24 @@ export class AppComponent { console.log(error); } - this._auth.logOutUser().subscribe(() => { - this.setUserLoggedIn(null); - localStorage.removeItem('token_expiration'); - this._auth.setAuthenticated(false); + // The local cleanup runs on every outcome (plan 84). `logOutUser()` maps an error to + // EMPTY, so a `subscribe` callback never ran after a failed request, and the user stayed + // logged in locally. + this._auth + .logOutUser() + .pipe(finalize(() => this._finishLogOut(isTokenExpired))) + .subscribe(); + } - if (this.isSaas) { - if (!isTokenExpired) window.location.href = 'https://rocketadmin.com/'; - } else { - this.router.navigate(['/login']); - } - }); + private _finishLogOut(isTokenExpired?: boolean): void { + this.setUserLoggedIn(null); + localStorage.removeItem('token_expiration'); + this._auth.setAuthenticated(false); + + if (this.isSaas) { + if (!isTokenExpired) window.location.href = postLogoutUrl(window.location.hostname); + } else { + this.router.navigate(['/login']); + } } } diff --git a/frontend/src/app/lib/post-logout-url.spec.ts b/frontend/src/app/lib/post-logout-url.spec.ts new file mode 100644 index 000000000..152147bd3 --- /dev/null +++ b/frontend/src/app/lib/post-logout-url.spec.ts @@ -0,0 +1,14 @@ +import { postLogoutUrl, ROCKETADMIN_SITE_URL, SITENOVA_LOGIN_URL } from './post-logout-url'; + +describe('postLogoutUrl', () => { + it('returns the SiteNova login page for a SiteNova host', () => { + expect(postLogoutUrl('rocket.sitenova.com')).toBe(SITENOVA_LOGIN_URL); + expect(postLogoutUrl('SiteNova.com')).toBe(SITENOVA_LOGIN_URL); + }); + + it('returns the RocketAdmin site for every other host', () => { + expect(postLogoutUrl('app.rocketadmin.com')).toBe(ROCKETADMIN_SITE_URL); + expect(postLogoutUrl('sitenova.com.evil.net')).toBe(ROCKETADMIN_SITE_URL); + expect(postLogoutUrl('')).toBe(ROCKETADMIN_SITE_URL); + }); +}); diff --git a/frontend/src/app/lib/post-logout-url.ts b/frontend/src/app/lib/post-logout-url.ts new file mode 100644 index 000000000..b36220203 --- /dev/null +++ b/frontend/src/app/lib/post-logout-url.ts @@ -0,0 +1,16 @@ +export const ROCKETADMIN_SITE_URL = 'https://rocketadmin.com/'; +export const SITENOVA_LOGIN_URL = 'https://app.sitenova.com/login'; + +/** + * Where the SaaS build sends the browser after a logout (plan 84). + * + * The panel on `rocket.sitenova.com` is reached from the SiteNova app, so a SiteNova host returns + * to that app's login page. Every other host returns to the RocketAdmin site. + */ +export function postLogoutUrl(hostname: string): string { + const host = (hostname ?? '').trim().toLowerCase(); + if (host === 'sitenova.com' || host.endsWith('.sitenova.com')) { + return SITENOVA_LOGIN_URL; + } + return ROCKETADMIN_SITE_URL; +}