Twenty sdk cli oauth (#18638)

<img width="1418" height="804" alt="image"
src="https://github.com/user-attachments/assets/de6c8222-6496-4a71-bc21-7e5e1269d5cb"
/>

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
martmull
2026-03-17 11:43:17 +01:00
committed by GitHub
co-authored by Félix Malfait Félix Malfait
parent 111debc1ce
commit 731e297147
96 changed files with 3191 additions and 2453 deletions
@@ -35,6 +35,8 @@ import { FileStorageService } from 'src/engine/core-modules/file-storage/file-st
import { FileDTO } from 'src/engine/core-modules/file/dtos/file.dto';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { DevelopmentGuard } from 'src/engine/guards/development.guard';
import {
@@ -109,10 +111,14 @@ export class ApplicationDevelopmentResolver {
async generateApplicationToken(
@Args() { applicationId }: GenerateApplicationTokenInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
@AuthUser({ allowUndefined: true }) user?: { id: string },
@AuthUserWorkspaceId() userWorkspaceId?: string,
): Promise<ApplicationTokenPairDTO> {
return this.applicationTokenService.generateApplicationTokenPair({
workspaceId,
applicationId,
userId: user?.id,
userWorkspaceId,
});
}
@@ -13,13 +13,13 @@ import { OAuthTokenController } from 'src/engine/core-modules/application/applic
import { OAuthService } from 'src/engine/core-modules/application/application-oauth/oauth.service';
import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.module';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { DomainServerConfigModule } from 'src/engine/core-modules/domain/domain-server-config/domain-server-config.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.module';
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { DomainServerConfigModule } from 'src/engine/core-modules/domain/domain-server-config/domain-server-config.module';
@Module({
imports: [
@@ -39,6 +39,7 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/
ThrottlerModule,
TwentyConfigModule,
WorkspaceCacheStorageModule,
DomainServerConfigModule,
],
controllers: [
OAuthTokenController,
@@ -1,24 +1,33 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { ALL_OAUTH_SCOPES } from 'src/engine/core-modules/application/application-oauth/constants/oauth-scopes';
import { DomainServerConfigService } from 'src/engine/core-modules/domain/domain-server-config/services/domain-server-config.service';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
import { DomainServerConfigService } from 'src/engine/core-modules/domain/domain-server-config/services/domain-server-config.service';
import { TWENTY_CLI_APPLICATION_REGISTRATION } from 'src/engine/workspace-manager/twenty-standard-application/constants/twenty-cli-application-registration.constant';
@Controller('.well-known')
export class OAuthDiscoveryController {
constructor(
private readonly twentyConfigService: TwentyConfigService,
private readonly domainServerConfigService: DomainServerConfigService,
private readonly applicationRegistrationService: ApplicationRegistrationService,
) {}
@Get('oauth-authorization-server')
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
getAuthorizationServerMetadata() {
async getAuthorizationServerMetadata() {
const serverUrl = this.twentyConfigService.get('SERVER_URL');
const frontUrl = this.domainServerConfigService.getFrontUrl().toString();
const cliRegistration =
await this.applicationRegistrationService.findOneByUniversalIdentifier(
TWENTY_CLI_APPLICATION_REGISTRATION.universalIdentifier,
);
return {
issuer: serverUrl,
authorization_endpoint: `${frontUrl.replace(/\/$/, '')}/authorize`,
@@ -37,6 +46,9 @@ export class OAuthDiscoveryController {
token_endpoint_auth_methods_supported: ['client_secret_post', 'none'],
revocation_endpoint_auth_methods_supported: ['client_secret_post'],
introspection_endpoint_auth_methods_supported: ['client_secret_post'],
...(cliRegistration
? { cli_client_id: cliRegistration.oAuthClientId }
: {}),
};
}
@@ -10,6 +10,7 @@ import { v4 } from 'uuid';
import { ALL_OAUTH_SCOPES } from 'src/engine/core-modules/application/application-oauth/constants/oauth-scopes';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { TWENTY_CLI_APPLICATION_REGISTRATION } from 'src/engine/workspace-manager/twenty-standard-application/constants/twenty-cli-application-registration.constant';
import {
ApplicationRegistrationException,
ApplicationRegistrationExceptionCode,
@@ -327,12 +328,30 @@ export class ApplicationRegistrationService {
await this.applicationRegistrationRepository.save(registration);
}
async findManyBySourceType(
sourceType: ApplicationRegistrationSourceType,
): Promise<ApplicationRegistrationEntity[]> {
return this.applicationRegistrationRepository.find({
where: { sourceType },
async createCliRegistrationIfNotExists(): Promise<ApplicationRegistrationEntity | null> {
const existing = await this.findOneByUniversalIdentifier(
TWENTY_CLI_APPLICATION_REGISTRATION.universalIdentifier,
);
if (isDefined(existing)) {
return null;
}
const registration = this.applicationRegistrationRepository.create({
universalIdentifier:
TWENTY_CLI_APPLICATION_REGISTRATION.universalIdentifier,
name: TWENTY_CLI_APPLICATION_REGISTRATION.name,
description: TWENTY_CLI_APPLICATION_REGISTRATION.description,
oAuthClientId: v4(),
oAuthClientSecretHash: null,
oAuthRedirectUris: [],
oAuthScopes: TWENTY_CLI_APPLICATION_REGISTRATION.oAuthScopes,
ownerWorkspaceId: null,
sourceType: ApplicationRegistrationSourceType.OAUTH_ONLY,
createdByUserId: null,
});
return this.applicationRegistrationRepository.save(registration);
}
async findManyListed(): Promise<ApplicationRegistrationEntity[]> {
@@ -519,15 +519,45 @@ export class AuthService {
);
}
if (
!applicationRegistration.oAuthRedirectUris.includes(
authorizeAppInput.redirectUrl,
)
) {
throw new AuthException(
`redirectUrl mismatch for '${clientId}'`,
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
// RFC 8252 §7.3: Native apps using loopback redirect URIs may use any port.
// When a registration has no explicit redirect URIs (e.g. the seeded CLI registration),
// allow any loopback redirect URI.
const hasRegisteredRedirectUris =
applicationRegistration.oAuthRedirectUris.length > 0;
if (hasRegisteredRedirectUris) {
if (
!applicationRegistration.oAuthRedirectUris.includes(
authorizeAppInput.redirectUrl,
)
) {
throw new AuthException(
`redirectUrl mismatch for '${clientId}'`,
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
} else {
let redirectUrl: URL;
try {
redirectUrl = new URL(authorizeAppInput.redirectUrl);
} catch {
throw new AuthException(
`Invalid redirectUrl for '${clientId}'`,
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
const isLoopback =
redirectUrl.hostname === 'localhost' ||
redirectUrl.hostname === '127.0.0.1';
if (!isLoopback) {
throw new AuthException(
`redirectUrl mismatch for '${clientId}'`,
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
}
// Validate requested scopes are a subset of the registration's allowed scopes
@@ -76,9 +76,6 @@ const createSignInUpServiceForTests = () => {
{
emitCustomBatchEvent: jest.fn(),
} as any,
{
getHttpClient: jest.fn(),
} as any,
mockTwentyConfigService as any,
{
generateSubdomain: jest.fn(),
@@ -33,7 +33,6 @@ import { FileCorePictureService } from 'src/engine/core-modules/file/file-core-p
import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service';
import { MetricsKeys } from 'src/engine/core-modules/metrics/types/metrics-keys.type';
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
import { TelemetryEventType } from 'src/engine/core-modules/telemetry/telemetry-event.type';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
@@ -59,7 +58,6 @@ export class SignInUpService {
private readonly userWorkspaceService: UserWorkspaceService,
private readonly onboardingService: OnboardingService,
private readonly workspaceEventEmitter: WorkspaceEventEmitter,
private readonly secureHttpClientService: SecureHttpClientService,
private readonly twentyConfigService: TwentyConfigService,
private readonly subdomainManagerService: SubdomainManagerService,
private readonly userService: UserService,