Merge branch 'claude/oauth-token-session-mgmt-u9arau' into claude/oauth-token-session-mgmt-u9arau-2

This commit is contained in:
Félix Malfait
2026-08-01 13:55:31 +00:00
6 changed files with 60 additions and 27 deletions

View File

@@ -0,0 +1,4 @@
// Single source for both the WORKSPACE_AUTO_LOGIN_WINDOW default and the
// fallback the gate applies when the configured value cannot be parsed, so
// the two cannot drift apart.
export const DEFAULT_WORKSPACE_AUTO_LOGIN_WINDOW = '10m';

View File

@@ -2,6 +2,8 @@ import { addMilliseconds } from 'date-fns';
import ms from 'ms';
import { isDefined } from 'twenty-shared/utils';
import { DEFAULT_WORKSPACE_AUTO_LOGIN_WINDOW } from 'src/engine/core-modules/auth/constants/default-workspace-auto-login-window.constant';
// Listing the workspaces a user belongs to is harmless and stays available
// for the whole session, but converting that credential into workspace access
// without re-authenticating is not: a workspace-agnostic session outlives a
@@ -9,7 +11,9 @@ import { isDefined } from 'twenty-shared/utils';
// cookie it does not own), so it would otherwise hand the workspace back.
// Workspace-scoped credentials are unaffected: signing out of a workspace
// revokes them, so they cannot outlive the sign-out they would bypass.
const DEFAULT_WORKSPACE_AUTO_LOGIN_WINDOW_MS = 10 * 60 * 1000;
const DEFAULT_WORKSPACE_AUTO_LOGIN_WINDOW_MS = ms(
DEFAULT_WORKSPACE_AUTO_LOGIN_WINDOW,
);
export const canCredentialAutoLoginIntoWorkspaces = ({
isWorkspaceScopedCredential,

View File

@@ -47,6 +47,7 @@ import { CastToUpperSnakeCase } from 'src/engine/core-modules/twenty-config/deco
import { ConfigVariablesMetadata } from 'src/engine/core-modules/twenty-config/decorators/config-variables-metadata.decorator';
import { IsAWSRegion } from 'src/engine/core-modules/twenty-config/decorators/is-aws-region.decorator';
import { IsDuration } from 'src/engine/core-modules/twenty-config/decorators/is-duration.decorator';
import { DEFAULT_WORKSPACE_AUTO_LOGIN_WINDOW } from 'src/engine/core-modules/auth/constants/default-workspace-auto-login-window.constant';
import { IsNonNegativeDuration } from 'src/engine/core-modules/twenty-config/decorators/is-non-negative-duration.decorator';
import { IsPositiveDuration } from 'src/engine/core-modules/twenty-config/decorators/is-positive-duration.decorator';
import { IsOptionalOrEmptyString } from 'src/engine/core-modules/twenty-config/decorators/is-optional-or-empty-string.decorator';
@@ -399,7 +400,7 @@ export class ConfigVariables {
})
@IsNonNegativeDuration()
@IsOptional()
WORKSPACE_AUTO_LOGIN_WINDOW = '10m';
WORKSPACE_AUTO_LOGIN_WINDOW: string = DEFAULT_WORKSPACE_AUTO_LOGIN_WINDOW;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.ADVANCED_SETTINGS,

View File

@@ -9,6 +9,18 @@ import { USER_SESSION_SECURE_COOKIE_NAME } from 'src/engine/core-modules/user-se
import { extractUserSessionTokenFromRequestCookie } from 'src/engine/core-modules/user-session/utils/extract-user-session-token-from-request.util';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
const isHttpsUrl = (url: string | undefined): boolean => {
if (!isNonEmptyString(url)) {
return false;
}
try {
return new URL(url).protocol === 'https:';
} catch {
return false;
}
};
@Injectable()
export class UserSessionCookieService {
constructor(private readonly twentyConfigService: TwentyConfigService) {}
@@ -19,10 +31,11 @@ export class UserSessionCookieService {
const serverUrl = this.twentyConfigService.get('SERVER_URL');
const sameSite = this.twentyConfigService.get('AUTH_COOKIE_SAME_SITE');
// Parsed rather than prefix-matched: a SERVER_URL spelled HTTPS:// is
// https, and treating it as plain http would silently hand out a cookie
// without the __Host- prefix.
// SameSite=None is rejected by browsers without Secure, so it forces it.
return (
Boolean(serverUrl && serverUrl.startsWith('https')) || sameSite === 'none'
);
return isHttpsUrl(serverUrl) || sameSite === 'none';
}
// The kill switch lives here rather than at each call site: with it off,

View File

@@ -19,23 +19,39 @@ const toOrigin = (url: string): string | undefined => {
}
};
// The whole 127.0.0.0/8 block is loopback, not just 127.0.0.1, and IPv6
// loopback arrives bracketed from URL.hostname.
const LOOPBACK_HOSTNAMES = new Set([
'localhost',
'[::1]',
'::1',
'[::ffff:127.0.0.1]',
]);
// URL canonicalises [::ffff:127.0.0.1] to [::ffff:7f00:1], so matching the
// dotted spelling alone would miss it. The whole 127.0.0.0/8 block is
// loopback, not just 127.0.0.1.
const IPV4_LOOPBACK_REGEX = /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;
const IPV4_MAPPED_DOTTED_REGEX = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/;
const IPV4_MAPPED_HEX_REGEX = /^::ffff:([0-9a-f]{1,4}):[0-9a-f]{1,4}$/;
const isLoopbackHostname = (hostname: string): boolean => {
const host = hostname.replace(/^\[|\]$/g, '').toLowerCase();
if (host === 'localhost' || host === '::1') {
return true;
}
if (IPV4_LOOPBACK_REGEX.test(host)) {
return true;
}
const mappedDotted = IPV4_MAPPED_DOTTED_REGEX.exec(host);
if (mappedDotted !== null) {
return IPV4_LOOPBACK_REGEX.test(mappedDotted[1]);
}
const mappedHex = IPV4_MAPPED_HEX_REGEX.exec(host);
// The high byte of the first hextet is the first octet of the v4 address.
return mappedHex !== null && Number.parseInt(mappedHex[1], 16) >> 8 === 127;
};
const isLoopbackOrigin = (origin: string): boolean => {
try {
const hostname = new URL(origin).hostname.toLowerCase();
return (
LOOPBACK_HOSTNAMES.has(hostname) || IPV4_LOOPBACK_REGEX.test(hostname)
);
return isLoopbackHostname(new URL(origin).hostname);
} catch {
return false;
}

View File

@@ -38,13 +38,11 @@ describe('CookieSessionCsrfMiddleware', () => {
};
let next: NextFunction;
let afterEachRestoreFlag = false;
// Restored unconditionally: a failing assertion must not leak a mutated
// config into the tests that follow, since mockConfig is describe-scoped.
afterEach(() => {
if (afterEachRestoreFlag) {
mockConfig.AUTH_COOKIE_SESSIONS_ENABLED = true;
afterEachRestoreFlag = false;
}
mockConfig.AUTH_COOKIE_SESSIONS_ENABLED = true;
mockConfig.AUTH_COOKIE_ALLOWED_ORIGINS = '';
});
beforeEach(async () => {
@@ -167,8 +165,6 @@ describe('CookieSessionCsrfMiddleware', () => {
middleware.use(request, buildResponse(), next);
expect(next).toHaveBeenCalled();
mockConfig.AUTH_COOKIE_ALLOWED_ORIGINS = '';
});
// Browsers omit :443 from Origin while Host keeps a port the client spelled
@@ -207,7 +203,6 @@ describe('CookieSessionCsrfMiddleware', () => {
it('should skip when cookie sessions are disabled', () => {
mockConfig.AUTH_COOKIE_SESSIONS_ENABLED = false;
afterEachRestoreFlag = true;
const request = buildRequest({
headers: {