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:
Félix Malfait
2026-02-28 14:07:49 +01:00
committed by GitHub
parent 9fe2a07c55
commit 012d819557
242 changed files with 5717 additions and 1100 deletions
@@ -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),
@@ -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,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;
@@ -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;
@@ -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,8 +2,8 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class BillingSessionOutput {
@ObjectType('BillingSession')
export class BillingSessionDTO {
@Field(() => String, { nullable: true })
url: string;
}
@@ -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;
@@ -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',
})
@@ -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';
@@ -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,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) => {