feat(settings): discovery hero rollout + ephemeral playground token (#21072)

## Summary

Two intertwined streams of work:

### UI — discovery hero pattern, settings shell, AI/API redesign
- **Generalize `SettingsDiscoveryHeroCard`** and use it on Layout, Data
Model, Apps, AI, API/Webhooks, Members. Drops 4 per-page wrapper files
(`SettingsObjectCoverImage`, `SettingsLayoutCoverImage`,
`SettingsLayoutCustomizeVideoModal`,
`SettingsDataModelVisualizeVideoModal`). Each page now supplies cover
src, modal id, and tab list.
- **Modal**: swap `<video>` placeholder for the Vimeo iframe pattern
from `twenty-docs`, per-tab `vimeoId`. Drop the parallel border-bottom
on the header (TabList draws its own baseline) and the grey background
behind the video. Note: Vimeo's embed allowlist applies — the iframes
load with the correct URL on `localhost` but the player itself requires
the video owner to allow the dev/staging domains in Vimeo settings.
- **AI page** rebuilt into a Cockpit pattern (Overview / Models / Skills
/ Tools / Usage). New `SettingsAiOverviewTab` with default Smart/Fast
pickers, at-a-glance stats, and an MCP signpost that deep-links to
`/settings/api-webhooks#mcp`. System Prompt link moved under Models.
Advanced tab removed.
- **API & Webhooks** now has 4 tabs (Playground / MCP / API Keys /
Webhooks). Hero card above tabs. Playground tab inverted to "Core API" /
"Metadata API" sections, each containing REST + GraphQL cards — schema
is the meaningful axis, protocol is secondary. Hash deep-link sync
delegated to the shared `TabListFromUrlOptionalEffect`.
- **Settings shell**: unified drawer outer padding (kill `isSettings`
branch), extract `CollapsibleNavigationDrawerSection`, add `iconColor`
on settings nav items, fix Exit Settings button alignment, 880px content
cap.

### Backend — strategy C: ephemeral playground token
The legacy paste-your-API-key flow is replaced by an on-demand
short-lived token scoped to the calling user's permissions. No shared
"Playground" API key to manage or revoke.

- New `JwtTokenTypeEnum.PLAYGROUND`. `PlaygroundTokenJwtPayload =
Omit<AccessTokenJwtPayload, 'type' | impersonation fields>` so any
future ACCESS claim flows through automatically.
- `AccessTokenService.generatePlaygroundToken` signs an access-shaped
JWT with `type: PLAYGROUND` and a configurable short TTL. A shared
private `resolveTokenSubject` helper parallelizes the user / workspace /
userWorkspace lookups for both generators.
- `JwtAuthStrategy.validateAccessToken` widened to accept
`AccessTokenJwtPayload | PlaygroundTokenJwtPayload`; impersonation gated
on `payload.type === ACCESS` so the union narrows without `as unknown
as` casts. The two branches in `validate()` collapse into one.
- New `PLAYGROUND_TOKEN_EXPIRES_IN` config var (default `2h`).
- New `generatePlaygroundToken` mutation (`WorkspaceAuthGuard`, no args,
returns `AuthToken`).
- Frontend `useOpenPlayground` hook centralizes mint → atom write →
navigate, with Apollo `onError` snackbar and a "use cached PLAYGROUND
token if still fresh" short-circuit (decodes via `jwt-decode`, checks
both `type` AND `exp`). Old API_KEY tokens left in localStorage from the
prior paste-form flow are rejected on `type` alone and force a re-mint —
this is what was causing the "This API Key is revoked" symptom on stale
browsers.

### Drive-by cleanups
- `PlaygroundToken` DTO removed (identical shape to `AuthToken` already
in use).
- 5 `customize-sidebar.webm` imports and the dead placeholder pipeline
removed.

## Test plan

### Discovery hero
- [ ] `/settings/layout`, `/settings/data-model`,
`/settings/applications`, `/settings/ai`, `/settings/api-webhooks`,
`/settings/members` each render the discovery hero card with its
illustration + play button + tabbed modal
- [ ] Modal tabs show the correct Vimeo embed URL per tab; aspect ratio
stays at 1440/900; no parallel border-bottom jog at the tab baseline
- [ ] AI Overview tab shows Smart/Fast model pickers + stats grid + MCP
signpost card; the MCP card lands on `/settings/api-webhooks#mcp` with
the MCP tab active

### API playground (ephemeral token)
- [ ] With an empty `playgroundApiKeyState` in localStorage, clicking
REST or GraphQL playground card opens the playground and the cached
token has `type: "PLAYGROUND"` with ~2h exp
- [ ] Clicking the card again within the freshness window does **not**
re-mint (`iat` / fingerprint stable across visits)
- [ ] Planting a fake API_KEY-shaped JWT in localStorage and clicking
the card forces a fresh mint (old token rejected on `type`)
- [ ] `GET /rest/companies?limit=1` with the cached token returns 200 +
real data
- [ ] `POST /graphql { __typename }` returns 200

### Settings shell
- [ ] Settings nav matches main app drawer padding; sections collapse;
Exit Settings button aligns with the workspace links above
- [ ] Active nav items have a right-gap (cleaner active state)
- [ ] Content area capped at 880px

### Verify
- [ ] `npx nx typecheck twenty-front` passes
- [ ] `npx nx typecheck twenty-server` passes
- [ ] `npx nx lint:diff-with-main twenty-front` passes
- [ ] `npx nx lint:diff-with-main twenty-server` passes
This commit is contained in:
Félix Malfait
2026-06-01 14:16:02 +02:00
committed by GitHub
parent 6e00a122c6
commit b338a7a1d2
148 changed files with 3092 additions and 1694 deletions
@@ -14,6 +14,7 @@ import { UpdateApiKeyInput } from 'src/engine/core-modules/api-key/dtos/update-a
import { apiKeyGraphqlApiExceptionHandler } from 'src/engine/core-modules/api-key/utils/api-key-graphql-api-exception-handler.util';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { RequireAccessTokenGuard } from 'src/engine/guards/require-access-token.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto';
@@ -58,6 +59,10 @@ export class ApiKeyResolver {
}
}
// A long-lived API key is durable credential management: only a first-person
// session (ACCESS) may mint or alter one, so a short-lived derived token
// (PLAYGROUND) or an API key itself cannot escalate into another.
@UseGuards(RequireAccessTokenGuard)
@Mutation(() => ApiKeyEntity)
async createApiKey(
@AuthWorkspace() workspace: WorkspaceEntity,
@@ -72,6 +77,7 @@ export class ApiKeyResolver {
});
}
@UseGuards(RequireAccessTokenGuard)
@Mutation(() => ApiKeyEntity, { nullable: true })
async updateApiKey(
@AuthWorkspace() workspace: WorkspaceEntity,
@@ -89,6 +95,7 @@ export class ApiKeyResolver {
return this.apiKeyService.update(input.id, workspace.id, updateData);
}
@UseGuards(RequireAccessTokenGuard)
@Mutation(() => ApiKeyEntity, { nullable: true })
async revokeApiKey(
@AuthWorkspace() workspace: WorkspaceEntity,
@@ -97,6 +104,7 @@ export class ApiKeyResolver {
return this.apiKeyService.revoke(input.id, workspace.id);
}
@UseGuards(RequireAccessTokenGuard)
@Mutation(() => Boolean)
async assignRoleToApiKey(
@AuthWorkspace() workspace: WorkspaceEntity,
@@ -21,6 +21,7 @@ import { ApiKeyService } from 'src/engine/core-modules/api-key/services/api-key.
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard';
import { RequireAccessTokenGuard } from 'src/engine/guards/require-access-token.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { PermissionsRestApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-rest-api-exception.filter';
@@ -54,6 +55,9 @@ export class ApiKeyController {
return this.apiKeyService.findById(id, workspace.id);
}
// Minting/altering a long-lived API key requires a first-person session
// (ACCESS); a derived PLAYGROUND token or an API key cannot escalate here.
@UseGuards(RequireAccessTokenGuard)
@Post()
async create(
@Body() createApiKeyDto: CreateApiKeyInput,
@@ -70,6 +74,7 @@ export class ApiKeyController {
});
}
@UseGuards(RequireAccessTokenGuard)
@Patch(':id')
async update(
@Param('id') id: string,
@@ -91,6 +96,7 @@ export class ApiKeyController {
return this.apiKeyService.update(id, workspace.id, updateData);
}
@UseGuards(RequireAccessTokenGuard)
@Delete(':id')
async remove(
@Param('id') id: string,
@@ -10,7 +10,7 @@ import {
} from 'src/engine/core-modules/api-key/exceptions/api-key.exception';
import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/services/api-key-role.service';
import { ApiKeyService } from 'src/engine/core-modules/api-key/services/api-key.service';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity';
import { RoleTargetService } from 'src/engine/metadata-modules/role-target/services/role-target.service';
@@ -10,7 +10,7 @@ import {
ApiKeyExceptionCode,
} from 'src/engine/core-modules/api-key/exceptions/api-key.exception';
import { type ApiKeyToken } from 'src/engine/core-modules/auth/dto/api-key-token.dto';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { RoleTargetService } from 'src/engine/metadata-modules/role-target/services/role-target.service';
import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator';
@@ -18,7 +18,7 @@ import { isDefined } from 'twenty-shared/utils';
import { type ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity';
import { ConnectionProviderOAuthFlowService } from 'src/engine/core-modules/application/connection-provider/connection-provider-oauth-flow.service';
import { ConnectionProviderService } from 'src/engine/core-modules/application/connection-provider/connection-provider.service';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@@ -19,10 +19,8 @@ import { buildAppOAuthCallbackUrl } from 'src/engine/core-modules/application/co
import { computePkceChallenge } from 'src/engine/core-modules/application/connection-provider/utils/compute-pkce-challenge.util';
import { exchangeCodeForToken } from 'src/engine/core-modules/application/connection-provider/utils/exchange-code-for-token.util';
import { generatePkceVerifier } from 'src/engine/core-modules/application/connection-provider/utils/generate-pkce-verifier.util';
import {
type AppOAuthStateJwtPayload,
JwtTokenTypeEnum,
} from 'src/engine/core-modules/auth/types/auth-context.type';
import { type AppOAuthStateJwtPayload } from 'src/engine/core-modules/auth/types/app-oauth-state-jwt-payload.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@@ -16,10 +16,8 @@ import {
ApprovedAccessDomainExceptionCode,
} from 'src/engine/core-modules/approved-access-domain/approved-access-domain.exception';
import { approvedAccessDomainValidator } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.validate';
import {
type ApprovedAccessDomainJwtPayload,
JwtTokenTypeEnum,
} from 'src/engine/core-modules/auth/types/auth-context.type';
import { type ApprovedAccessDomainJwtPayload } from 'src/engine/core-modules/auth/types/approved-access-domain-jwt-payload.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
@@ -10,7 +10,7 @@ import {
ApprovedAccessDomainException,
ApprovedAccessDomainExceptionCode,
} from 'src/engine/core-modules/approved-access-domain/approved-access-domain.exception';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
@@ -6,6 +6,7 @@ import { ApiKeyService } from 'src/engine/core-modules/api-key/services/api-key.
import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service';
import { CaptchaGuard } from 'src/engine/core-modules/captcha/captcha.guard';
@@ -86,6 +87,10 @@ describe('AuthResolver', () => {
provide: ApiKeyService,
useValue: {},
},
{
provide: AccessTokenService,
useValue: {},
},
{
provide: ResetPasswordService,
useValue: {},
@@ -38,17 +38,16 @@ import { VerifyEmailAndGetLoginTokenDTO } from 'src/engine/core-modules/auth/dto
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
import { ResetPasswordService } from 'src/engine/core-modules/auth/services/reset-password.service';
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { EmailVerificationTokenService } from 'src/engine/core-modules/auth/token/services/email-verification-token.service';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
import { RenewTokenService } from 'src/engine/core-modules/auth/token/services/renew-token.service';
import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service';
import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service';
import {
AuthContextUser,
JwtTokenTypeEnum,
LoginTokenJwtPayload,
} from 'src/engine/core-modules/auth/types/auth-context.type';
import { AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { LoginTokenJwtPayload } from 'src/engine/core-modules/auth/types/login-token-jwt-payload.type';
import { CaptchaGuard } from 'src/engine/core-modules/captcha/captcha.guard';
import { CaptchaGraphqlApiExceptionFilter } from 'src/engine/core-modules/captcha/filters/captcha-graphql-api-exception.filter';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
@@ -73,6 +72,7 @@ import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
import { RequireAccessTokenGuard } from 'src/engine/guards/require-access-token.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
@@ -80,6 +80,7 @@ import { PermissionsService } from 'src/engine/metadata-modules/permissions/perm
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
import { ApiKeyToken } from './dto/api-key-token.dto';
import { AuthToken } from './dto/auth-token.dto';
import { AuthTokens } from './dto/auth-tokens.dto';
import { GetAuthTokensFromLoginTokenInput } from './dto/get-auth-tokens-from-login-token.input';
import { LoginTokenDTO } from './dto/login-token.dto';
@@ -112,6 +113,7 @@ export class AuthResolver {
private renewTokenService: RenewTokenService,
private userService: UserService,
private apiKeyService: ApiKeyService,
private accessTokenService: AccessTokenService,
private resetPasswordService: ResetPasswordService,
private loginTokenService: LoginTokenService,
private workspaceAgnosticTokenService: WorkspaceAgnosticTokenService,
@@ -805,6 +807,7 @@ export class AuthResolver {
@UseGuards(
WorkspaceAuthGuard,
RequireAccessTokenGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@Mutation(() => ApiKeyToken)
@@ -819,6 +822,24 @@ export class AuthResolver {
);
}
@UseGuards(
WorkspaceAuthGuard,
RequireAccessTokenGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@Mutation(() => AuthToken)
async generatePlaygroundToken(
@AuthUser() user: UserEntity,
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthProvider() authProvider: AuthProviderEnum,
): Promise<AuthToken> {
return await this.accessTokenService.generatePlaygroundToken({
userId: user.id,
workspaceId: workspace.id,
authProvider,
});
}
@Mutation(() => EmailPasswordResetLinkDTO)
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
async emailPasswordResetLink(
@@ -45,10 +45,8 @@ import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service';
import {
AuthContextUser,
JwtTokenTypeEnum,
} from 'src/engine/core-modules/auth/types/auth-context.type';
import { AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import {
type AuthProviderWithPasswordType,
type ExistingUserOrNewUser,
@@ -6,10 +6,8 @@ import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import {
type JwtPayload,
JwtTokenTypeEnum,
} from 'src/engine/core-modules/auth/types/auth-context.type';
import { type JwtPayload } from 'src/engine/core-modules/auth/types/jwt-payload.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { JwtAuthStrategy } from './jwt.auth.strategy';
@@ -944,4 +942,58 @@ describe('JwtAuthStrategy', () => {
);
});
});
describe('PLAYGROUND token validation', () => {
// A PLAYGROUND token is access-shaped but must never impersonate. This is the
// same payload that, as an ACCESS token with isImpersonating set but without
// impersonation ids, throws 'Invalid or missing user workspace ID in
// impersonation token' above; as a PLAYGROUND token it must skip the
// impersonation path entirely and resolve to the first-person context.
it('ignores isImpersonating and resolves first-person', async () => {
const validUserId = 'valid-user-id';
const validUserWorkspaceId = randomUUID();
const validWorkspaceId = randomUUID();
const payload = {
sub: validUserId,
type: JwtTokenTypeEnum.PLAYGROUND,
userWorkspaceId: validUserWorkspaceId,
workspaceId: validWorkspaceId,
isImpersonating: true,
};
workspaceStore[validWorkspaceId] = new WorkspaceEntity();
userStore[validUserId] = { id: validUserId, lastName: 'lastNameDefault' };
coreEntityCacheService.get.mockImplementation(
async (keyName: string, entityId: string) => {
if (keyName === 'workspaceEntity') {
return workspaceStore[entityId] ?? null;
}
if (keyName === 'user') {
return userStore[entityId] ?? null;
}
if (keyName === 'userWorkspaceEntity') {
return {
id: validUserWorkspaceId,
user: { id: validUserId, lastName: 'lastNameDefault' },
workspace: { id: validWorkspaceId },
};
}
return null;
},
);
strategy = createStrategy();
const result = await strategy.validate(payload as JwtPayload);
expect(result.impersonationContext).toBeUndefined();
expect(result.tokenType).toBe(JwtTokenTypeEnum.PLAYGROUND);
expect(result.userWorkspaceId).toBe(validUserWorkspaceId);
});
});
});
@@ -13,15 +13,16 @@ import {
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import {
type AccessTokenJwtPayload,
type ApiKeyTokenJwtPayload,
ApplicationAccessTokenJwtPayload,
type AuthContext,
type AuthContextUser,
type JwtPayload,
JwtTokenTypeEnum,
type WorkspaceAgnosticTokenJwtPayload,
} from 'src/engine/core-modules/auth/types/auth-context.type';
import { type AccessTokenJwtPayload } from 'src/engine/core-modules/auth/types/access-token-jwt-payload.type';
import { type ApiKeyTokenJwtPayload } from 'src/engine/core-modules/auth/types/api-key-token-jwt-payload.type';
import { ApplicationAccessTokenJwtPayload } from 'src/engine/core-modules/auth/types/application-access-token-jwt-payload.type';
import { type JwtPayload } from 'src/engine/core-modules/auth/types/jwt-payload.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { type PlaygroundTokenJwtPayload } from 'src/engine/core-modules/auth/types/playground-token-jwt-payload.type';
import { type WorkspaceAgnosticTokenJwtPayload } from 'src/engine/core-modules/auth/types/workspace-agnostic-token-jwt-payload.type';
import { type FlatUserWorkspace } from 'src/engine/core-modules/user-workspace/types/flat-user-workspace.type';
import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service';
import { JWT_SUPPORTED_VERIFY_ALGORITHMS } from 'src/engine/core-modules/jwt/constants/jwt-algorithm.constant';
@@ -100,7 +101,7 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
}
private async validateAccessToken(
payload: AccessTokenJwtPayload,
payload: AccessTokenJwtPayload | PlaygroundTokenJwtPayload,
): Promise<AuthContext> {
let user: AuthContextUser | null = null;
let context: AuthContext = {};
@@ -117,7 +118,11 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
);
}
if (payload.isImpersonating === true) {
// Only ACCESS tokens can carry impersonation; PLAYGROUND is always first-person.
if (
payload.type === JwtTokenTypeEnum.ACCESS &&
payload.isImpersonating === true
) {
context.impersonationContext = await this.validateImpersonation(payload);
}
@@ -425,6 +430,17 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
}
async validate(payload: JwtPayload): Promise<AuthContext> {
const context = await this.dispatch(payload);
return {
...context,
tokenType: this.isLegacyApiKeyPayload(payload)
? JwtTokenTypeEnum.API_KEY
: payload.type,
};
}
private async dispatch(payload: JwtPayload): Promise<AuthContext> {
// Support legacy api keys
if (
payload.type === JwtTokenTypeEnum.API_KEY ||
@@ -437,7 +453,10 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
return await this.validateWorkspaceAgnosticToken(payload);
}
if (payload.type === JwtTokenTypeEnum.ACCESS) {
if (
payload.type === JwtTokenTypeEnum.ACCESS ||
payload.type === JwtTokenTypeEnum.PLAYGROUND
) {
return await this.validateAccessToken(payload);
}
@@ -224,6 +224,12 @@ describe('AccessTokenService', () => {
it('should throw an error if user is not found', async () => {
jest.spyOn(twentyConfigService, 'get').mockReturnValue('1h');
jest.spyOn(userRepository, 'findOne').mockResolvedValue(null);
jest
.spyOn(workspaceRepository, 'findOne')
.mockResolvedValue({} as WorkspaceEntity);
jest
.spyOn(userWorkspaceRepository, 'findOne')
.mockResolvedValue({} as UserWorkspaceEntity);
await expect(
service.generateAccessToken({
@@ -15,11 +15,10 @@ import {
} from 'src/engine/core-modules/auth/auth.exception';
import { type AuthToken } from 'src/engine/core-modules/auth/dto/auth-token.dto';
import { JwtAuthStrategy } from 'src/engine/core-modules/auth/strategies/jwt.auth.strategy';
import {
type AccessTokenJwtPayload,
type AuthContext,
JwtTokenTypeEnum,
} from 'src/engine/core-modules/auth/types/auth-context.type';
import { type AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { type AccessTokenJwtPayload } from 'src/engine/core-modules/auth/types/access-token-jwt-payload.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { type PlaygroundTokenJwtPayload } from 'src/engine/core-modules/auth/types/playground-token-jwt-payload.type';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
@@ -47,42 +46,36 @@ export class AccessTokenService {
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
) {}
async generateAccessToken({
userId,
workspaceId,
authProvider,
isImpersonating,
impersonatorUserWorkspaceId,
impersonatedUserWorkspaceId,
}: Omit<
AccessTokenJwtPayload,
'type' | 'workspaceMemberId' | 'userWorkspaceId' | 'sub'
>): Promise<AuthToken> {
const expiresIn = this.twentyConfigService.get('ACCESS_TOKEN_EXPIRES_IN');
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
const user = await this.userRepository.findOne({
where: { id: userId },
});
private async resolveTokenSubject(
userId: string,
workspaceId: string,
): Promise<{
user: UserEntity;
workspace: WorkspaceEntity;
userWorkspace: UserWorkspaceEntity;
workspaceMemberId: string | undefined;
}> {
const [user, workspace, userWorkspace] = await Promise.all([
this.userRepository.findOne({ where: { id: userId } }),
this.workspaceRepository.findOne({ where: { id: workspaceId } }),
this.userWorkspaceRepository.findOne({
where: { userId, workspaceId },
}),
]);
userValidator.assertIsDefinedOrThrow(
user,
new AuthException('User is not found', AuthExceptionCode.INVALID_INPUT),
);
let tokenWorkspaceMemberId: string | undefined;
const workspace = await this.workspaceRepository.findOne({
where: { id: workspaceId },
});
assertIsDefinedOrThrow(workspace, WorkspaceNotFoundDefaultError);
assertIsDefinedOrThrow(userWorkspace, UserWorkspaceNotFoundDefaultError);
let workspaceMemberId: string | undefined;
if (isWorkspaceActiveOrSuspended(workspace)) {
const authContext = buildSystemAuthContext(workspaceId);
tokenWorkspaceMemberId =
workspaceMemberId =
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const workspaceMemberRepository =
@@ -93,9 +86,7 @@ export class AccessTokenService {
);
const workspaceMember = await workspaceMemberRepository.findOne({
where: {
userId: user.id,
},
where: { userId: user.id },
});
assertIsDefinedOrThrow(
@@ -114,31 +105,73 @@ export class AccessTokenService {
authContext,
);
}
const userWorkspace = await this.userWorkspaceRepository.findOne({
where: {
userId: user.id,
workspaceId,
},
});
assertIsDefinedOrThrow(userWorkspace, UserWorkspaceNotFoundDefaultError);
return { user, workspace, userWorkspace, workspaceMemberId };
}
const payloadImpersonatorUserWorkspaceId =
isImpersonating === true ? impersonatorUserWorkspaceId : undefined;
const payloadOriginalUserWorkspaceId =
isImpersonating === true ? impersonatedUserWorkspaceId : undefined;
async generateAccessToken({
userId,
workspaceId,
authProvider,
isImpersonating,
impersonatorUserWorkspaceId,
impersonatedUserWorkspaceId,
}: Omit<
AccessTokenJwtPayload,
'type' | 'workspaceMemberId' | 'userWorkspaceId' | 'sub'
>): Promise<AuthToken> {
const expiresIn = this.twentyConfigService.get('ACCESS_TOKEN_EXPIRES_IN');
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
const { user, userWorkspace, workspaceMemberId } =
await this.resolveTokenSubject(userId, workspaceId);
const jwtPayload: AccessTokenJwtPayload = {
sub: user.id,
userId: user.id,
workspaceId,
workspaceMemberId: tokenWorkspaceMemberId,
workspaceMemberId,
userWorkspaceId: userWorkspace.id,
type: JwtTokenTypeEnum.ACCESS,
authProvider,
isImpersonating: isImpersonating === true,
impersonatorUserWorkspaceId: payloadImpersonatorUserWorkspaceId,
impersonatedUserWorkspaceId: payloadOriginalUserWorkspaceId,
impersonatorUserWorkspaceId:
isImpersonating === true ? impersonatorUserWorkspaceId : undefined,
impersonatedUserWorkspaceId:
isImpersonating === true ? impersonatedUserWorkspaceId : undefined,
};
const token = await this.jwtWrapperService.signAsyncOrThrow(jwtPayload, {
expiresIn,
});
return { token, expiresAt };
}
async generatePlaygroundToken({
userId,
workspaceId,
authProvider,
}: Pick<
PlaygroundTokenJwtPayload,
'userId' | 'workspaceId' | 'authProvider'
>): Promise<AuthToken> {
const expiresIn = this.twentyConfigService.get(
'PLAYGROUND_TOKEN_EXPIRES_IN',
);
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
const { user, userWorkspace, workspaceMemberId } =
await this.resolveTokenSubject(userId, workspaceId);
const jwtPayload: PlaygroundTokenJwtPayload = {
sub: user.id,
userId: user.id,
workspaceId,
workspaceMemberId,
userWorkspaceId: userWorkspace.id,
type: JwtTokenTypeEnum.PLAYGROUND,
authProvider,
};
const token = await this.jwtWrapperService.signAsyncOrThrow(jwtPayload, {
@@ -10,7 +10,7 @@ import {
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -8,11 +8,9 @@ import ms from 'ms';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import {
type ApplicationAccessTokenJwtPayload,
type ApplicationRefreshTokenJwtPayload,
JwtTokenTypeEnum,
} from 'src/engine/core-modules/auth/types/auth-context.type';
import { type ApplicationAccessTokenJwtPayload } from 'src/engine/core-modules/auth/types/application-access-token-jwt-payload.type';
import { type ApplicationRefreshTokenJwtPayload } from 'src/engine/core-modules/auth/types/application-refresh-token-jwt-payload.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { type AuthToken } from 'src/engine/core-modules/auth/dto/auth-token.dto';
import { WorkspaceNotFoundDefaultError } from 'src/engine/core-modules/workspace/workspace.exception';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
@@ -3,7 +3,7 @@ import { Test, type TestingModule } from '@nestjs/testing';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { LoginTokenService } from './login-token.service';
@@ -8,10 +8,8 @@ import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import {
type LoginTokenJwtPayload,
JwtTokenTypeEnum,
} from 'src/engine/core-modules/auth/types/auth-context.type';
import { type LoginTokenJwtPayload } from 'src/engine/core-modules/auth/types/login-token-jwt-payload.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { type AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
@@ -8,7 +8,7 @@ import {
AppTokenType,
} from 'src/engine/core-modules/app-token/app-token.entity';
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
@@ -14,10 +14,8 @@ import {
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { type AuthToken } from 'src/engine/core-modules/auth/dto/auth-token.dto';
import {
type RefreshTokenJwtPayload,
JwtTokenTypeEnum,
} from 'src/engine/core-modules/auth/types/auth-context.type';
import { type RefreshTokenJwtPayload } from 'src/engine/core-modules/auth/types/refresh-token-jwt-payload.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
@@ -8,7 +8,7 @@ import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { type UserEntity } from 'src/engine/core-modules/user/user.entity';
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
@@ -13,7 +13,7 @@ import { type AuthToken } from 'src/engine/core-modules/auth/dto/auth-token.dto'
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
@Injectable()
@@ -2,7 +2,7 @@ import { Test, type TestingModule } from '@nestjs/testing';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { TransientTokenService } from './transient-token.service';
@@ -10,10 +10,8 @@ import {
} from 'src/engine/core-modules/auth/auth.exception';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import {
type TransientTokenJwtPayload,
JwtTokenTypeEnum,
} from 'src/engine/core-modules/auth/types/auth-context.type';
import { type TransientTokenJwtPayload } from 'src/engine/core-modules/auth/types/transient-token-jwt-payload.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
@Injectable()
export class TransientTokenService {
@@ -9,7 +9,7 @@ import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twent
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service';
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
describe('WorkspaceAgnosticToken', () => {
let service: WorkspaceAgnosticTokenService;
@@ -11,11 +11,9 @@ import {
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { type AuthToken } from 'src/engine/core-modules/auth/dto/auth-token.dto';
import {
type AuthContext,
JwtTokenTypeEnum,
type WorkspaceAgnosticTokenJwtPayload,
} from 'src/engine/core-modules/auth/types/auth-context.type';
import { type AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { type WorkspaceAgnosticTokenJwtPayload } from 'src/engine/core-modules/auth/types/workspace-agnostic-token-jwt-payload.type';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
@@ -0,0 +1,15 @@
import { type CommonPropertiesJwtPayload } from 'src/engine/core-modules/auth/types/common-properties-jwt-payload.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { type AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
export type AccessTokenJwtPayload = CommonPropertiesJwtPayload & {
type: JwtTokenTypeEnum.ACCESS;
workspaceId: string;
userId: string;
workspaceMemberId?: string;
userWorkspaceId: string;
authProvider: AuthProviderEnum;
isImpersonating?: boolean;
impersonatorUserWorkspaceId?: string;
impersonatedUserWorkspaceId?: string;
};
@@ -0,0 +1,9 @@
import { type CommonPropertiesJwtPayload } from 'src/engine/core-modules/auth/types/common-properties-jwt-payload.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
export type ApiKeyTokenJwtPayload = CommonPropertiesJwtPayload & {
type: JwtTokenTypeEnum.API_KEY;
workspaceId: string;
workspaceMemberId?: string;
jti?: string;
};
@@ -0,0 +1,19 @@
import { type CommonPropertiesJwtPayload } from 'src/engine/core-modules/auth/types/common-properties-jwt-payload.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
export type AppOAuthStateJwtPayload = CommonPropertiesJwtPayload & {
type: JwtTokenTypeEnum.APP_OAUTH_STATE;
workspaceId: string;
connectionProviderId: string;
userId: string;
userWorkspaceId: string;
// 'user' = the resulting credential is private to userWorkspaceId.
// 'workspace' = visible to anyone in the workspace.
// Named `visibility` to disambiguate from OAuth `scopes` on the row.
visibility: 'user' | 'workspace';
// If set, the callback updates this existing connectedAccount row instead
// of creating a new one (used by the UI's "Reconnect" action).
reconnectingConnectedAccountId: string | null;
redirectLocation: string | null;
codeVerifier: string | null;
};
@@ -0,0 +1,10 @@
import { type CommonPropertiesJwtPayload } from 'src/engine/core-modules/auth/types/common-properties-jwt-payload.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
export type ApplicationAccessTokenJwtPayload = CommonPropertiesJwtPayload & {
type: JwtTokenTypeEnum.APPLICATION_ACCESS;
workspaceId: string;
applicationId: string;
userWorkspaceId?: string;
userId?: string;
};
@@ -0,0 +1,10 @@
import { type CommonPropertiesJwtPayload } from 'src/engine/core-modules/auth/types/common-properties-jwt-payload.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
export type ApplicationRefreshTokenJwtPayload = CommonPropertiesJwtPayload & {
type: JwtTokenTypeEnum.APPLICATION_REFRESH;
workspaceId: string;
applicationId: string;
userWorkspaceId?: string;
userId?: string;
};
@@ -0,0 +1,9 @@
import { type CommonPropertiesJwtPayload } from 'src/engine/core-modules/auth/types/common-properties-jwt-payload.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
export type ApprovedAccessDomainJwtPayload = CommonPropertiesJwtPayload & {
type: JwtTokenTypeEnum.APPROVED_ACCESS_DOMAIN;
workspaceId: string;
approvedAccessDomainId: string;
domain: string;
};
@@ -1,177 +1,7 @@
import { type AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
import { type FlatApiKey } from 'src/engine/core-modules/api-key/types/flat-api-key.type';
import { type FlatAuthContextUser } from 'src/engine/core-modules/auth/types/flat-auth-context-user.type';
import { type FlatUserWorkspace } from 'src/engine/core-modules/user-workspace/types/flat-user-workspace.type';
import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
import { type RawAuthContext } from 'src/engine/core-modules/auth/types/raw-auth-context.type';
export { AUTH_CONTEXT_USER_SELECT_FIELDS } from 'src/engine/core-modules/auth/constants/auth-context-user-select-fields.constants';
export { type FlatAuthContextUser as AuthContextUser } from 'src/engine/core-modules/auth/types/flat-auth-context-user.type';
export type RawAuthContext = {
user?: FlatAuthContextUser | null | undefined;
apiKey?: FlatApiKey | null | undefined;
workspaceMemberId?: string;
workspaceMember?: WorkspaceMemberWorkspaceEntity;
workspace?: FlatWorkspace;
application?: FlatApplication | null | undefined;
userWorkspaceId?: string;
userWorkspace?: FlatUserWorkspace;
authProvider?: AuthProviderEnum;
impersonationContext?: {
impersonatorUserWorkspaceId?: string;
impersonatedUserWorkspaceId?: string;
};
};
// @deprecated Use WorkspaceAuthContext instead
export type AuthContext = RawAuthContext;
export type SerializableAuthContext = {
userId?: string;
userWorkspaceId?: string;
workspaceMemberId?: string;
apiKeyId?: string;
applicationId?: string;
};
export enum JwtTokenTypeEnum {
ACCESS = 'ACCESS',
REFRESH = 'REFRESH',
WORKSPACE_AGNOSTIC = 'WORKSPACE_AGNOSTIC',
LOGIN = 'LOGIN',
FILE = 'FILE',
API_KEY = 'API_KEY',
REMOTE_SERVER = 'REMOTE_SERVER',
KEY_ENCRYPTION_KEY = 'KEY_ENCRYPTION_KEY',
APPLICATION_ACCESS = 'APPLICATION_ACCESS',
APPLICATION_REFRESH = 'APPLICATION_REFRESH',
APP_OAUTH_STATE = 'APP_OAUTH_STATE',
APPROVED_ACCESS_DOMAIN = 'APPROVED_ACCESS_DOMAIN',
}
type CommonPropertiesJwtPayload = {
sub: string;
};
export type FileTokenJwtPayloadLegacy = CommonPropertiesJwtPayload & {
type: JwtTokenTypeEnum.FILE;
workspaceId: string;
filename: string;
workspaceMemberId?: string;
noteBlockId?: string;
attachmentId?: string;
personId?: string;
};
export type FileTokenJwtPayload = CommonPropertiesJwtPayload & {
type: JwtTokenTypeEnum.FILE;
workspaceId: string;
fileId: string;
};
export type LoginTokenJwtPayload = CommonPropertiesJwtPayload & {
type: JwtTokenTypeEnum.LOGIN;
workspaceId: string;
authProvider: AuthProviderEnum;
impersonatorUserWorkspaceId?: string;
};
export type TransientTokenJwtPayload = CommonPropertiesJwtPayload & {
type: JwtTokenTypeEnum.LOGIN;
workspaceId: string;
userId: string;
workspaceMemberId: string;
};
export type RefreshTokenJwtPayload = CommonPropertiesJwtPayload & {
type: JwtTokenTypeEnum.REFRESH;
workspaceId?: string | null;
userId: string;
jti?: string;
authProvider?: AuthProviderEnum;
targetedTokenType: JwtTokenTypeEnum;
isImpersonating?: boolean;
impersonatorUserWorkspaceId?: string;
impersonatedUserWorkspaceId?: string;
};
export type WorkspaceAgnosticTokenJwtPayload = CommonPropertiesJwtPayload & {
type: JwtTokenTypeEnum.WORKSPACE_AGNOSTIC;
userId: string;
authProvider: AuthProviderEnum;
};
export type ApiKeyTokenJwtPayload = CommonPropertiesJwtPayload & {
type: JwtTokenTypeEnum.API_KEY;
workspaceId: string;
workspaceMemberId?: string;
jti?: string;
};
export type ApplicationAccessTokenJwtPayload = CommonPropertiesJwtPayload & {
type: JwtTokenTypeEnum.APPLICATION_ACCESS;
workspaceId: string;
applicationId: string;
userWorkspaceId?: string;
userId?: string;
};
export type ApplicationRefreshTokenJwtPayload = CommonPropertiesJwtPayload & {
type: JwtTokenTypeEnum.APPLICATION_REFRESH;
workspaceId: string;
applicationId: string;
userWorkspaceId?: string;
userId?: string;
};
export type AccessTokenJwtPayload = CommonPropertiesJwtPayload & {
type: JwtTokenTypeEnum.ACCESS;
workspaceId: string;
userId: string;
workspaceMemberId?: string;
userWorkspaceId: string;
authProvider: AuthProviderEnum;
isImpersonating?: boolean;
impersonatorUserWorkspaceId?: string;
impersonatedUserWorkspaceId?: string;
};
export type AppOAuthStateJwtPayload = CommonPropertiesJwtPayload & {
type: JwtTokenTypeEnum.APP_OAUTH_STATE;
workspaceId: string;
connectionProviderId: string;
userId: string;
userWorkspaceId: string;
// 'user' = the resulting credential is private to userWorkspaceId.
// 'workspace' = visible to anyone in the workspace.
// Named `visibility` to disambiguate from OAuth `scopes` on the row.
visibility: 'user' | 'workspace';
// If set, the callback updates this existing connectedAccount row instead
// of creating a new one (used by the UI's "Reconnect" action).
reconnectingConnectedAccountId: string | null;
redirectLocation: string | null;
codeVerifier: string | null;
};
export type ApprovedAccessDomainJwtPayload = CommonPropertiesJwtPayload & {
type: JwtTokenTypeEnum.APPROVED_ACCESS_DOMAIN;
workspaceId: string;
approvedAccessDomainId: string;
domain: string;
};
export type JwtPayload =
| AccessTokenJwtPayload
| ApiKeyTokenJwtPayload
| ApplicationAccessTokenJwtPayload
| ApplicationRefreshTokenJwtPayload
| WorkspaceAgnosticTokenJwtPayload
| LoginTokenJwtPayload
| TransientTokenJwtPayload
| RefreshTokenJwtPayload
| FileTokenJwtPayload
| FileTokenJwtPayloadLegacy
| AppOAuthStateJwtPayload
| ApprovedAccessDomainJwtPayload;
@@ -0,0 +1,3 @@
export type CommonPropertiesJwtPayload = {
sub: string;
};
@@ -0,0 +1,12 @@
import { type CommonPropertiesJwtPayload } from 'src/engine/core-modules/auth/types/common-properties-jwt-payload.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
export type FileTokenJwtPayloadLegacy = CommonPropertiesJwtPayload & {
type: JwtTokenTypeEnum.FILE;
workspaceId: string;
filename: string;
workspaceMemberId?: string;
noteBlockId?: string;
attachmentId?: string;
personId?: string;
};
@@ -0,0 +1,8 @@
import { type CommonPropertiesJwtPayload } from 'src/engine/core-modules/auth/types/common-properties-jwt-payload.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
export type FileTokenJwtPayload = CommonPropertiesJwtPayload & {
type: JwtTokenTypeEnum.FILE;
workspaceId: string;
fileId: string;
};
@@ -0,0 +1,28 @@
import { type AccessTokenJwtPayload } from 'src/engine/core-modules/auth/types/access-token-jwt-payload.type';
import { type ApiKeyTokenJwtPayload } from 'src/engine/core-modules/auth/types/api-key-token-jwt-payload.type';
import { type ApplicationAccessTokenJwtPayload } from 'src/engine/core-modules/auth/types/application-access-token-jwt-payload.type';
import { type ApplicationRefreshTokenJwtPayload } from 'src/engine/core-modules/auth/types/application-refresh-token-jwt-payload.type';
import { type AppOAuthStateJwtPayload } from 'src/engine/core-modules/auth/types/app-oauth-state-jwt-payload.type';
import { type ApprovedAccessDomainJwtPayload } from 'src/engine/core-modules/auth/types/approved-access-domain-jwt-payload.type';
import { type FileTokenJwtPayload } from 'src/engine/core-modules/auth/types/file-token-jwt-payload.type';
import { type FileTokenJwtPayloadLegacy } from 'src/engine/core-modules/auth/types/file-token-jwt-payload-legacy.type';
import { type LoginTokenJwtPayload } from 'src/engine/core-modules/auth/types/login-token-jwt-payload.type';
import { type PlaygroundTokenJwtPayload } from 'src/engine/core-modules/auth/types/playground-token-jwt-payload.type';
import { type RefreshTokenJwtPayload } from 'src/engine/core-modules/auth/types/refresh-token-jwt-payload.type';
import { type TransientTokenJwtPayload } from 'src/engine/core-modules/auth/types/transient-token-jwt-payload.type';
import { type WorkspaceAgnosticTokenJwtPayload } from 'src/engine/core-modules/auth/types/workspace-agnostic-token-jwt-payload.type';
export type JwtPayload =
| AccessTokenJwtPayload
| ApiKeyTokenJwtPayload
| ApplicationAccessTokenJwtPayload
| ApplicationRefreshTokenJwtPayload
| WorkspaceAgnosticTokenJwtPayload
| LoginTokenJwtPayload
| TransientTokenJwtPayload
| RefreshTokenJwtPayload
| FileTokenJwtPayload
| FileTokenJwtPayloadLegacy
| AppOAuthStateJwtPayload
| ApprovedAccessDomainJwtPayload
| PlaygroundTokenJwtPayload;
@@ -0,0 +1,15 @@
export enum JwtTokenTypeEnum {
ACCESS = 'ACCESS',
REFRESH = 'REFRESH',
WORKSPACE_AGNOSTIC = 'WORKSPACE_AGNOSTIC',
LOGIN = 'LOGIN',
FILE = 'FILE',
API_KEY = 'API_KEY',
REMOTE_SERVER = 'REMOTE_SERVER',
KEY_ENCRYPTION_KEY = 'KEY_ENCRYPTION_KEY',
APPLICATION_ACCESS = 'APPLICATION_ACCESS',
APPLICATION_REFRESH = 'APPLICATION_REFRESH',
APP_OAUTH_STATE = 'APP_OAUTH_STATE',
APPROVED_ACCESS_DOMAIN = 'APPROVED_ACCESS_DOMAIN',
PLAYGROUND = 'PLAYGROUND',
}
@@ -0,0 +1,10 @@
import { type CommonPropertiesJwtPayload } from 'src/engine/core-modules/auth/types/common-properties-jwt-payload.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { type AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
export type LoginTokenJwtPayload = CommonPropertiesJwtPayload & {
type: JwtTokenTypeEnum.LOGIN;
workspaceId: string;
authProvider: AuthProviderEnum;
impersonatorUserWorkspaceId?: string;
};
@@ -0,0 +1,12 @@
import { type AccessTokenJwtPayload } from 'src/engine/core-modules/auth/types/access-token-jwt-payload.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
export type PlaygroundTokenJwtPayload = Omit<
AccessTokenJwtPayload,
| 'type'
| 'isImpersonating'
| 'impersonatorUserWorkspaceId'
| 'impersonatedUserWorkspaceId'
> & {
type: JwtTokenTypeEnum.PLAYGROUND;
};
@@ -0,0 +1,25 @@
import { type FlatApiKey } from 'src/engine/core-modules/api-key/types/flat-api-key.type';
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
import { type FlatAuthContextUser } from 'src/engine/core-modules/auth/types/flat-auth-context-user.type';
import { type JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { type FlatUserWorkspace } from 'src/engine/core-modules/user-workspace/types/flat-user-workspace.type';
import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
import { type AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
export type RawAuthContext = {
user?: FlatAuthContextUser | null | undefined;
apiKey?: FlatApiKey | null | undefined;
workspaceMemberId?: string;
workspaceMember?: WorkspaceMemberWorkspaceEntity;
workspace?: FlatWorkspace;
application?: FlatApplication | null | undefined;
userWorkspaceId?: string;
userWorkspace?: FlatUserWorkspace;
authProvider?: AuthProviderEnum;
impersonationContext?: {
impersonatorUserWorkspaceId?: string;
impersonatedUserWorkspaceId?: string;
};
tokenType?: JwtTokenTypeEnum;
};
@@ -0,0 +1,15 @@
import { type CommonPropertiesJwtPayload } from 'src/engine/core-modules/auth/types/common-properties-jwt-payload.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { type AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
export type RefreshTokenJwtPayload = CommonPropertiesJwtPayload & {
type: JwtTokenTypeEnum.REFRESH;
workspaceId?: string | null;
userId: string;
jti?: string;
authProvider?: AuthProviderEnum;
targetedTokenType: JwtTokenTypeEnum;
isImpersonating?: boolean;
impersonatorUserWorkspaceId?: string;
impersonatedUserWorkspaceId?: string;
};
@@ -0,0 +1,7 @@
export type SerializableAuthContext = {
userId?: string;
userWorkspaceId?: string;
workspaceMemberId?: string;
apiKeyId?: string;
applicationId?: string;
};
@@ -0,0 +1,9 @@
import { type CommonPropertiesJwtPayload } from 'src/engine/core-modules/auth/types/common-properties-jwt-payload.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
export type TransientTokenJwtPayload = CommonPropertiesJwtPayload & {
type: JwtTokenTypeEnum.LOGIN;
workspaceId: string;
userId: string;
workspaceMemberId: string;
};
@@ -0,0 +1,9 @@
import { type CommonPropertiesJwtPayload } from 'src/engine/core-modules/auth/types/common-properties-jwt-payload.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { type AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
export type WorkspaceAgnosticTokenJwtPayload = CommonPropertiesJwtPayload & {
type: JwtTokenTypeEnum.WORKSPACE_AGNOSTIC;
userId: string;
authProvider: AuthProviderEnum;
};
@@ -1,4 +1,4 @@
import { type RawAuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { type RawAuthContext } from 'src/engine/core-modules/auth/types/raw-auth-context.type';
export type WorkspaceAuthContextType =
| 'system'
@@ -1,4 +1,4 @@
import { type RawAuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { type RawAuthContext } from 'src/engine/core-modules/auth/types/raw-auth-context.type';
import { type ApiKeyWorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
type ApiKeyAuthContextInput = {
@@ -1,4 +1,4 @@
import { type RawAuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { type RawAuthContext } from 'src/engine/core-modules/auth/types/raw-auth-context.type';
import { type ApplicationWorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
type ApplicationAuthContextInput = {
@@ -1,4 +1,4 @@
import { type RawAuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { type RawAuthContext } from 'src/engine/core-modules/auth/types/raw-auth-context.type';
import { type PendingActivationUserWorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
type PendingActivationUserAuthContextInput = {
@@ -1,4 +1,4 @@
import { type RawAuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { type RawAuthContext } from 'src/engine/core-modules/auth/types/raw-auth-context.type';
import { type SystemWorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
type SystemAuthContextInput = {
@@ -1,4 +1,4 @@
import { type RawAuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { type RawAuthContext } from 'src/engine/core-modules/auth/types/raw-auth-context.type';
import { type UserWorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
type UserAuthContextInput = {
@@ -3,10 +3,8 @@ import { Injectable } from '@nestjs/common';
import { FileFolder } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
FileTokenJwtPayload,
JwtTokenTypeEnum,
} from 'src/engine/core-modules/auth/types/auth-context.type';
import { FileTokenJwtPayload } from 'src/engine/core-modules/auth/types/file-token-jwt-payload.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -4,7 +4,7 @@ import { FileFolder } from 'twenty-shared/types';
import { fileFolderConfigs } from 'src/engine/core-modules/file/interfaces/file-folder.interface';
import { FileTokenJwtPayload } from 'src/engine/core-modules/auth/types/auth-context.type';
import { FileTokenJwtPayload } from 'src/engine/core-modules/auth/types/file-token-jwt-payload.type';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
export const SUPPORTED_FILE_FOLDERS = [
@@ -4,7 +4,7 @@ import {
Injectable,
} from '@nestjs/common';
import { FileTokenJwtPayloadLegacy } from 'src/engine/core-modules/auth/types/auth-context.type';
import { FileTokenJwtPayloadLegacy } from 'src/engine/core-modules/auth/types/file-token-jwt-payload-legacy.type';
import { extractFileInfoFromRequest } from 'src/engine/core-modules/file/utils/extract-file-info-from-request.utils';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
@@ -12,10 +12,8 @@ import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import {
type JwtPayload,
JwtTokenTypeEnum,
} from 'src/engine/core-modules/auth/types/auth-context.type';
import { type JwtPayload } from 'src/engine/core-modules/auth/types/jwt-payload.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import {
JWT_ASYMMETRIC_ALGORITHM,
JWT_LEGACY_ALGORITHM,
@@ -16,10 +16,8 @@ import {
} from 'src/engine/core-modules/code-interpreter/drivers/interfaces/code-interpreter-driver.interface';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import {
type AccessTokenJwtPayload,
JwtTokenTypeEnum,
} from 'src/engine/core-modules/auth/types/auth-context.type';
import { type AccessTokenJwtPayload } from 'src/engine/core-modules/auth/types/access-token-jwt-payload.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { CodeInterpreterService } from 'src/engine/core-modules/code-interpreter/code-interpreter.service';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
@@ -365,6 +365,16 @@ export class ConfigVariables {
@IsOptional()
APPLICATION_REFRESH_TOKEN_EXPIRES_IN = '60d';
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.TOKENS_DURATION,
description:
'Duration for which a playground token (in-app REST/GraphQL playground bearer) is valid',
type: ConfigVariableType.STRING,
})
@IsDuration()
@IsOptional()
PLAYGROUND_TOKEN_EXPIRES_IN = '2h';
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.EMAIL_SETTINGS,
description: 'Email address used as the sender for outgoing emails',
@@ -2,7 +2,7 @@ import { Test, type TestingModule } from '@nestjs/testing';
import { createCipheriv, createHash, randomBytes } from 'crypto';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { SimpleSecretEncryptionUtil } from './simple-secret-encryption.util';
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { createDecipheriv, createHash } from 'crypto';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type';