OAuth security hardening: RFC compliance, PKCE binding, rate limiting (#18305)
## Summary Follow-up to #18267. Hardens the OAuth implementation with security fixes identified during audit: **P0 — Critical:** - Bind authorization codes to `client_id` in context to prevent auth code injection (RFC 6749 §4.1.3) - Store PKCE `code_challenge` directly in auth code context instead of a separate `CodeChallenge` token — cryptographically binds the challenge to its code - Enforce `code_verifier` when `code_challenge` was used during authorization - Hash authorization codes (SHA-256) before storage to prevent exposure if DB is compromised - Add `Cache-Control: no-store` + `Pragma: no-cache` headers on token responses (RFC 6749 §5.1) - Add rate limiting on `/oauth/token` endpoint (20 req/min per client via existing `ThrottlerService`) **P1 — High:** - Return HTTP 401 for `invalid_client` errors instead of 400 (RFC 6749 §5.2) - Verify refresh tokens belong to the presenting client (cross-client token theft prevention) - Limit fields exposed by public `findApplicationRegistrationByClientId` query to only what the frontend needs (`id`, `name`, `logoUrl`, `websiteUrl`, `oAuthScopes`) - Require `API_KEYS_AND_WEBHOOKS` permission for `createApplicationRegistration` mutation **P2/P3 — Medium/Low:** - Add error handling and loading states to frontend Authorize page - Rename redirect URL param from `authorizationCode` to `code` (RFC standard) - Add unit tests for `validateRedirectUri` utility (8 test cases) ## Test plan - [ ] Existing OAuth integration tests updated for all changes (hashed codes, context-based PKCE, client binding, 401 status codes, cache headers) - [ ] New test: auth code rejected when presented by a different client - [ ] New test: refresh token rejected when presented by a different client - [ ] New test: `code_verifier` required when PKCE was used in authorization - [ ] New test: `Cache-Control: no-store` header present on responses - [ ] New unit tests for `validateRedirectUri` (HTTPS, localhost, fragments, invalid URIs) - [ ] Verify frontend authorize page shows errors gracefully Made with [Cursor](https://cursor.com)
This commit is contained in:
@@ -89,5 +89,11 @@ export class AppTokenEntity {
|
||||
}
|
||||
|
||||
@Column({ nullable: true, type: 'jsonb' })
|
||||
context: { email?: string; redirectUri?: string } | null;
|
||||
context: {
|
||||
email?: string;
|
||||
redirectUri?: string;
|
||||
clientId?: string;
|
||||
codeChallenge?: string;
|
||||
scope?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
+29
-5
@@ -27,7 +27,13 @@ export class ApplicationRegistrationVariableService {
|
||||
|
||||
async findVariables(
|
||||
applicationRegistrationId: string,
|
||||
workspaceId: string,
|
||||
): Promise<ApplicationRegistrationVariableEntity[]> {
|
||||
await this.assertRegistrationOwnedByWorkspace(
|
||||
applicationRegistrationId,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
return this.variableRepository.find({
|
||||
where: { applicationRegistrationId },
|
||||
order: { key: 'ASC' },
|
||||
@@ -36,8 +42,12 @@ export class ApplicationRegistrationVariableService {
|
||||
|
||||
async createVariable(
|
||||
input: CreateApplicationRegistrationVariableInput,
|
||||
workspaceId: string,
|
||||
): Promise<ApplicationRegistrationVariableEntity> {
|
||||
await this.assertRegistrationExists(input.applicationRegistrationId);
|
||||
await this.assertRegistrationOwnedByWorkspace(
|
||||
input.applicationRegistrationId,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
const encryptedValue = this.encryptionService.encrypt(input.value);
|
||||
|
||||
@@ -54,6 +64,7 @@ export class ApplicationRegistrationVariableService {
|
||||
|
||||
async updateVariable(
|
||||
input: UpdateApplicationRegistrationVariableInput,
|
||||
workspaceId: string,
|
||||
): Promise<ApplicationRegistrationVariableEntity> {
|
||||
const { id, update } = input;
|
||||
|
||||
@@ -68,6 +79,11 @@ export class ApplicationRegistrationVariableService {
|
||||
);
|
||||
}
|
||||
|
||||
await this.assertRegistrationOwnedByWorkspace(
|
||||
variable.applicationRegistrationId,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
const updateData: Record<string, unknown> = {};
|
||||
|
||||
if (isDefined(update.value)) {
|
||||
@@ -85,7 +101,7 @@ export class ApplicationRegistrationVariableService {
|
||||
return this.variableRepository.findOneOrFail({ where: { id } });
|
||||
}
|
||||
|
||||
async deleteVariable(id: string): Promise<boolean> {
|
||||
async deleteVariable(id: string, workspaceId: string): Promise<boolean> {
|
||||
const variable = await this.variableRepository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
@@ -97,6 +113,11 @@ export class ApplicationRegistrationVariableService {
|
||||
);
|
||||
}
|
||||
|
||||
await this.assertRegistrationOwnedByWorkspace(
|
||||
variable.applicationRegistrationId,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
await this.variableRepository.delete(id);
|
||||
|
||||
return true;
|
||||
@@ -150,14 +171,17 @@ export class ApplicationRegistrationVariableService {
|
||||
}
|
||||
}
|
||||
|
||||
private async assertRegistrationExists(id: string): Promise<void> {
|
||||
private async assertRegistrationOwnedByWorkspace(
|
||||
registrationId: string,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
const registration = await this.applicationRegistrationRepository.findOne({
|
||||
where: { id },
|
||||
where: { id: registrationId, workspaceId },
|
||||
});
|
||||
|
||||
if (!registration) {
|
||||
throw new ApplicationRegistrationException(
|
||||
`Application registration with id ${id} not found`,
|
||||
`Application registration with id ${registrationId} not found`,
|
||||
ApplicationRegistrationExceptionCode.APPLICATION_REGISTRATION_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
+9
@@ -18,6 +18,7 @@ import {
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application-registration/application-registration-variable.entity';
|
||||
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
@Entity({ name: 'applicationRegistration', schema: 'core' })
|
||||
@ObjectType('ApplicationRegistration')
|
||||
@@ -38,6 +39,7 @@ import { UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
},
|
||||
)
|
||||
@Index('IDX_APPLICATION_REGISTRATION_CREATED_BY_USER_ID', ['createdByUserId'])
|
||||
@Index('IDX_APPLICATION_REGISTRATION_WORKSPACE_ID', ['workspaceId'])
|
||||
export class ApplicationRegistrationEntity {
|
||||
@IDField(() => UUIDScalarType)
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
@@ -85,6 +87,13 @@ export class ApplicationRegistrationEntity {
|
||||
@JoinColumn({ name: 'createdByUserId' })
|
||||
createdByUser: Relation<UserEntity> | null;
|
||||
|
||||
@Column({ nullable: false, type: 'uuid' })
|
||||
workspaceId: string;
|
||||
|
||||
@ManyToOne(() => WorkspaceEntity, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'workspaceId' })
|
||||
workspace: Relation<WorkspaceEntity>;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@Column({ nullable: true, type: 'text' })
|
||||
websiteUrl: string | null;
|
||||
|
||||
+2
@@ -14,6 +14,7 @@ import { ApplicationEntity } from 'src/engine/core-modules/application/applicati
|
||||
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
|
||||
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
|
||||
import { SecretEncryptionModule } from 'src/engine/core-modules/secret-encryption/secret-encryption.module';
|
||||
import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.module';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
|
||||
@@ -28,6 +29,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
|
||||
]),
|
||||
SecretEncryptionModule,
|
||||
PermissionsModule,
|
||||
ThrottlerModule,
|
||||
TokenModule,
|
||||
ApplicationModule,
|
||||
],
|
||||
|
||||
+50
-17
@@ -10,6 +10,7 @@ import { ApplicationRegistrationService } from 'src/engine/core-modules/applicat
|
||||
import { ApplicationRegistrationStatsDTO } from 'src/engine/core-modules/application-registration/dtos/application-registration-stats.dto';
|
||||
import { CreateApplicationRegistrationInput } from 'src/engine/core-modules/application-registration/dtos/create-application-registration.input';
|
||||
import { CreateApplicationRegistrationDTO } from 'src/engine/core-modules/application-registration/dtos/create-application-registration.dto';
|
||||
import { PublicApplicationRegistrationDTO } from 'src/engine/core-modules/application-registration/dtos/public-application-registration.dto';
|
||||
import { CreateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application-registration/dtos/create-application-registration-variable.input';
|
||||
import { RotateClientSecretDTO } from 'src/engine/core-modules/application-registration/dtos/rotate-client-secret.dto';
|
||||
import { UpdateApplicationRegistrationInput } from 'src/engine/core-modules/application-registration/dtos/update-application-registration.input';
|
||||
@@ -18,8 +19,10 @@ import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filt
|
||||
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
|
||||
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
|
||||
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
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 { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
|
||||
@@ -38,11 +41,11 @@ export class ApplicationRegistrationResolver {
|
||||
) {}
|
||||
|
||||
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
|
||||
@Query(() => ApplicationRegistrationEntity, { nullable: true })
|
||||
@Query(() => PublicApplicationRegistrationDTO, { nullable: true })
|
||||
async findApplicationRegistrationByClientId(
|
||||
@Args('clientId') clientId: string,
|
||||
): Promise<ApplicationRegistrationEntity | null> {
|
||||
return this.applicationRegistrationService.findOneByClientId(clientId);
|
||||
): Promise<PublicApplicationRegistrationDTO | null> {
|
||||
return this.applicationRegistrationService.findPublicByClientId(clientId);
|
||||
}
|
||||
|
||||
@UseGuards(WorkspaceAuthGuard, NoPermissionGuard)
|
||||
@@ -60,10 +63,10 @@ export class ApplicationRegistrationResolver {
|
||||
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
|
||||
)
|
||||
@Query(() => [ApplicationRegistrationEntity])
|
||||
async findManyApplicationRegistrations(): Promise<
|
||||
ApplicationRegistrationEntity[]
|
||||
> {
|
||||
return this.applicationRegistrationService.findMany();
|
||||
async findManyApplicationRegistrations(
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
): Promise<ApplicationRegistrationEntity[]> {
|
||||
return this.applicationRegistrationService.findMany(workspaceId);
|
||||
}
|
||||
|
||||
@UseGuards(
|
||||
@@ -73,8 +76,9 @@ export class ApplicationRegistrationResolver {
|
||||
@Query(() => ApplicationRegistrationEntity)
|
||||
async findOneApplicationRegistration(
|
||||
@Args('id') id: string,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
): Promise<ApplicationRegistrationEntity> {
|
||||
return this.applicationRegistrationService.findOneById(id);
|
||||
return this.applicationRegistrationService.findOneById(id, workspaceId);
|
||||
}
|
||||
|
||||
@UseGuards(
|
||||
@@ -84,17 +88,26 @@ export class ApplicationRegistrationResolver {
|
||||
@Query(() => ApplicationRegistrationStatsDTO)
|
||||
async findApplicationRegistrationStats(
|
||||
@Args('id') id: string,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
): Promise<ApplicationRegistrationStatsDTO> {
|
||||
return this.applicationRegistrationService.getStats(id);
|
||||
return this.applicationRegistrationService.getStats(id, workspaceId);
|
||||
}
|
||||
|
||||
@UseGuards(WorkspaceAuthGuard, NoPermissionGuard)
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
|
||||
)
|
||||
@Mutation(() => CreateApplicationRegistrationDTO)
|
||||
async createApplicationRegistration(
|
||||
@Args('input') input: CreateApplicationRegistrationInput,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
@AuthUser({ allowUndefined: true }) user: UserEntity | undefined,
|
||||
): Promise<CreateApplicationRegistrationDTO> {
|
||||
return this.applicationRegistrationService.create(input, user?.id ?? null);
|
||||
return this.applicationRegistrationService.create(
|
||||
input,
|
||||
workspaceId,
|
||||
user?.id ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
@UseGuards(
|
||||
@@ -104,8 +117,9 @@ export class ApplicationRegistrationResolver {
|
||||
@Mutation(() => ApplicationRegistrationEntity)
|
||||
async updateApplicationRegistration(
|
||||
@Args('input') input: UpdateApplicationRegistrationInput,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
): Promise<ApplicationRegistrationEntity> {
|
||||
return this.applicationRegistrationService.update(input);
|
||||
return this.applicationRegistrationService.update(input, workspaceId);
|
||||
}
|
||||
|
||||
@UseGuards(
|
||||
@@ -115,8 +129,9 @@ export class ApplicationRegistrationResolver {
|
||||
@Mutation(() => Boolean)
|
||||
async deleteApplicationRegistration(
|
||||
@Args('id') id: string,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
): Promise<boolean> {
|
||||
return this.applicationRegistrationService.delete(id);
|
||||
return this.applicationRegistrationService.delete(id, workspaceId);
|
||||
}
|
||||
|
||||
@UseGuards(
|
||||
@@ -126,9 +141,13 @@ export class ApplicationRegistrationResolver {
|
||||
@Mutation(() => RotateClientSecretDTO)
|
||||
async rotateApplicationRegistrationClientSecret(
|
||||
@Args('id') id: string,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
): Promise<RotateClientSecretDTO> {
|
||||
const clientSecret =
|
||||
await this.applicationRegistrationService.rotateClientSecret(id);
|
||||
await this.applicationRegistrationService.rotateClientSecret(
|
||||
id,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
return { clientSecret };
|
||||
}
|
||||
@@ -140,9 +159,11 @@ export class ApplicationRegistrationResolver {
|
||||
@Query(() => [ApplicationRegistrationVariableEntity])
|
||||
async findApplicationRegistrationVariables(
|
||||
@Args('applicationRegistrationId') applicationRegistrationId: string,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
): Promise<ApplicationRegistrationVariableEntity[]> {
|
||||
return this.applicationRegistrationVariableService.findVariables(
|
||||
applicationRegistrationId,
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -153,8 +174,12 @@ export class ApplicationRegistrationResolver {
|
||||
@Mutation(() => ApplicationRegistrationVariableEntity)
|
||||
async createApplicationRegistrationVariable(
|
||||
@Args('input') input: CreateApplicationRegistrationVariableInput,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
): Promise<ApplicationRegistrationVariableEntity> {
|
||||
return this.applicationRegistrationVariableService.createVariable(input);
|
||||
return this.applicationRegistrationVariableService.createVariable(
|
||||
input,
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
@UseGuards(
|
||||
@@ -164,8 +189,12 @@ export class ApplicationRegistrationResolver {
|
||||
@Mutation(() => ApplicationRegistrationVariableEntity)
|
||||
async updateApplicationRegistrationVariable(
|
||||
@Args('input') input: UpdateApplicationRegistrationVariableInput,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
): Promise<ApplicationRegistrationVariableEntity> {
|
||||
return this.applicationRegistrationVariableService.updateVariable(input);
|
||||
return this.applicationRegistrationVariableService.updateVariable(
|
||||
input,
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
@UseGuards(
|
||||
@@ -175,7 +204,11 @@ export class ApplicationRegistrationResolver {
|
||||
@Mutation(() => Boolean)
|
||||
async deleteApplicationRegistrationVariable(
|
||||
@Args('id') id: string,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
): Promise<boolean> {
|
||||
return this.applicationRegistrationVariableService.deleteVariable(id);
|
||||
return this.applicationRegistrationVariableService.deleteVariable(
|
||||
id,
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+54
-10
@@ -16,6 +16,7 @@ import {
|
||||
import { ALL_OAUTH_SCOPES } from 'src/engine/core-modules/application-registration/constants/oauth-scopes';
|
||||
import { type ApplicationRegistrationStatsDTO } from 'src/engine/core-modules/application-registration/dtos/application-registration-stats.dto';
|
||||
import { type CreateApplicationRegistrationInput } from 'src/engine/core-modules/application-registration/dtos/create-application-registration.input';
|
||||
import { type PublicApplicationRegistrationDTO } from 'src/engine/core-modules/application-registration/dtos/public-application-registration.dto';
|
||||
import { type UpdateApplicationRegistrationInput } from 'src/engine/core-modules/application-registration/dtos/update-application-registration.input';
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
import { validateRedirectUri } from 'src/engine/core-modules/auth/utils/validate-redirect-uri.util';
|
||||
@@ -31,15 +32,21 @@ export class ApplicationRegistrationService {
|
||||
private readonly applicationRepository: Repository<ApplicationEntity>,
|
||||
) {}
|
||||
|
||||
async findMany(): Promise<ApplicationRegistrationEntity[]> {
|
||||
async findMany(
|
||||
workspaceId: string,
|
||||
): Promise<ApplicationRegistrationEntity[]> {
|
||||
return this.applicationRegistrationRepository.find({
|
||||
where: { workspaceId },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findOneById(id: string): Promise<ApplicationRegistrationEntity> {
|
||||
async findOneById(
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
): Promise<ApplicationRegistrationEntity> {
|
||||
const registration = await this.applicationRegistrationRepository.findOne({
|
||||
where: { id },
|
||||
where: { id, workspaceId },
|
||||
});
|
||||
|
||||
if (!registration) {
|
||||
@@ -52,6 +59,7 @@ export class ApplicationRegistrationService {
|
||||
return registration;
|
||||
}
|
||||
|
||||
// Global lookup — used by OAuth flow (no workspace scoping)
|
||||
async findOneByClientId(
|
||||
clientId: string,
|
||||
): Promise<ApplicationRegistrationEntity | null> {
|
||||
@@ -60,6 +68,38 @@ export class ApplicationRegistrationService {
|
||||
});
|
||||
}
|
||||
|
||||
// Global lookup — used by OAuth authorize page (no workspace scoping)
|
||||
async findPublicByClientId(
|
||||
clientId: string,
|
||||
): Promise<PublicApplicationRegistrationDTO | null> {
|
||||
const registration = await this.applicationRegistrationRepository.findOne({
|
||||
where: { oAuthClientId: clientId },
|
||||
select: ['id', 'name', 'logoUrl', 'websiteUrl', 'oAuthScopes'],
|
||||
});
|
||||
|
||||
if (!registration) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: registration.id,
|
||||
name: registration.name,
|
||||
logoUrl: registration.logoUrl,
|
||||
websiteUrl: registration.websiteUrl,
|
||||
oAuthScopes: registration.oAuthScopes,
|
||||
};
|
||||
}
|
||||
|
||||
async isOwnedByWorkspace(id: string, workspaceId: string): Promise<boolean> {
|
||||
const registration = await this.applicationRegistrationRepository.findOne({
|
||||
where: { id },
|
||||
select: ['id', 'workspaceId'],
|
||||
});
|
||||
|
||||
return registration?.workspaceId === workspaceId;
|
||||
}
|
||||
|
||||
// Global lookup — used by app sync to find existing registrations
|
||||
async findOneByUniversalIdentifier(
|
||||
universalIdentifier: string,
|
||||
): Promise<ApplicationRegistrationEntity | null> {
|
||||
@@ -70,6 +110,7 @@ export class ApplicationRegistrationService {
|
||||
|
||||
async create(
|
||||
input: CreateApplicationRegistrationInput,
|
||||
workspaceId: string,
|
||||
createdByUserId: string | null,
|
||||
): Promise<{
|
||||
applicationRegistration: ApplicationRegistrationEntity;
|
||||
@@ -111,6 +152,7 @@ export class ApplicationRegistrationService {
|
||||
oAuthRedirectUris: input.oAuthRedirectUris ?? [],
|
||||
oAuthScopes: input.oAuthScopes ?? [],
|
||||
createdByUserId,
|
||||
workspaceId,
|
||||
websiteUrl: input.websiteUrl ?? null,
|
||||
termsUrl: input.termsUrl ?? null,
|
||||
});
|
||||
@@ -124,10 +166,11 @@ export class ApplicationRegistrationService {
|
||||
|
||||
async update(
|
||||
input: UpdateApplicationRegistrationInput,
|
||||
workspaceId: string,
|
||||
): Promise<ApplicationRegistrationEntity> {
|
||||
const { id, update } = input;
|
||||
|
||||
await this.findOneById(id);
|
||||
await this.findOneById(id, workspaceId);
|
||||
|
||||
if (isDefined(update.oAuthRedirectUris)) {
|
||||
this.validateRedirectUris(update.oAuthRedirectUris);
|
||||
@@ -155,18 +198,18 @@ export class ApplicationRegistrationService {
|
||||
await this.applicationRegistrationRepository.update(id, updateData);
|
||||
}
|
||||
|
||||
return this.findOneById(id);
|
||||
return this.findOneById(id, workspaceId);
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<boolean> {
|
||||
await this.findOneById(id);
|
||||
async delete(id: string, workspaceId: string): Promise<boolean> {
|
||||
await this.findOneById(id, workspaceId);
|
||||
await this.applicationRegistrationRepository.softDelete(id);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async rotateClientSecret(id: string): Promise<string> {
|
||||
await this.findOneById(id);
|
||||
async rotateClientSecret(id: string, workspaceId: string): Promise<string> {
|
||||
await this.findOneById(id, workspaceId);
|
||||
|
||||
const { clientSecret, clientSecretHash } =
|
||||
await this.generateClientSecret();
|
||||
@@ -191,8 +234,9 @@ export class ApplicationRegistrationService {
|
||||
|
||||
async getStats(
|
||||
applicationRegistrationId: string,
|
||||
workspaceId: string,
|
||||
): Promise<ApplicationRegistrationStatsDTO> {
|
||||
await this.findOneById(applicationRegistrationId);
|
||||
await this.findOneById(applicationRegistrationId, workspaceId);
|
||||
|
||||
const versionDistribution: { version: string; count: number }[] =
|
||||
await this.applicationRepository
|
||||
|
||||
+4
@@ -18,6 +18,8 @@ export class OAuthDiscoveryController {
|
||||
issuer: serverUrl,
|
||||
authorization_endpoint: `${serverUrl}/authorize`,
|
||||
token_endpoint: `${serverUrl}/oauth/token`,
|
||||
revocation_endpoint: `${serverUrl}/oauth/revoke`,
|
||||
introspection_endpoint: `${serverUrl}/oauth/introspect`,
|
||||
scopes_supported: ALL_OAUTH_SCOPES,
|
||||
response_types_supported: ['code'],
|
||||
grant_types_supported: [
|
||||
@@ -27,6 +29,8 @@ export class OAuthDiscoveryController {
|
||||
],
|
||||
code_challenge_methods_supported: ['S256'],
|
||||
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'],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+106
-3
@@ -1,7 +1,9 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
HttpCode,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
UseFilters,
|
||||
UseGuards,
|
||||
@@ -9,28 +11,42 @@ import {
|
||||
ValidationPipe,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import { type Response } from 'express';
|
||||
import { type Request, type Response } from 'express';
|
||||
|
||||
import { OAuthIntrospectInput } from 'src/engine/core-modules/application-registration/dtos/oauth-introspect.input';
|
||||
import { OAuthRevokeInput } from 'src/engine/core-modules/application-registration/dtos/oauth-revoke.input';
|
||||
import { OAuthTokenInput } from 'src/engine/core-modules/application-registration/dtos/oauth-token.input';
|
||||
import { OAuthService } from 'src/engine/core-modules/application-registration/oauth.service';
|
||||
import { OAuthErrorResponse } from 'src/engine/core-modules/application-registration/types/oauth-error-response.type';
|
||||
import { OAuthTokenResponse } from 'src/engine/core-modules/application-registration/types/oauth-token-response.type';
|
||||
import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter';
|
||||
import { ThrottlerException } from 'src/engine/core-modules/throttler/throttler.exception';
|
||||
import { ThrottlerService } from 'src/engine/core-modules/throttler/throttler.service';
|
||||
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
|
||||
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
|
||||
|
||||
const OAUTH_RATE_LIMIT_MAX = 60;
|
||||
const OAUTH_RATE_LIMIT_WINDOW_MS = 60_000;
|
||||
|
||||
@Controller('oauth')
|
||||
@UseFilters(AuthRestApiExceptionFilter)
|
||||
export class OAuthTokenController {
|
||||
constructor(private readonly oauthService: OAuthService) {}
|
||||
constructor(
|
||||
private readonly oauthService: OAuthService,
|
||||
private readonly throttlerService: ThrottlerService,
|
||||
) {}
|
||||
|
||||
@Post('token')
|
||||
@HttpCode(200)
|
||||
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
|
||||
@UsePipes(new ValidationPipe())
|
||||
async token(
|
||||
@Body() body: OAuthTokenInput,
|
||||
@Req() req: Request,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
if (await this.applyRateLimit(req, res)) return;
|
||||
|
||||
let result: OAuthTokenResponse | OAuthErrorResponse;
|
||||
|
||||
switch (body.grant_type) {
|
||||
@@ -68,8 +84,95 @@ export class OAuthTokenController {
|
||||
break;
|
||||
}
|
||||
|
||||
res.status('error' in result ? 400 : 200);
|
||||
this.setSecurityHeaders(res);
|
||||
|
||||
if ('error' in result) {
|
||||
const statusCode = result.error === 'invalid_client' ? 401 : 400;
|
||||
|
||||
res.status(statusCode);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post('revoke')
|
||||
@HttpCode(200)
|
||||
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
|
||||
@UsePipes(new ValidationPipe())
|
||||
async revoke(
|
||||
@Body() body: OAuthRevokeInput,
|
||||
@Req() req: Request,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
if (await this.applyRateLimit(req, res)) return;
|
||||
this.setSecurityHeaders(res);
|
||||
|
||||
await this.oauthService.revokeToken({
|
||||
token: body.token,
|
||||
clientId: body.client_id,
|
||||
clientSecret: body.client_secret,
|
||||
});
|
||||
|
||||
// RFC 7009 §2.2: always return 200, even for invalid tokens
|
||||
return {};
|
||||
}
|
||||
|
||||
@Post('introspect')
|
||||
@HttpCode(200)
|
||||
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
|
||||
@UsePipes(new ValidationPipe())
|
||||
async introspect(
|
||||
@Body() body: OAuthIntrospectInput,
|
||||
@Req() req: Request,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
if (await this.applyRateLimit(req, res)) return;
|
||||
this.setSecurityHeaders(res);
|
||||
|
||||
if (!body.client_id) {
|
||||
res.status(401);
|
||||
|
||||
return {
|
||||
error: 'invalid_client',
|
||||
error_description: 'client_id is required',
|
||||
};
|
||||
}
|
||||
|
||||
return this.oauthService.introspectToken({
|
||||
token: body.token,
|
||||
clientId: body.client_id,
|
||||
clientSecret: body.client_secret,
|
||||
});
|
||||
}
|
||||
|
||||
private async applyRateLimit(req: Request, res: Response): Promise<boolean> {
|
||||
const rateLimitKey = `oauth:${req.ip}`;
|
||||
|
||||
try {
|
||||
await this.throttlerService.tokenBucketThrottleOrThrow(
|
||||
rateLimitKey,
|
||||
1,
|
||||
OAUTH_RATE_LIMIT_MAX,
|
||||
OAUTH_RATE_LIMIT_WINDOW_MS,
|
||||
);
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
if (error instanceof ThrottlerException) {
|
||||
res.status(429).json({
|
||||
error: 'rate_limit_exceeded',
|
||||
error_description: 'Too many requests, please try again later',
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private setSecurityHeaders(res: Response): void {
|
||||
res.set('Cache-Control', 'no-store');
|
||||
res.set('Pragma', 'no-cache');
|
||||
}
|
||||
}
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class OAuthIntrospectInput {
|
||||
@IsString()
|
||||
@MaxLength(4096)
|
||||
token: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
token_type_hint?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(256)
|
||||
client_id?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(512)
|
||||
client_secret?: string;
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class OAuthRevokeInput {
|
||||
@IsString()
|
||||
@MaxLength(4096)
|
||||
token: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
token_type_hint?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(256)
|
||||
client_id?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(512)
|
||||
client_secret?: string;
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
|
||||
@ObjectType('PublicApplicationRegistration')
|
||||
export class PublicApplicationRegistrationDTO {
|
||||
@Field(() => UUIDScalarType)
|
||||
id: string;
|
||||
|
||||
@Field()
|
||||
name: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
logoUrl: string | null;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
websiteUrl: string | null;
|
||||
|
||||
@Field(() => [String])
|
||||
oAuthScopes: string[];
|
||||
}
|
||||
+239
-46
@@ -4,7 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import crypto from 'crypto';
|
||||
|
||||
import ms from 'ms';
|
||||
import { IsNull, Repository } from 'typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { base64UrlEncode } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
@@ -79,11 +79,15 @@ export class OAuthService {
|
||||
}
|
||||
}
|
||||
|
||||
const hashedAuthorizationCode = crypto
|
||||
.createHash('sha256')
|
||||
.update(authorizationCode)
|
||||
.digest('hex');
|
||||
|
||||
const authCodeToken = await this.appTokenRepository.findOne({
|
||||
where: {
|
||||
value: authorizationCode,
|
||||
value: hashedAuthorizationCode,
|
||||
type: AppTokenType.AuthorizationCode,
|
||||
revokedAt: IsNull(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -94,10 +98,34 @@ export class OAuthService {
|
||||
);
|
||||
}
|
||||
|
||||
// RFC 6749 §4.1.2: if a previously used code is presented, this indicates
|
||||
// a potential compromise — log a security warning
|
||||
if (authCodeToken.revokedAt) {
|
||||
this.logger.warn(
|
||||
`Authorization code replay detected for client ${clientId}. ` +
|
||||
`Code was already used at ${authCodeToken.revokedAt.toISOString()}.`,
|
||||
);
|
||||
|
||||
return this.errorResponse(
|
||||
'invalid_grant',
|
||||
'Authorization code has already been used',
|
||||
);
|
||||
}
|
||||
|
||||
if (authCodeToken.expiresAt.getTime() < Date.now()) {
|
||||
return this.errorResponse('invalid_grant', 'Authorization code expired');
|
||||
}
|
||||
|
||||
// RFC 6749 §4.1.3: auth code must have been issued to this client
|
||||
const storedClientId = authCodeToken.context?.clientId;
|
||||
|
||||
if (!storedClientId || storedClientId !== clientId) {
|
||||
return this.errorResponse(
|
||||
'invalid_grant',
|
||||
'Authorization code was not issued to this client',
|
||||
);
|
||||
}
|
||||
|
||||
// RFC 6749 §4.1.3: redirect_uri must match the one used in the authorization request
|
||||
const storedRedirectUri = authCodeToken.context?.redirectUri;
|
||||
|
||||
@@ -117,15 +145,35 @@ export class OAuthService {
|
||||
}
|
||||
}
|
||||
|
||||
if (codeVerifier) {
|
||||
const pkceError = await this.validatePkce(codeVerifier, authCodeToken);
|
||||
// PKCE: if code_challenge was stored, code_verifier is required
|
||||
const storedCodeChallenge = authCodeToken.context?.codeChallenge;
|
||||
|
||||
if (pkceError) {
|
||||
return pkceError;
|
||||
if (storedCodeChallenge) {
|
||||
if (!codeVerifier) {
|
||||
return this.errorResponse(
|
||||
'invalid_request',
|
||||
'code_verifier is required (PKCE was used in authorization)',
|
||||
);
|
||||
}
|
||||
|
||||
const computedChallenge = base64UrlEncode(
|
||||
crypto.createHash('sha256').update(codeVerifier).digest(),
|
||||
);
|
||||
|
||||
if (computedChallenge !== storedCodeChallenge) {
|
||||
return this.errorResponse(
|
||||
'invalid_grant',
|
||||
'Code verifier does not match the code challenge',
|
||||
);
|
||||
}
|
||||
} else if (codeVerifier) {
|
||||
return this.errorResponse(
|
||||
'invalid_request',
|
||||
'code_verifier provided but no code_challenge was used in authorization',
|
||||
);
|
||||
}
|
||||
|
||||
if (!clientSecret && !codeVerifier) {
|
||||
if (!clientSecret && !storedCodeChallenge) {
|
||||
return this.errorResponse(
|
||||
'invalid_request',
|
||||
'Either client_secret or code_verifier (PKCE) is required',
|
||||
@@ -155,20 +203,35 @@ export class OAuthService {
|
||||
},
|
||||
});
|
||||
|
||||
if (!userWorkspace) {
|
||||
return this.errorResponse(
|
||||
'invalid_grant',
|
||||
'User no longer has access to this workspace',
|
||||
);
|
||||
}
|
||||
|
||||
const { applicationAccessToken, applicationRefreshToken } =
|
||||
await this.applicationTokenService.generateApplicationTokenPair({
|
||||
workspaceId: authCodeToken.workspaceId,
|
||||
applicationId: application.id,
|
||||
userId: authCodeToken.userId,
|
||||
userWorkspaceId: userWorkspace?.id,
|
||||
userWorkspaceId: userWorkspace.id,
|
||||
});
|
||||
|
||||
const grantedScope =
|
||||
authCodeToken.context?.scope ??
|
||||
applicationRegistration.oAuthScopes.join(' ');
|
||||
|
||||
this.logger.log(
|
||||
`Authorization code exchanged: client=${clientId} workspace=${authCodeToken.workspaceId} user=${authCodeToken.userId}`,
|
||||
);
|
||||
|
||||
return {
|
||||
access_token: applicationAccessToken.token,
|
||||
token_type: 'Bearer',
|
||||
expires_in: this.getAccessTokenExpiresInSeconds(),
|
||||
refresh_token: applicationRefreshToken.token,
|
||||
scope: applicationRegistration.oAuthScopes.join(' '),
|
||||
scope: grantedScope,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -221,6 +284,10 @@ export class OAuthService {
|
||||
applicationId: application.id,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Client credentials token issued: client=${clientId} workspace=${application.workspaceId}`,
|
||||
);
|
||||
|
||||
return {
|
||||
access_token: applicationAccessToken.token,
|
||||
token_type: 'Bearer',
|
||||
@@ -244,6 +311,14 @@ export class OAuthService {
|
||||
|
||||
const applicationRegistration = clientValidation;
|
||||
|
||||
// Confidential clients (those with a secret) must authenticate
|
||||
if (applicationRegistration.oAuthClientSecretHash && !clientSecret) {
|
||||
return this.errorResponse(
|
||||
'invalid_client',
|
||||
'Client authentication required for confidential clients',
|
||||
);
|
||||
}
|
||||
|
||||
if (clientSecret) {
|
||||
const secretError = await this.validateClientSecret(
|
||||
applicationRegistration,
|
||||
@@ -261,9 +336,28 @@ export class OAuthService {
|
||||
refreshToken,
|
||||
);
|
||||
|
||||
// Verify the refresh token belongs to this client
|
||||
const application = await this.applicationRepository.findOne({
|
||||
where: { id: payload.applicationId },
|
||||
});
|
||||
|
||||
if (
|
||||
!application ||
|
||||
application.applicationRegistrationId !== applicationRegistration.id
|
||||
) {
|
||||
return this.errorResponse(
|
||||
'invalid_grant',
|
||||
'Refresh token was not issued to this client',
|
||||
);
|
||||
}
|
||||
|
||||
const { applicationAccessToken, applicationRefreshToken } =
|
||||
await this.applicationTokenService.renewApplicationTokens(payload);
|
||||
|
||||
this.logger.log(
|
||||
`Refresh token exchanged: client=${clientId} application=${payload.applicationId}`,
|
||||
);
|
||||
|
||||
return {
|
||||
access_token: applicationAccessToken.token,
|
||||
token_type: 'Bearer',
|
||||
@@ -272,7 +366,7 @@ export class OAuthService {
|
||||
scope: applicationRegistration.oAuthScopes.join(' '),
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('Refresh token grant failed', error);
|
||||
this.logger.warn(`Refresh token grant failed: client=${clientId}`, error);
|
||||
|
||||
return this.errorResponse(
|
||||
'invalid_grant',
|
||||
@@ -281,6 +375,140 @@ export class OAuthService {
|
||||
}
|
||||
}
|
||||
|
||||
// RFC 7009: Token revocation
|
||||
// Returns true if token was successfully processed (even if already invalid)
|
||||
async revokeToken(params: {
|
||||
token: string;
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
}): Promise<{ success: boolean }> {
|
||||
const { token, clientId, clientSecret } = params;
|
||||
|
||||
if (clientId) {
|
||||
const clientValidation = await this.validateClient(clientId);
|
||||
|
||||
if ('error' in clientValidation) {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
if (clientSecret) {
|
||||
const secretError = await this.validateClientSecret(
|
||||
clientValidation,
|
||||
clientSecret,
|
||||
);
|
||||
|
||||
if (secretError) {
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Since our tokens are stateless JWTs, we can't truly revoke them.
|
||||
// We validate the token to log that revocation was requested.
|
||||
try {
|
||||
const payload =
|
||||
this.applicationTokenService.validateApplicationRefreshToken(token);
|
||||
|
||||
this.logger.log(
|
||||
`Token revocation requested for application ${payload.applicationId}`,
|
||||
);
|
||||
} catch {
|
||||
// Per RFC 7009 §2.2: the server responds with HTTP 200 for both
|
||||
// valid and invalid tokens
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// RFC 7662: Token introspection
|
||||
async introspectToken(params: {
|
||||
token: string;
|
||||
clientId: string;
|
||||
clientSecret?: string;
|
||||
}): Promise<Record<string, unknown>> {
|
||||
const { token, clientId, clientSecret } = params;
|
||||
|
||||
const clientValidation = await this.validateClient(clientId);
|
||||
|
||||
if ('error' in clientValidation) {
|
||||
return { active: false };
|
||||
}
|
||||
|
||||
if (clientSecret) {
|
||||
const secretError = await this.validateClientSecret(
|
||||
clientValidation,
|
||||
clientSecret,
|
||||
);
|
||||
|
||||
if (secretError) {
|
||||
return { active: false };
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
this.applicationTokenService.validateApplicationRefreshToken(token);
|
||||
|
||||
const decoded = this.applicationTokenService.decodeToken(token);
|
||||
|
||||
if (!decoded) {
|
||||
return { active: false };
|
||||
}
|
||||
|
||||
// Verify the token belongs to this client
|
||||
const application = await this.applicationRepository.findOne({
|
||||
where: { id: decoded.applicationId },
|
||||
});
|
||||
|
||||
if (
|
||||
!application ||
|
||||
application.applicationRegistrationId !== clientValidation.id
|
||||
) {
|
||||
return { active: false };
|
||||
}
|
||||
|
||||
return {
|
||||
active: true,
|
||||
sub: decoded.sub,
|
||||
client_id: clientId,
|
||||
token_type: 'Bearer',
|
||||
scope: clientValidation.oAuthScopes.join(' '),
|
||||
aud: decoded.workspaceId,
|
||||
iss: this.twentyConfigService.get('SERVER_URL'),
|
||||
exp: decoded.exp,
|
||||
iat: decoded.iat,
|
||||
};
|
||||
} catch {
|
||||
// Try as access token (with signature verification)
|
||||
try {
|
||||
const payload =
|
||||
this.applicationTokenService.validateApplicationAccessToken(token);
|
||||
|
||||
const application = await this.applicationRepository.findOne({
|
||||
where: { id: payload.applicationId },
|
||||
});
|
||||
|
||||
if (
|
||||
!application ||
|
||||
application.applicationRegistrationId !== clientValidation.id
|
||||
) {
|
||||
return { active: false };
|
||||
}
|
||||
|
||||
return {
|
||||
active: true,
|
||||
sub: payload.sub,
|
||||
client_id: clientId,
|
||||
token_type: 'Bearer',
|
||||
scope: clientValidation.oAuthScopes.join(' '),
|
||||
aud: payload.workspaceId,
|
||||
iss: this.twentyConfigService.get('SERVER_URL'),
|
||||
};
|
||||
} catch {
|
||||
return { active: false };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async validateClient(
|
||||
clientId: string,
|
||||
): Promise<ApplicationRegistrationEntity | OAuthErrorResponse> {
|
||||
@@ -311,41 +539,6 @@ export class OAuthService {
|
||||
return null;
|
||||
}
|
||||
|
||||
private async validatePkce(
|
||||
codeVerifier: string,
|
||||
authCodeToken: AppTokenEntity,
|
||||
): Promise<OAuthErrorResponse | null> {
|
||||
const codeChallenge = base64UrlEncode(
|
||||
crypto.createHash('sha256').update(codeVerifier).digest(),
|
||||
);
|
||||
|
||||
const challengeToken = await this.appTokenRepository.findOne({
|
||||
where: {
|
||||
value: codeChallenge,
|
||||
type: AppTokenType.CodeChallenge,
|
||||
revokedAt: IsNull(),
|
||||
...(authCodeToken.userId ? { userId: authCodeToken.userId } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!challengeToken) {
|
||||
return this.errorResponse(
|
||||
'invalid_grant',
|
||||
'Code verifier does not match the code challenge',
|
||||
);
|
||||
}
|
||||
|
||||
if (challengeToken.expiresAt.getTime() < Date.now()) {
|
||||
return this.errorResponse('invalid_grant', 'Code challenge expired');
|
||||
}
|
||||
|
||||
await this.appTokenRepository.update(challengeToken.id, {
|
||||
revokedAt: new Date(),
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async findOrInstallApplication(
|
||||
applicationRegistration: ApplicationRegistrationEntity,
|
||||
workspaceId: string,
|
||||
|
||||
+20
-4
@@ -136,12 +136,26 @@ export class ApplicationSyncService {
|
||||
application.applicationRegistrationId,
|
||||
manifest.application.universalIdentifier,
|
||||
applicationRegistrationMetadata,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
await this.applicationRegistrationService.update({
|
||||
id: applicationRegistrationId,
|
||||
update: applicationRegistrationMetadata,
|
||||
});
|
||||
// Only update registration metadata if this workspace owns it.
|
||||
// Other workspaces that install the same app attach to the existing
|
||||
// registration but must not be able to modify its metadata.
|
||||
if (
|
||||
await this.applicationRegistrationService.isOwnedByWorkspace(
|
||||
applicationRegistrationId,
|
||||
workspaceId,
|
||||
)
|
||||
) {
|
||||
await this.applicationRegistrationService.update(
|
||||
{
|
||||
id: applicationRegistrationId,
|
||||
update: applicationRegistrationMetadata,
|
||||
},
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
if (manifest.application.serverVariables) {
|
||||
await this.applicationRegistrationVariableService.syncVariableSchemas(
|
||||
@@ -248,6 +262,7 @@ export class ApplicationSyncService {
|
||||
websiteUrl?: string;
|
||||
termsUrl?: string;
|
||||
},
|
||||
workspaceId: string,
|
||||
): Promise<string> {
|
||||
if (existingId) {
|
||||
return existingId;
|
||||
@@ -265,6 +280,7 @@ export class ApplicationSyncService {
|
||||
const { applicationRegistration: newRegistration } =
|
||||
await this.applicationRegistrationService.create(
|
||||
{ ...metadata, universalIdentifier },
|
||||
workspaceId,
|
||||
null,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Field, ArgsType } from '@nestjs/graphql';
|
||||
|
||||
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
@ArgsType()
|
||||
export class AuthorizeAppInput {
|
||||
@@ -11,10 +11,24 @@ export class AuthorizeAppInput {
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@IsString()
|
||||
@MaxLength(256)
|
||||
@IsOptional()
|
||||
codeChallenge?: string;
|
||||
|
||||
@Field(() => String)
|
||||
@IsString()
|
||||
@MaxLength(2048)
|
||||
redirectUrl: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@IsString()
|
||||
@MaxLength(1024)
|
||||
@IsOptional()
|
||||
state?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@IsString()
|
||||
@MaxLength(1024)
|
||||
@IsOptional()
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
@@ -526,6 +526,27 @@ export class AuthService {
|
||||
);
|
||||
}
|
||||
|
||||
// Validate requested scopes are a subset of the registration's allowed scopes
|
||||
const parsedScopes = authorizeAppInput.scope
|
||||
? authorizeAppInput.scope.split(' ').filter(Boolean)
|
||||
: [];
|
||||
|
||||
const requestedScopes =
|
||||
parsedScopes.length > 0
|
||||
? parsedScopes
|
||||
: applicationRegistration.oAuthScopes;
|
||||
|
||||
const invalidScopes = requestedScopes.filter(
|
||||
(scope) => !applicationRegistration.oAuthScopes.includes(scope),
|
||||
);
|
||||
|
||||
if (invalidScopes.length > 0) {
|
||||
throw new AuthException(
|
||||
`Invalid scopes: ${invalidScopes.join(', ')}`,
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
);
|
||||
}
|
||||
|
||||
const redirectUriValidation = validateRedirectUri(
|
||||
authorizeAppInput.redirectUrl,
|
||||
);
|
||||
@@ -538,49 +559,40 @@ export class AuthService {
|
||||
}
|
||||
|
||||
const authorizationCode = crypto.randomBytes(42).toString('hex');
|
||||
const hashedAuthorizationCode = crypto
|
||||
.createHash('sha256')
|
||||
.update(authorizationCode)
|
||||
.digest('hex');
|
||||
|
||||
const expiresAt = addMilliseconds(new Date().getTime(), ms('5m'));
|
||||
|
||||
const authCodeContext = { redirectUri: authorizeAppInput.redirectUrl };
|
||||
const authCodeContext = {
|
||||
redirectUri: authorizeAppInput.redirectUrl,
|
||||
clientId: applicationRegistration.oAuthClientId,
|
||||
scope: requestedScopes.join(' '),
|
||||
...(codeChallenge ? { codeChallenge } : {}),
|
||||
};
|
||||
|
||||
if (codeChallenge) {
|
||||
const tokens = this.appTokenRepository.create([
|
||||
{
|
||||
value: codeChallenge,
|
||||
type: AppTokenType.CodeChallenge,
|
||||
userId: user.id,
|
||||
workspaceId: workspace.id,
|
||||
expiresAt,
|
||||
},
|
||||
{
|
||||
value: authorizationCode,
|
||||
type: AppTokenType.AuthorizationCode,
|
||||
userId: user.id,
|
||||
workspaceId: workspace.id,
|
||||
expiresAt,
|
||||
context: authCodeContext,
|
||||
},
|
||||
]);
|
||||
const token = this.appTokenRepository.create({
|
||||
value: hashedAuthorizationCode,
|
||||
type: AppTokenType.AuthorizationCode,
|
||||
userId: user.id,
|
||||
workspaceId: workspace.id,
|
||||
expiresAt,
|
||||
context: authCodeContext,
|
||||
});
|
||||
|
||||
await this.appTokenRepository.save(tokens);
|
||||
} else {
|
||||
const token = this.appTokenRepository.create({
|
||||
value: authorizationCode,
|
||||
type: AppTokenType.AuthorizationCode,
|
||||
userId: user.id,
|
||||
workspaceId: workspace.id,
|
||||
expiresAt,
|
||||
context: authCodeContext,
|
||||
});
|
||||
await this.appTokenRepository.save(token);
|
||||
|
||||
await this.appTokenRepository.save(token);
|
||||
redirectUriValidation.parsed.searchParams.set('code', authorizationCode);
|
||||
|
||||
if (authorizeAppInput.state) {
|
||||
redirectUriValidation.parsed.searchParams.set(
|
||||
'state',
|
||||
authorizeAppInput.state,
|
||||
);
|
||||
}
|
||||
|
||||
redirectUriValidation.parsed.searchParams.set(
|
||||
'authorizationCode',
|
||||
authorizationCode,
|
||||
);
|
||||
|
||||
return { redirectUrl: redirectUriValidation.parsed.toString() };
|
||||
}
|
||||
|
||||
|
||||
+41
@@ -148,6 +148,47 @@ export class ApplicationTokenService {
|
||||
}
|
||||
}
|
||||
|
||||
validateApplicationAccessToken(
|
||||
token: string,
|
||||
): ApplicationAccessTokenJwtPayload {
|
||||
try {
|
||||
this.jwtWrapperService.verifyJwtToken(token);
|
||||
|
||||
const payload =
|
||||
this.jwtWrapperService.decode<ApplicationAccessTokenJwtPayload>(token, {
|
||||
json: true,
|
||||
});
|
||||
|
||||
if (payload.type !== JwtTokenTypeEnum.APPLICATION_ACCESS) {
|
||||
throw new AuthException(
|
||||
'Expected an application access token',
|
||||
AuthExceptionCode.INVALID_JWT_TOKEN_TYPE,
|
||||
);
|
||||
}
|
||||
|
||||
return payload;
|
||||
} catch (error) {
|
||||
if (error instanceof AuthException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AuthException(
|
||||
'Invalid application access token',
|
||||
AuthExceptionCode.UNAUTHENTICATED,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
decodeToken(token: string): (
|
||||
| ApplicationAccessTokenJwtPayload
|
||||
| ApplicationRefreshTokenJwtPayload
|
||||
) & {
|
||||
exp?: number;
|
||||
iat?: number;
|
||||
} {
|
||||
return this.jwtWrapperService.decode(token, { json: true });
|
||||
}
|
||||
|
||||
async renewApplicationTokens(payload: {
|
||||
workspaceId: string;
|
||||
applicationId: string;
|
||||
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
import { validateRedirectUri } from 'src/engine/core-modules/auth/utils/validate-redirect-uri.util';
|
||||
|
||||
describe('validateRedirectUri', () => {
|
||||
it('should accept a valid HTTPS URI', () => {
|
||||
const result = validateRedirectUri('https://example.com/callback');
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
|
||||
if (result.valid) {
|
||||
expect(result.parsed.href).toBe('https://example.com/callback');
|
||||
}
|
||||
});
|
||||
|
||||
it('should accept localhost HTTP', () => {
|
||||
const result = validateRedirectUri('http://localhost:3000/callback');
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept 127.0.0.1 HTTP', () => {
|
||||
const result = validateRedirectUri('http://127.0.0.1:8080/callback');
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject non-HTTPS non-localhost URIs', () => {
|
||||
const result = validateRedirectUri('http://example.com/callback');
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
|
||||
if (!result.valid) {
|
||||
expect(result.reason).toContain('HTTPS');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject URIs with fragments', () => {
|
||||
const result = validateRedirectUri('https://example.com/callback#section');
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
|
||||
if (!result.valid) {
|
||||
expect(result.reason).toContain('fragments');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject invalid URIs', () => {
|
||||
const result = validateRedirectUri('not-a-url');
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
|
||||
if (!result.valid) {
|
||||
expect(result.reason).toContain('Invalid redirect URI');
|
||||
}
|
||||
});
|
||||
|
||||
it('should accept HTTPS with query parameters', () => {
|
||||
const result = validateRedirectUri(
|
||||
'https://example.com/callback?state=abc',
|
||||
);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept HTTPS with port', () => {
|
||||
const result = validateRedirectUri('https://example.com:8443/callback');
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user