OAuth Client — Unified ApplicationRegistration, OAuth server, and frontend (#18267)
## Summary Consolidates three separate PRs (#18260, #18261, #18262) into a single unified branch with all review feedback addressed: ### New features - **ApplicationRegistration entity** — server-level registration for OAuth apps with encrypted server variables - **OAuth 2.0 server** — authorization code, client credentials, refresh token grants with PKCE support - **OAuth discovery endpoint** — `.well-known/oauth-authorization-server` metadata - **Frontend UI** — app registration details page with credential management, redirect URI editing, and server variable configuration - **CLI integration** — `twenty dev` auto-registers apps and stores OAuth credentials locally - **Authorize consent screen** — OAuth consent page at `/authorize` showing requested scopes ### Review feedback addressed **Renames (PR #18260):** - `appRegistration` → `applicationRegistration` (entity, tables, files, imports, GraphQL types) - `appRegistrationVariable` → `applicationRegistrationVariable` - `clientId` → `oAuthClientId`, `clientSecretHash` → `oAuthClientSecretHash`, `redirectUris` → `oAuthRedirectUris`, `scopes` → `oAuthScopes` **Security fixes (PR #18261):** - Fixed redirect URI validation bypass when `oAuthRedirectUris` is an empty array - Fixed workspace isolation in `clientCredentialsGrant` — now uses `find()` with explicit handling for multiple installations - Added error logging in refresh token `catch` block instead of silently swallowing **Code quality (PR #18262):** - Split `VersionDistributionEntry` into its own file (one export per file) - Split GraphQL queries and mutations into individual files with a shared fragment - Removed unused `OAuth` entry from `AuthProviderEnum` - Added loading state to `handleRotateSecret` - Removed 27 narration-style comments from test files - Added proper guards (`PublicEndpointGuard`, `NoPermissionGuard`) to controllers and resolvers ## Test plan - [ ] Verify `twenty dev` registers an app and stores OAuth credentials - [ ] Test OAuth authorization code flow end-to-end (authorize → token → API call) - [ ] Test client credentials grant - [ ] Verify redirect URI validation rejects requests when no URIs are registered - [ ] Verify app registration detail page renders correctly - [ ] Test secret rotation with loading state - [ ] Verify server variable editing and saving - [ ] Run `npx nx database:reset twenty-server` to validate migration Closes #18260, #18261, #18262 Made with [Cursor](https://cursor.com) --------- Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
This commit is contained in:
@@ -10,11 +10,11 @@ import { type ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entit
|
||||
import { BillingCheckoutSessionInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-checkout-session.input';
|
||||
import { BillingSessionInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-session.input';
|
||||
import { BillingUpdateSubscriptionItemPriceInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-update-subscription-item-price.input';
|
||||
import { BillingEndTrialPeriodOutput } from 'src/engine/core-modules/billing/dtos/outputs/billing-end-trial-period.output';
|
||||
import { BillingMeteredProductUsageOutput } from 'src/engine/core-modules/billing/dtos/outputs/billing-metered-product-usage.output';
|
||||
import { BillingPlanOutput } from 'src/engine/core-modules/billing/dtos/outputs/billing-plan.output';
|
||||
import { BillingSessionOutput } from 'src/engine/core-modules/billing/dtos/outputs/billing-session.output';
|
||||
import { BillingUpdateOutput } from 'src/engine/core-modules/billing/dtos/outputs/billing-update.output';
|
||||
import { BillingEndTrialPeriodDTO } from 'src/engine/core-modules/billing/dtos/billing-end-trial-period.dto';
|
||||
import { BillingMeteredProductUsageDTO } from 'src/engine/core-modules/billing/dtos/billing-metered-product-usage.dto';
|
||||
import { BillingPlanDTO } from 'src/engine/core-modules/billing/dtos/billing-plan.dto';
|
||||
import { BillingSessionDTO } from 'src/engine/core-modules/billing/dtos/billing-session.dto';
|
||||
import { BillingUpdateDTO } from 'src/engine/core-modules/billing/dtos/billing-update.dto';
|
||||
import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
|
||||
import { BillingPlanService } from 'src/engine/core-modules/billing/services/billing-plan.service';
|
||||
import { BillingPortalWorkspaceService } from 'src/engine/core-modules/billing/services/billing-portal.workspace-service';
|
||||
@@ -65,7 +65,7 @@ export class BillingResolver {
|
||||
private readonly permissionsService: PermissionsService,
|
||||
) {}
|
||||
|
||||
@Query(() => BillingSessionOutput)
|
||||
@Query(() => BillingSessionDTO)
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.BILLING),
|
||||
@@ -82,7 +82,7 @@ export class BillingResolver {
|
||||
};
|
||||
}
|
||||
|
||||
@Mutation(() => BillingSessionOutput)
|
||||
@Mutation(() => BillingSessionDTO)
|
||||
@UseGuards(WorkspaceAuthGuard, UserAuthGuard, NoPermissionGuard)
|
||||
async checkoutSession(
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
@@ -143,7 +143,7 @@ export class BillingResolver {
|
||||
}
|
||||
}
|
||||
|
||||
@Mutation(() => BillingUpdateOutput)
|
||||
@Mutation(() => BillingUpdateDTO)
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.BILLING),
|
||||
@@ -165,7 +165,7 @@ export class BillingResolver {
|
||||
};
|
||||
}
|
||||
|
||||
@Mutation(() => BillingUpdateOutput)
|
||||
@Mutation(() => BillingUpdateDTO)
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.BILLING),
|
||||
@@ -185,7 +185,7 @@ export class BillingResolver {
|
||||
};
|
||||
}
|
||||
|
||||
@Mutation(() => BillingUpdateOutput)
|
||||
@Mutation(() => BillingUpdateDTO)
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.BILLING),
|
||||
@@ -205,7 +205,7 @@ export class BillingResolver {
|
||||
};
|
||||
}
|
||||
|
||||
@Mutation(() => BillingUpdateOutput)
|
||||
@Mutation(() => BillingUpdateDTO)
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.BILLING),
|
||||
@@ -229,7 +229,7 @@ export class BillingResolver {
|
||||
};
|
||||
}
|
||||
|
||||
@Mutation(() => BillingUpdateOutput)
|
||||
@Mutation(() => BillingUpdateDTO)
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.BILLING),
|
||||
@@ -255,22 +255,22 @@ export class BillingResolver {
|
||||
};
|
||||
}
|
||||
|
||||
@Query(() => [BillingPlanOutput])
|
||||
@Query(() => [BillingPlanDTO])
|
||||
@UseGuards(WorkspaceAuthGuard, NoPermissionGuard)
|
||||
async listPlans(): Promise<BillingPlanOutput[]> {
|
||||
async listPlans(): Promise<BillingPlanDTO[]> {
|
||||
const plans = await this.billingPlanService.listPlans();
|
||||
|
||||
return plans.map(formatBillingDatabaseProductToGraphqlDTO);
|
||||
}
|
||||
|
||||
@Mutation(() => BillingEndTrialPeriodOutput)
|
||||
@Mutation(() => BillingEndTrialPeriodDTO)
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.BILLING),
|
||||
)
|
||||
async endSubscriptionTrialPeriod(
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
): Promise<BillingEndTrialPeriodOutput> {
|
||||
): Promise<BillingEndTrialPeriodDTO> {
|
||||
const result =
|
||||
await this.billingSubscriptionService.endTrialPeriod(workspace);
|
||||
|
||||
@@ -295,14 +295,14 @@ export class BillingResolver {
|
||||
};
|
||||
}
|
||||
|
||||
@Query(() => [BillingMeteredProductUsageOutput])
|
||||
@Query(() => [BillingMeteredProductUsageDTO])
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.BILLING),
|
||||
)
|
||||
async getMeteredProductsUsage(
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
): Promise<BillingMeteredProductUsageOutput[]> {
|
||||
): Promise<BillingMeteredProductUsageDTO[]> {
|
||||
const usageData =
|
||||
await this.billingUsageService.getMeteredProductsUsage(workspace);
|
||||
|
||||
@@ -316,7 +316,7 @@ export class BillingResolver {
|
||||
}));
|
||||
}
|
||||
|
||||
@Mutation(() => BillingUpdateOutput)
|
||||
@Mutation(() => BillingUpdateDTO)
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.BILLING),
|
||||
|
||||
+2
-2
@@ -4,8 +4,8 @@ import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
|
||||
|
||||
@ObjectType()
|
||||
export class BillingEndTrialPeriodOutput {
|
||||
@ObjectType('BillingEndTrialPeriod')
|
||||
export class BillingEndTrialPeriodDTO {
|
||||
@Field(() => SubscriptionStatus, {
|
||||
description: 'Updated subscription status',
|
||||
nullable: true,
|
||||
+2
-2
@@ -2,8 +2,8 @@ import { Field, Float, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
|
||||
|
||||
@ObjectType()
|
||||
export class BillingMeteredProductUsageOutput {
|
||||
@ObjectType('BillingMeteredProductUsage')
|
||||
export class BillingMeteredProductUsageDTO {
|
||||
@Field(() => BillingProductKey)
|
||||
productKey: BillingProductKey;
|
||||
|
||||
+2
-2
@@ -8,8 +8,8 @@ import {
|
||||
} from 'src/engine/core-modules/billing/dtos/billing-product.dto';
|
||||
import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
|
||||
|
||||
@ObjectType()
|
||||
export class BillingPlanOutput {
|
||||
@ObjectType('BillingPlan')
|
||||
export class BillingPlanDTO {
|
||||
@Field(() => BillingPlanKey)
|
||||
planKey: BillingPlanKey;
|
||||
|
||||
+2
-2
@@ -4,8 +4,8 @@ import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
|
||||
|
||||
@ObjectType()
|
||||
export class BillingPriceOutput {
|
||||
@ObjectType('BillingPrice')
|
||||
export class BillingPriceDTO {
|
||||
@Field(() => Number)
|
||||
upTo: number;
|
||||
|
||||
+2
-2
@@ -2,8 +2,8 @@
|
||||
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType()
|
||||
export class BillingSessionOutput {
|
||||
@ObjectType('BillingSession')
|
||||
export class BillingSessionDTO {
|
||||
@Field(() => String, { nullable: true })
|
||||
url: string;
|
||||
}
|
||||
+1
-1
@@ -7,7 +7,7 @@ import { IDField } from '@ptc-org/nestjs-query-graphql';
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { BillingProductDTO } from 'src/engine/core-modules/billing/dtos/billing-product.dto';
|
||||
|
||||
@ObjectType()
|
||||
@ObjectType('BillingSubscriptionItem')
|
||||
export class BillingSubscriptionItemDTO {
|
||||
@IDField(() => UUIDScalarType)
|
||||
id: string;
|
||||
+2
-2
@@ -4,8 +4,8 @@ import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
|
||||
|
||||
@ObjectType()
|
||||
export class BillingUpdateOutput {
|
||||
@ObjectType('BillingUpdate')
|
||||
export class BillingUpdateDTO {
|
||||
@Field(() => BillingSubscriptionEntity, {
|
||||
description: 'Current billing subscription',
|
||||
})
|
||||
+1
-1
@@ -38,7 +38,7 @@ export type CancellationDetailsJson = {
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { BillingSubscriptionSchedulePhaseDTO } from 'src/engine/core-modules/billing/dtos/billing-subscription-schedule-phase.dto';
|
||||
import { BillingSubscriptionItemDTO } from 'src/engine/core-modules/billing/dtos/outputs/billing-subscription-item.output';
|
||||
import { BillingSubscriptionItemDTO } from 'src/engine/core-modules/billing/dtos/billing-subscription-item.dto';
|
||||
import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
|
||||
import { BillingSubscriptionItemEntity } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
|
||||
import { BillingSubscriptionCollectionMethod } from 'src/engine/core-modules/billing/enums/billing-subscription-collection-method.enum';
|
||||
|
||||
+3
-3
@@ -10,7 +10,7 @@ import {
|
||||
BillingException,
|
||||
BillingExceptionCode,
|
||||
} from 'src/engine/core-modules/billing/billing.exception';
|
||||
import { type BillingMeteredProductUsageOutput } from 'src/engine/core-modules/billing/dtos/outputs/billing-metered-product-usage.output';
|
||||
import { type BillingMeteredProductUsageDTO } from 'src/engine/core-modules/billing/dtos/billing-metered-product-usage.dto';
|
||||
import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
|
||||
import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
|
||||
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
|
||||
@@ -90,7 +90,7 @@ export class BillingUsageService {
|
||||
|
||||
async getMeteredProductsUsage(
|
||||
workspace: WorkspaceEntity,
|
||||
): Promise<BillingMeteredProductUsageOutput[]> {
|
||||
): Promise<BillingMeteredProductUsageDTO[]> {
|
||||
const subscription =
|
||||
await this.billingSubscriptionService.getCurrentBillingSubscriptionOrThrow(
|
||||
{ workspaceId: workspace.id },
|
||||
@@ -146,7 +146,7 @@ export class BillingUsageService {
|
||||
>[number],
|
||||
periodStart: Date,
|
||||
periodEnd: Date,
|
||||
): Promise<BillingMeteredProductUsageOutput> {
|
||||
): Promise<BillingMeteredProductUsageDTO> {
|
||||
const meterEventsSum =
|
||||
await this.stripeBillingMeterEventService.sumMeterEvents(
|
||||
item.stripeMeterId,
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
import { type BillingPriceLicensedDTO } from 'src/engine/core-modules/billing/dtos/billing-price-licensed.dto';
|
||||
import { type BillingPriceMeteredDTO } from 'src/engine/core-modules/billing/dtos/billing-price-metered.dto';
|
||||
import { type BillingPlanOutput } from 'src/engine/core-modules/billing/dtos/outputs/billing-plan.output';
|
||||
import { type BillingPlanDTO } from 'src/engine/core-modules/billing/dtos/billing-plan.dto';
|
||||
import { type BillingPriceEntity } from 'src/engine/core-modules/billing/entities/billing-price.entity';
|
||||
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
|
||||
import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
|
||||
@@ -11,7 +11,7 @@ import { toDisplayCredits } from 'src/engine/core-modules/billing/utils/to-displ
|
||||
|
||||
export const formatBillingDatabaseProductToGraphqlDTO = (
|
||||
plan: BillingGetPlanResult,
|
||||
): BillingPlanOutput => {
|
||||
): BillingPlanDTO => {
|
||||
return {
|
||||
planKey: plan.planKey,
|
||||
licensedProducts: plan.licensedProducts.map((product) => {
|
||||
|
||||
Reference in New Issue
Block a user