mirror of
https://github.com/twentyhq/twenty.git
synced 2026-08-02 10:43:08 -04:00
Merge branch 'claude/oauth-token-session-mgmt-u9arau' into claude/oauth-token-session-mgmt-u9arau-2
This commit is contained in:
@@ -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';
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user