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
@@ -4,6 +4,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
import { ApplicationRegistrationModule } from 'src/engine/core-modules/application-registration/application-registration.module';
import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
import { AppTokenService } from 'src/engine/core-modules/app-token/services/app-token.service';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
@@ -117,6 +118,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
AuditModule,
SubdomainManagerModule,
DomainServerConfigModule,
ApplicationRegistrationModule,
ApplicationModule,
WorkspaceCacheModule,
SecureHttpClientModule,
@@ -24,7 +24,6 @@ import { PermissionsService } from 'src/engine/metadata-modules/permissions/perm
import { AuthResolver } from './auth.resolver';
import { AuthService } from './services/auth.service';
// import { OAuthService } from './services/oauth.service';
import { ResetPasswordService } from './services/reset-password.service';
import { EmailVerificationTokenService } from './token/services/email-verification-token.service';
import { LoginTokenService } from './token/services/login-token.service';
@@ -139,10 +138,6 @@ describe('AuthResolver', () => {
}),
},
},
// {
// provide: OAuthService,
// useValue: {},
// },
],
})
.overrideGuard(CaptchaGuard)
@@ -11,17 +11,16 @@ import { Repository } from 'typeorm';
import { ApiKeyTokenInput } from 'src/engine/core-modules/auth/dto/api-key-token.input';
import { AppTokenInput } from 'src/engine/core-modules/auth/dto/app-token.input';
import { AuthorizeAppOutput } from 'src/engine/core-modules/auth/dto/authorize-app.dto';
import { AuthorizeAppDTO } from 'src/engine/core-modules/auth/dto/authorize-app.dto';
import { AuthorizeAppInput } from 'src/engine/core-modules/auth/dto/authorize-app.input';
import { EmailPasswordResetLinkOutput } from 'src/engine/core-modules/auth/dto/email-password-reset-link.dto';
import { EmailPasswordResetLinkDTO } from 'src/engine/core-modules/auth/dto/email-password-reset-link.dto';
import { EmailPasswordResetLinkInput } from 'src/engine/core-modules/auth/dto/email-password-reset-link.input';
import { InvalidatePasswordOutput } from 'src/engine/core-modules/auth/dto/invalidate-password.dto';
import { TransientTokenOutput } from 'src/engine/core-modules/auth/dto/transient-token.dto';
import { InvalidatePasswordDTO } from 'src/engine/core-modules/auth/dto/invalidate-password.dto';
import { TransientTokenDTO } from 'src/engine/core-modules/auth/dto/transient-token.dto';
import { UpdatePasswordViaResetTokenInput } from 'src/engine/core-modules/auth/dto/update-password-via-reset-token.input';
import { ValidatePasswordResetTokenOutput } from 'src/engine/core-modules/auth/dto/validate-password-reset-token.dto';
import { ValidatePasswordResetTokenDTO } from 'src/engine/core-modules/auth/dto/validate-password-reset-token.dto';
import { ValidatePasswordResetTokenInput } from 'src/engine/core-modules/auth/dto/validate-password-reset-token.input';
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
// import { OAuthService } from 'src/engine/core-modules/auth/services/oauth.service';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { ApiKeyService } from 'src/engine/core-modules/api-key/services/api-key.service';
import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
@@ -31,12 +30,12 @@ import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { AvailableWorkspacesAndAccessTokensOutput } from 'src/engine/core-modules/auth/dto/available-workspaces-and-access-tokens.output';
import { AvailableWorkspacesAndAccessTokensDTO } from 'src/engine/core-modules/auth/dto/available-workspaces-and-access-tokens.dto';
import { GetAuthTokenFromEmailVerificationTokenInput } from 'src/engine/core-modules/auth/dto/get-auth-token-from-email-verification-token.input';
import { GetAuthorizationUrlForSSOInput } from 'src/engine/core-modules/auth/dto/get-authorization-url-for-sso.input';
import { GetAuthorizationUrlForSSOOutput } from 'src/engine/core-modules/auth/dto/get-authorization-url-for-sso.output';
import { SignUpOutput } from 'src/engine/core-modules/auth/dto/sign-up.output';
import { VerifyEmailAndGetLoginTokenOutput } from 'src/engine/core-modules/auth/dto/verify-email-and-get-login-token.output';
import { GetAuthorizationUrlForSSODTO } from 'src/engine/core-modules/auth/dto/get-authorization-url-for-sso.dto';
import { SignUpDTO } from 'src/engine/core-modules/auth/dto/sign-up.dto';
import { VerifyEmailAndGetLoginTokenDTO } from 'src/engine/core-modules/auth/dto/verify-email-and-get-login-token.dto';
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 { EmailVerificationTokenService } from 'src/engine/core-modules/auth/token/services/email-verification-token.service';
@@ -82,12 +81,12 @@ import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-module
import { ApiKeyToken } from './dto/api-key-token.dto';
import { AuthTokens } from './dto/auth-tokens.dto';
import { GetAuthTokensFromLoginTokenInput } from './dto/get-auth-tokens-from-login-token.input';
import { LoginTokenOutput } from './dto/login-token.dto';
import { LoginTokenDTO } from './dto/login-token.dto';
import { SignUpInput } from './dto/sign-up.input';
import { UserCredentialsInput } from './dto/user-credentials.input';
import { CheckUserExistOutput } from './dto/user-exists.dto';
import { CheckUserExistDTO } from './dto/user-exists.dto';
import { EmailAndCaptchaInput } from './dto/user-exists.input';
import { WorkspaceInviteHashValidOutput } from './dto/workspace-invite-hash-valid.dto';
import { WorkspaceInviteHashValidDTO } from './dto/workspace-invite-hash-valid.dto';
import { WorkspaceInviteHashValidInput } from './dto/workspace-invite-hash.input';
import { AuthService } from './services/auth.service';
@@ -128,16 +127,16 @@ export class AuthResolver {
) {}
@UseGuards(CaptchaGuard, PublicEndpointGuard, NoPermissionGuard)
@Query(() => CheckUserExistOutput)
@Query(() => CheckUserExistDTO)
async checkUserExists(
@Args() checkUserExistsInput: EmailAndCaptchaInput,
): Promise<CheckUserExistOutput> {
): Promise<CheckUserExistDTO> {
return await this.authService.checkUserExists(
checkUserExistsInput.email.toLowerCase(),
);
}
@Mutation(() => GetAuthorizationUrlForSSOOutput)
@Mutation(() => GetAuthorizationUrlForSSODTO)
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
async getAuthorizationUrlForSSO(
@Args('input') params: GetAuthorizationUrlForSSOInput,
@@ -148,11 +147,11 @@ export class AuthResolver {
);
}
@Query(() => WorkspaceInviteHashValidOutput)
@Query(() => WorkspaceInviteHashValidDTO)
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
async checkWorkspaceInviteHashIsValid(
@Args() workspaceInviteHashValidInput: WorkspaceInviteHashValidInput,
): Promise<WorkspaceInviteHashValidOutput> {
): Promise<WorkspaceInviteHashValidDTO> {
return await this.authService.checkWorkspaceInviteHashIsValid(
workspaceInviteHashValidInput.inviteHash,
);
@@ -168,13 +167,13 @@ export class AuthResolver {
);
}
@Mutation(() => LoginTokenOutput)
@Mutation(() => LoginTokenDTO)
@UseGuards(CaptchaGuard, PublicEndpointGuard, NoPermissionGuard)
async getLoginTokenFromCredentials(
@Args()
getLoginTokenFromCredentialsInput: UserCredentialsInput,
@Args('origin') origin: string,
): Promise<LoginTokenOutput> {
): Promise<LoginTokenDTO> {
const workspace =
await this.workspaceDomainsService.getWorkspaceByOriginOrDefaultWorkspace(
origin,
@@ -203,12 +202,12 @@ export class AuthResolver {
return { loginToken };
}
@Mutation(() => AvailableWorkspacesAndAccessTokensOutput)
@Mutation(() => AvailableWorkspacesAndAccessTokensDTO)
@UseGuards(CaptchaGuard, PublicEndpointGuard, NoPermissionGuard)
async signIn(
@Args()
userCredentials: UserCredentialsInput,
): Promise<AvailableWorkspacesAndAccessTokensOutput> {
): Promise<AvailableWorkspacesAndAccessTokensDTO> {
const user =
await this.authService.validateLoginWithPassword(userCredentials);
@@ -241,7 +240,7 @@ export class AuthResolver {
};
}
@Mutation(() => VerifyEmailAndGetLoginTokenOutput)
@Mutation(() => VerifyEmailAndGetLoginTokenDTO)
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
async verifyEmailAndGetLoginToken(
@Args()
@@ -254,7 +253,10 @@ export class AuthResolver {
getAuthTokenFromEmailVerificationTokenInput,
);
if (appToken.context && appToken.context.email !== appToken.user.email) {
if (
appToken.context?.email &&
appToken.context.email !== appToken.user.email
) {
await this.userService.updateEmailFromVerificationToken(
appToken.user.id,
appToken.context.email,
@@ -283,7 +285,7 @@ export class AuthResolver {
return { loginToken, workspaceUrls };
}
@Mutation(() => AvailableWorkspacesAndAccessTokensOutput)
@Mutation(() => AvailableWorkspacesAndAccessTokensDTO)
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
async verifyEmailAndGetWorkspaceAgnosticToken(
@Args()
@@ -295,7 +297,10 @@ export class AuthResolver {
getAuthTokenFromEmailVerificationTokenInput,
);
if (appToken.context && appToken.context.email !== appToken.user.email) {
if (
appToken.context?.email &&
appToken.context.email !== appToken.user.email
) {
await this.userService.updateEmailFromVerificationToken(
appToken.user.id,
appToken.context.email,
@@ -373,11 +378,11 @@ export class AuthResolver {
return await this.authService.verify(email, workspace.id, authProvider);
}
@Mutation(() => AvailableWorkspacesAndAccessTokensOutput)
@Mutation(() => AvailableWorkspacesAndAccessTokensDTO)
@UseGuards(CaptchaGuard, PublicEndpointGuard, NoPermissionGuard)
async signUp(
@Args() signUpInput: UserCredentialsInput,
): Promise<AvailableWorkspacesAndAccessTokensOutput> {
): Promise<AvailableWorkspacesAndAccessTokensDTO> {
const user = await this.signInUpService.signUpWithoutWorkspace(
{
email: signUpInput.email,
@@ -426,12 +431,12 @@ export class AuthResolver {
};
}
@Mutation(() => SignUpOutput)
@Mutation(() => SignUpDTO)
@UseGuards(CaptchaGuard, PublicEndpointGuard, NoPermissionGuard)
async signUpInWorkspace(
@Args() signUpInput: SignUpInput,
@AuthProvider() authProvider: AuthProviderEnum,
): Promise<SignUpOutput> {
): Promise<SignUpDTO> {
const currentWorkspace = await this.authService.findWorkspaceForSignInUp({
workspaceInviteHash: signUpInput.workspaceInviteHash,
authProvider: AuthProviderEnum.Password,
@@ -500,12 +505,12 @@ export class AuthResolver {
};
}
@Mutation(() => SignUpOutput)
@Mutation(() => SignUpDTO)
@UseGuards(UserAuthGuard, NoPermissionGuard)
async signUpInNewWorkspace(
@AuthUser() currentUser: UserEntity,
@AuthProvider() authProvider: AuthProviderEnum,
): Promise<SignUpOutput> {
): Promise<SignUpDTO> {
const { user, workspace } = await this.signInUpService.signUpOnNewWorkspace(
{ type: 'existingUser', existingUser: currentUser },
);
@@ -525,12 +530,12 @@ export class AuthResolver {
};
}
@Mutation(() => TransientTokenOutput)
@Mutation(() => TransientTokenDTO)
@UseGuards(UserAuthGuard, NoPermissionGuard)
async generateTransientToken(
@AuthUser() user: UserEntity,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<TransientTokenOutput | void> {
): Promise<TransientTokenDTO | void> {
const workspaceMember = await this.userService.loadWorkspaceMember(
user,
workspace,
@@ -771,13 +776,13 @@ export class AuthResolver {
};
}
@Mutation(() => AuthorizeAppOutput)
@Mutation(() => AuthorizeAppDTO)
@UseGuards(UserAuthGuard, NoPermissionGuard)
async authorizeApp(
@Args() authorizeAppInput: AuthorizeAppInput,
@AuthUser() user: UserEntity,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<AuthorizeAppOutput> {
): Promise<AuthorizeAppDTO> {
return await this.authService.generateAuthorizationCode(
authorizeAppInput,
user,
@@ -811,12 +816,12 @@ export class AuthResolver {
);
}
@Mutation(() => EmailPasswordResetLinkOutput)
@Mutation(() => EmailPasswordResetLinkDTO)
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
async emailPasswordResetLink(
@Args() emailPasswordResetInput: EmailPasswordResetLinkInput,
@Context() context: I18nContext,
): Promise<EmailPasswordResetLinkOutput> {
): Promise<EmailPasswordResetLinkDTO> {
const resetToken =
await this.resetPasswordService.generatePasswordResetToken(
emailPasswordResetInput.email,
@@ -830,12 +835,12 @@ export class AuthResolver {
);
}
@Mutation(() => InvalidatePasswordOutput)
@Mutation(() => InvalidatePasswordDTO)
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
async updatePasswordViaResetToken(
@Args()
{ passwordResetToken, newPassword }: UpdatePasswordViaResetTokenInput,
): Promise<InvalidatePasswordOutput> {
): Promise<InvalidatePasswordDTO> {
const { id } =
await this.resetPasswordService.validatePasswordResetToken(
passwordResetToken,
@@ -846,11 +851,11 @@ export class AuthResolver {
return await this.resetPasswordService.invalidatePasswordResetToken(id);
}
@Query(() => ValidatePasswordResetTokenOutput)
@Query(() => ValidatePasswordResetTokenDTO)
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
async validatePasswordResetToken(
@Args() args: ValidatePasswordResetTokenInput,
): Promise<ValidatePasswordResetTokenOutput> {
): Promise<ValidatePasswordResetTokenDTO> {
return this.resetPasswordService.validatePasswordResetToken(
args.passwordResetToken,
);
@@ -1,7 +1,7 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class AuthorizeAppOutput {
@ObjectType('AuthorizeApp')
export class AuthorizeAppDTO {
@Field(() => String)
redirectUrl: string;
}
@@ -1,11 +1,11 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { AvailableWorkspaces } from 'src/engine/core-modules/auth/dto/available-workspaces.output';
import { AvailableWorkspaces } from 'src/engine/core-modules/auth/dto/available-workspaces.dto';
import { AuthTokenPair } from './auth-token-pair.dto';
@ObjectType()
export class AvailableWorkspacesAndAccessTokensOutput {
@ObjectType('AvailableWorkspacesAndAccessTokens')
export class AvailableWorkspacesAndAccessTokensDTO {
@Field(() => AuthTokenPair)
tokens: AuthTokenPair;
@@ -1,7 +1,7 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class EmailPasswordResetLinkOutput {
@ObjectType('EmailPasswordResetLink')
export class EmailPasswordResetLinkDTO {
@Field(() => Boolean, {
description: 'Boolean that confirms query was dispatched',
})
@@ -2,8 +2,8 @@ import { Field, ObjectType } from '@nestjs/graphql';
import { AuthToken } from 'src/engine/core-modules/auth/dto/auth-token.dto';
@ObjectType()
export class ExchangeAuthCodeOutput {
@ObjectType('ExchangeAuthCode')
export class ExchangeAuthCodeDTO {
@Field(() => AuthToken)
accessOrWorkspaceAgnosticToken: AuthToken;
@@ -1,15 +0,0 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { AuthToken } from 'src/engine/core-modules/auth/dto/auth-token.dto';
@ObjectType()
export class ExchangeAuthCode {
@Field(() => AuthToken)
accessOrWorkspaceAgnosticToken: AuthToken;
@Field(() => AuthToken)
refreshToken: AuthToken;
@Field(() => AuthToken)
loginToken: AuthToken;
}
@@ -5,8 +5,8 @@ import { Field, ObjectType } from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { type SSOConfiguration } from 'src/engine/core-modules/sso/types/SSOConfigurations.type';
@ObjectType()
export class GetAuthorizationUrlForSSOOutput {
@ObjectType('GetAuthorizationUrlForSSO')
export class GetAuthorizationUrlForSSODTO {
@Field(() => String)
authorizationURL: string;
@@ -1,7 +1,7 @@
import { ObjectType, Field } from '@nestjs/graphql';
@ObjectType()
export class InvalidatePasswordOutput {
@ObjectType('InvalidatePassword')
export class InvalidatePasswordDTO {
@Field(() => Boolean, {
description: 'Boolean that confirms query was dispatched',
})
@@ -2,8 +2,8 @@ import { Field, ObjectType } from '@nestjs/graphql';
import { AuthToken } from 'src/engine/core-modules/auth/dto/auth-token.dto';
@ObjectType()
export class LoginTokenOutput {
@ObjectType('LoginToken')
export class LoginTokenDTO {
@Field(() => AuthToken)
loginToken: AuthToken;
}
@@ -4,8 +4,8 @@ import { WorkspaceUrlsAndIdDTO } from 'src/engine/core-modules/workspace/dtos/wo
import { AuthToken } from './auth-token.dto';
@ObjectType('SignUpOutput')
export class SignUpOutput {
@ObjectType('SignUp')
export class SignUpDTO {
@Field(() => AuthToken)
loginToken: AuthToken;
@@ -2,8 +2,8 @@ import { Field, ObjectType } from '@nestjs/graphql';
import { AuthToken } from 'src/engine/core-modules/auth/dto/auth-token.dto';
@ObjectType()
export class TransientTokenOutput {
@ObjectType('TransientToken')
export class TransientTokenDTO {
@Field(() => AuthToken)
transientToken: AuthToken;
}
@@ -1,7 +1,7 @@
import { ObjectType, Field } from '@nestjs/graphql';
@ObjectType()
export class UpdatePasswordOutput {
@ObjectType('UpdatePassword')
export class UpdatePasswordDTO {
@Field(() => Boolean, {
description: 'Boolean that confirms query was dispatched',
})
@@ -1,7 +1,7 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class CheckUserExistOutput {
@ObjectType('CheckUserExist')
export class CheckUserExistDTO {
@Field(() => Boolean)
exists: boolean;
@@ -2,8 +2,8 @@ import { Field, ObjectType } from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ObjectType()
export class ValidatePasswordResetTokenOutput {
@ObjectType('ValidatePasswordResetToken')
export class ValidatePasswordResetTokenDTO {
@Field(() => UUIDScalarType)
id: string;
@@ -4,8 +4,8 @@ import { WorkspaceUrlsDTO } from 'src/engine/core-modules/workspace/dtos/workspa
import { AuthToken } from './auth-token.dto';
@ObjectType('VerifyEmailAndGetLoginTokenOutput')
export class VerifyEmailAndGetLoginTokenOutput {
@ObjectType('VerifyEmailAndGetLoginToken')
export class VerifyEmailAndGetLoginTokenDTO {
@Field(() => AuthToken)
loginToken: AuthToken;
@@ -1,7 +1,7 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class WorkspaceInviteHashValidOutput {
@ObjectType('WorkspaceInviteHashValid')
export class WorkspaceInviteHashValidDTO {
@Field(() => Boolean)
isValid: boolean;
}
@@ -29,6 +29,7 @@ import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application-registration/application-registration.service';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { AuthService } from './auth.service';
@@ -169,6 +170,10 @@ describe('AuthService', () => {
.mockResolvedValue(false),
},
},
{
provide: ApplicationRegistrationService,
useValue: {},
},
],
}).compile();
@@ -13,8 +13,7 @@ import { AppPath } from 'twenty-shared/types';
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
import { IsNull, Repository } from 'typeorm';
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application-registration/application-registration.service';
import {
AppTokenEntity,
AppTokenType,
@@ -30,12 +29,13 @@ import {
hashPassword,
} from 'src/engine/core-modules/auth/auth.util';
import { type AuthTokens } from 'src/engine/core-modules/auth/dto/auth-tokens.dto';
import { type AuthorizeAppOutput } from 'src/engine/core-modules/auth/dto/authorize-app.dto';
import { validateRedirectUri } from 'src/engine/core-modules/auth/utils/validate-redirect-uri.util';
import { type AuthorizeAppDTO } from 'src/engine/core-modules/auth/dto/authorize-app.dto';
import { type AuthorizeAppInput } from 'src/engine/core-modules/auth/dto/authorize-app.input';
import { type UpdatePasswordOutput } from 'src/engine/core-modules/auth/dto/update-password.dto';
import { type UpdatePasswordDTO } from 'src/engine/core-modules/auth/dto/update-password.dto';
import { type UserCredentialsInput } from 'src/engine/core-modules/auth/dto/user-credentials.input';
import { type CheckUserExistOutput } from 'src/engine/core-modules/auth/dto/user-exists.dto';
import { type WorkspaceInviteHashValidOutput } from 'src/engine/core-modules/auth/dto/workspace-invite-hash-valid.dto';
import { type CheckUserExistDTO } from 'src/engine/core-modules/auth/dto/user-exists.dto';
import { type WorkspaceInviteHashValidDTO } from 'src/engine/core-modules/auth/dto/workspace-invite-hash-valid.dto';
import { AuthSsoService } from 'src/engine/core-modules/auth/services/auth-sso.service';
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
import { type GoogleRequest } from 'src/engine/core-modules/auth/strategies/google.auth.strategy';
@@ -94,6 +94,7 @@ export class AuthService {
private readonly appTokenRepository: Repository<AppTokenEntity>,
private readonly i18nService: I18nService,
private readonly auditService: AuditService,
private readonly applicationRegistrationService: ApplicationRegistrationService,
) {}
private async checkAccessAndUseInvitationOrThrow(
@@ -467,7 +468,7 @@ export class AuthService {
).flat(2).length;
}
async checkUserExists(email: string): Promise<CheckUserExistOutput> {
async checkUserExists(email: string): Promise<CheckUserExistDTO> {
const user = await this.userService.findUserByEmail(email);
const isUserExist = isDefined(user);
@@ -482,7 +483,7 @@ export class AuthService {
async checkWorkspaceInviteHashIsValid(
inviteHash: string,
): Promise<WorkspaceInviteHashValidOutput> {
): Promise<WorkspaceInviteHashValidDTO> {
const workspace = await this.workspaceRepository.findOneBy({
inviteHash,
});
@@ -494,51 +495,54 @@ export class AuthService {
authorizeAppInput: AuthorizeAppInput,
user: UserEntity,
workspace: WorkspaceEntity,
): Promise<AuthorizeAppOutput> {
// TODO: replace with db call to - third party app table
const apps = [
{
id: 'chrome',
name: 'Chrome Extension',
redirectUrl:
this.twentyConfigService.get('NODE_ENV') ===
NodeEnvironment.DEVELOPMENT
? authorizeAppInput.redirectUrl
: `https://${this.twentyConfigService.get(
'CHROME_EXTENSION_ID',
)}.chromiumapp.org/`,
},
];
): Promise<AuthorizeAppDTO> {
const { clientId, codeChallenge } = authorizeAppInput;
const client = apps.find((app) => app.id === clientId);
const applicationRegistration =
await this.applicationRegistrationService.findOneByClientId(clientId);
if (!client) {
if (!applicationRegistration) {
throw new AuthException(
`Client not found for '${clientId}'`,
AuthExceptionCode.CLIENT_NOT_FOUND,
);
}
if (!client.redirectUrl || !authorizeAppInput.redirectUrl) {
if (!authorizeAppInput.redirectUrl) {
throw new AuthException(
`redirectUrl not found for '${clientId}'`,
`redirectUrl not provided for '${clientId}'`,
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
if (client.redirectUrl !== authorizeAppInput.redirectUrl) {
if (
!applicationRegistration.oAuthRedirectUris.includes(
authorizeAppInput.redirectUrl,
)
) {
throw new AuthException(
`redirectUrl mismatch for '${clientId}'`,
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
const redirectUriValidation = validateRedirectUri(
authorizeAppInput.redirectUrl,
);
if (!redirectUriValidation.valid) {
throw new AuthException(
redirectUriValidation.reason,
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
const authorizationCode = crypto.randomBytes(42).toString('hex');
const expiresAt = addMilliseconds(new Date().getTime(), ms('5m'));
const authCodeContext = { redirectUri: authorizeAppInput.redirectUrl };
if (codeChallenge) {
const tokens = this.appTokenRepository.create([
{
@@ -554,6 +558,7 @@ export class AuthService {
userId: user.id,
workspaceId: workspace.id,
expiresAt,
context: authCodeContext,
},
]);
@@ -565,22 +570,24 @@ export class AuthService {
userId: user.id,
workspaceId: workspace.id,
expiresAt,
context: authCodeContext,
});
await this.appTokenRepository.save(token);
}
const redirectUrl = `${
client.redirectUrl ? client.redirectUrl : authorizeAppInput.redirectUrl
}?authorizationCode=${authorizationCode}`;
redirectUriValidation.parsed.searchParams.set(
'authorizationCode',
authorizationCode,
);
return { redirectUrl };
return { redirectUrl: redirectUriValidation.parsed.toString() };
}
async updatePassword(
userId: string,
newPassword: string,
): Promise<UpdatePasswordOutput> {
): Promise<UpdatePasswordDTO> {
if (!userId) {
throw new AuthException(
'User ID is required',
@@ -1,157 +0,0 @@
// import { Injectable } from '@nestjs/common';
// import { InjectRepository } from '@nestjs/typeorm';
//
// import crypto from 'crypto';
//
// import { Repository } from 'typeorm';
//
// import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
// import {
// AuthException,
// AuthExceptionCode,
// } from 'src/engine/core-modules/auth/auth.exception';
// import { ExchangeAuthCode } from 'src/engine/core-modules/auth/dto/exchange-auth-code.entity';
// import { ExchangeAuthCodeInput } from 'src/engine/core-modules/auth/dto/exchange-auth-code.input';
// import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-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 { UserEntity } from 'src/engine/core-modules/user/user.entity';
// import { userValidator } from 'src/engine/core-modules/user/user.validate';
//
// @Injectable()
// export class OAuthService {
// constructor(
// @InjectRepository(UserEntity)
// private readonly userRepository: Repository<UserEntity>,
// @InjectRepository(AppTokenEntity)
// private readonly appTokenRepository: Repository<AppTokenEntity>,
// private readonly accessTokenService: AccessTokenService,
// private readonly refreshTokenService: RefreshTokenService,
// private readonly loginTokenService: LoginTokenService,
// ) {}
//
// async verifyAuthorizationCode(
// exchangeAuthCodeInput: ExchangeAuthCodeInput,
// ): Promise<ExchangeAuthCode> {
// const { authorizationCode, codeVerifier } = exchangeAuthCodeInput;
//
// if (!authorizationCode) {
// throw new AuthException(
// 'Authorization code not found',
// AuthExceptionCode.INVALID_INPUT,
// );
// }
//
// let userId = '';
//
// if (codeVerifier) {
// const authorizationCodeAppToken = await this.appTokenRepository.findOne({
// where: {
// value: authorizationCode,
// },
// });
//
// if (!authorizationCodeAppToken) {
// throw new AuthException(
// 'Authorization code does not exist',
// AuthExceptionCode.INVALID_INPUT,
// );
// }
//
// if (!(authorizationCodeAppToken.expiresAt.getTime() >= Date.now())) {
// throw new AuthException(
// 'Authorization code expired.',
// AuthExceptionCode.FORBIDDEN_EXCEPTION,
// );
// }
//
// const codeChallenge = crypto
// .createHash('sha256')
// .update(codeVerifier)
// .digest()
// .toString('base64')
// .replace(/\+/g, '-')
// .replace(/\//g, '_')
// .replace(/=/g, '');
//
// const codeChallengeAppToken = await this.appTokenRepository.findOne({
// where: {
// value: codeChallenge,
// },
// });
//
// if (!codeChallengeAppToken || !codeChallengeAppToken.userId) {
// throw new AuthException(
// 'code verifier doesnt match the challenge',
// AuthExceptionCode.FORBIDDEN_EXCEPTION,
// );
// }
//
// if (!(codeChallengeAppToken.expiresAt.getTime() >= Date.now())) {
// throw new AuthException(
// 'code challenge expired.',
// AuthExceptionCode.FORBIDDEN_EXCEPTION,
// );
// }
//
// if (codeChallengeAppToken.userId !== authorizationCodeAppToken.userId) {
// throw new AuthException(
// 'authorization code / code verifier was not created by same client',
// AuthExceptionCode.FORBIDDEN_EXCEPTION,
// );
// }
//
// if (codeChallengeAppToken.revokedAt) {
// throw new AuthException(
// 'Token has been revoked.',
// AuthExceptionCode.FORBIDDEN_EXCEPTION,
// );
// }
//
// await this.appTokenRepository.save({
// id: codeChallengeAppToken.id,
// revokedAt: new Date(),
// });
//
// userId = codeChallengeAppToken.userId;
// }
//
// const user = await this.userRepository.findOne({
// where: { id: userId },
// relations: ['defaultWorkspace'],
// });
//
// userValidator.assertIsDefinedOrThrow(
// user,
// new AuthException(
// 'User who generated the token does not exist',
// AuthExceptionCode.INVALID_INPUT,
// ),
// );
//
// if (!user.defaultWorkspace) {
// throw new AuthException(
// 'User does not have a default workspace',
// AuthExceptionCode.INVALID_DATA,
// );
// }
//
// const accessToken = await this.accessTokenService.generateAccessToken(
// user.id,
// user.defaultWorkspaceId,
// );
// const refreshToken = await this.refreshTokenService.generateRefreshToken(
// user.id,
// user.defaultWorkspaceId,
// );
// const loginToken = await this.loginTokenService.generateLoginToken(
// user.email,
// );
//
// return {
// accessToken,
// refreshToken,
// loginToken,
// };
// }
// }
@@ -25,10 +25,10 @@ import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { type EmailPasswordResetLinkOutput } from 'src/engine/core-modules/auth/dto/email-password-reset-link.dto';
import { type InvalidatePasswordOutput } from 'src/engine/core-modules/auth/dto/invalidate-password.dto';
import { type EmailPasswordResetLinkDTO } from 'src/engine/core-modules/auth/dto/email-password-reset-link.dto';
import { type InvalidatePasswordDTO } from 'src/engine/core-modules/auth/dto/invalidate-password.dto';
import { type PasswordResetToken } from 'src/engine/core-modules/auth/dto/password-reset-token.dto';
import { type ValidatePasswordResetTokenOutput } from 'src/engine/core-modules/auth/dto/validate-password-reset-token.dto';
import { type ValidatePasswordResetTokenDTO } from 'src/engine/core-modules/auth/dto/validate-password-reset-token.dto';
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 { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
@@ -119,7 +119,7 @@ export class ResetPasswordService {
resetToken: PasswordResetToken,
email: string,
locale: keyof typeof APP_LOCALES,
): Promise<EmailPasswordResetLinkOutput> {
): Promise<EmailPasswordResetLinkDTO> {
const user = await this.userService.findUserByEmailOrThrow(
email,
new AuthException('User not found', AuthExceptionCode.INVALID_INPUT),
@@ -180,7 +180,7 @@ export class ResetPasswordService {
async validatePasswordResetToken(
resetToken: string,
): Promise<ValidatePasswordResetTokenOutput> {
): Promise<ValidatePasswordResetTokenDTO> {
const hashedResetToken = crypto
.createHash('sha256')
.update(resetToken)
@@ -216,7 +216,7 @@ export class ResetPasswordService {
async invalidatePasswordResetToken(
userId: string,
): Promise<InvalidatePasswordOutput> {
): Promise<InvalidatePasswordDTO> {
const user = await this.userService.findUserByIdOrThrow(
userId,
new AuthException('User not found', AuthExceptionCode.INVALID_INPUT),
@@ -259,9 +259,13 @@ describe('JwtAuthStrategy', () => {
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException('User not found', expect.any(String), {
userFriendlyMessage: msg`User does not have access to this workspace.`,
}),
new AuthException(
'User or user workspace not found',
expect.any(String),
{
userFriendlyMessage: msg`User does not have access to this workspace`,
},
),
);
try {
@@ -301,15 +305,19 @@ describe('JwtAuthStrategy', () => {
);
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
new AuthException('UserWorkspaceEntity not found', expect.any(String), {
userFriendlyMessage: msg`User does not have access to this workspace.`,
}),
new AuthException(
'User or user workspace not found',
expect.any(String),
{
userFriendlyMessage: msg`User does not have access to this workspace`,
},
),
);
try {
await strategy.validate(payload as JwtPayload);
} catch (e) {
expect(e.code).toBe(AuthExceptionCode.USER_WORKSPACE_NOT_FOUND);
expect(e.code).toBe(AuthExceptionCode.USER_NOT_FOUND);
}
});
@@ -145,17 +145,6 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
);
}
user = await this.userRepository.findOne({
where: { id: userId },
});
if (!isDefined(user)) {
throw new AuthException(
'User not found',
AuthExceptionCode.USER_NOT_FOUND,
);
}
if (!payload.userWorkspaceId) {
throw new AuthException(
'UserWorkspaceEntity not found',
@@ -163,29 +152,31 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
);
}
const userWorkspace = await this.userWorkspaceRepository.findOne({
where: { id: payload.userWorkspaceId },
relations: ['user', 'workspace'],
const userContext = await this.resolveUserContext({
userId,
userWorkspaceId: payload.userWorkspaceId,
});
assertIsDefinedOrThrow(
userWorkspace,
userContext,
new AuthException(
'UserWorkspaceEntity not found',
AuthExceptionCode.USER_WORKSPACE_NOT_FOUND,
'User or user workspace not found',
AuthExceptionCode.USER_NOT_FOUND,
{
userFriendlyMessage: msg`User does not have access to this workspace`,
},
),
);
user = userContext.user;
context = {
...context,
user,
workspace,
authProvider: payload.authProvider,
userWorkspace,
userWorkspaceId: userWorkspace.id,
userWorkspace: userContext.userWorkspace,
userWorkspaceId: userContext.userWorkspace.id,
workspaceMemberId: payload.workspaceMemberId,
};
@@ -225,6 +216,41 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
};
}
private async resolveUserContext(params: {
userId: string;
userWorkspaceId: string;
expectedWorkspaceId?: string;
}): Promise<{
user: UserEntity;
userWorkspace: UserWorkspaceEntity;
} | null> {
const user = await this.userRepository.findOne({
where: { id: params.userId },
});
if (!isDefined(user)) {
return null;
}
const userWorkspace = await this.userWorkspaceRepository.findOne({
where: { id: params.userWorkspaceId },
relations: ['user', 'workspace'],
});
if (!isDefined(userWorkspace)) {
return null;
}
if (
isDefined(params.expectedWorkspaceId) &&
userWorkspace.workspace.id !== params.expectedWorkspaceId
) {
return null;
}
return { user, userWorkspace };
}
private async validateImpersonation(payload: AccessTokenJwtPayload) {
// Validate required impersonation fields
if (
@@ -359,12 +385,23 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
);
}
// TODO: Token carries userId/userWorkspaceId but they are unused.
// Compute the intersection of user and application permissions instead.
return {
application,
workspace,
};
const context: AuthContext = { application, workspace };
if (payload.userId && payload.userWorkspaceId) {
const userContext = await this.resolveUserContext({
userId: payload.userId,
userWorkspaceId: payload.userWorkspaceId,
expectedWorkspaceId: workspace.id,
});
if (isDefined(userContext)) {
context.user = userContext.user;
context.userWorkspace = userContext.userWorkspace;
context.userWorkspaceId = userContext.userWorkspace.id;
}
}
return context;
}
private isLegacyApiKeyPayload(
@@ -12,6 +12,7 @@ import {
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 { 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';
import { WorkspaceException } from 'src/engine/core-modules/workspace/workspace.exception';
@@ -43,6 +44,12 @@ describe('ApplicationTokenService', () => {
provide: getRepositoryToken(WorkspaceEntity),
useClass: Repository,
},
{
provide: TwentyConfigService,
useValue: {
get: jest.fn().mockReturnValue('1h'),
},
},
],
}).compile();
@@ -79,7 +86,6 @@ describe('ApplicationTokenService', () => {
const result = await service.generateApplicationAccessToken({
workspaceId,
applicationId,
expiresInSeconds: 10,
});
expect(result).toEqual({
@@ -117,7 +123,6 @@ describe('ApplicationTokenService', () => {
applicationId,
userWorkspaceId,
userId,
expiresInSeconds: 10,
});
expect(result).toEqual({
@@ -151,7 +156,6 @@ describe('ApplicationTokenService', () => {
service.generateApplicationAccessToken({
applicationId: 'non-existent-application',
workspaceId: 'workspace-id',
expiresInSeconds: 10,
}),
).rejects.toThrow(ApplicationException);
});
@@ -163,7 +167,6 @@ describe('ApplicationTokenService', () => {
service.generateApplicationAccessToken({
applicationId: 'application-id',
workspaceId: 'non-existent-workspace',
expiresInSeconds: 10,
}),
).rejects.toThrow(WorkspaceException);
});
@@ -24,9 +24,8 @@ import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
const APPLICATION_ACCESS_TOKEN_EXPIRY_SECONDS = 1800;
const APPLICATION_REFRESH_TOKEN_EXPIRY_SECONDS = 60 * 60 * 24 * 60; // 60 days
const APPLICATION_REFRESH_TOKEN_INVALID_OR_EXPIRED_MESSAGE =
'Application refresh token invalid or expired';
@@ -39,6 +38,7 @@ export class ApplicationTokenService {
private readonly workspaceRepository: Repository<WorkspaceEntity>,
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
private readonly twentyConfigService: TwentyConfigService,
) {}
async generateApplicationAccessToken({
@@ -46,23 +46,25 @@ export class ApplicationTokenService {
applicationId,
userWorkspaceId,
userId,
expiresInSeconds = APPLICATION_ACCESS_TOKEN_EXPIRY_SECONDS,
}: {
workspaceId: string;
applicationId: string;
userWorkspaceId?: string;
userId?: string;
expiresInSeconds?: number;
}): Promise<AuthToken> {
await this.validateWorkspaceAndApplication(workspaceId, applicationId);
const expiresIn = this.twentyConfigService.get(
'APPLICATION_ACCESS_TOKEN_EXPIRES_IN',
);
return this.signApplicationToken({
workspaceId,
applicationId,
userWorkspaceId,
userId,
tokenType: JwtTokenTypeEnum.APPLICATION_ACCESS,
expiresInSeconds,
expiresIn,
});
}
@@ -82,26 +84,30 @@ export class ApplicationTokenService {
}> {
await this.validateWorkspaceAndApplication(workspaceId, applicationId);
const [applicationAccessToken, applicationRefreshToken] = await Promise.all(
[
this.signApplicationToken({
workspaceId,
applicationId,
userWorkspaceId,
userId,
tokenType: JwtTokenTypeEnum.APPLICATION_ACCESS,
expiresInSeconds: APPLICATION_ACCESS_TOKEN_EXPIRY_SECONDS,
}),
this.signApplicationToken({
workspaceId,
applicationId,
userWorkspaceId,
userId,
tokenType: JwtTokenTypeEnum.APPLICATION_REFRESH,
expiresInSeconds: APPLICATION_REFRESH_TOKEN_EXPIRY_SECONDS,
}),
],
const accessTokenExpiresIn = this.twentyConfigService.get(
'APPLICATION_ACCESS_TOKEN_EXPIRES_IN',
);
const refreshTokenExpiresIn = this.twentyConfigService.get(
'APPLICATION_REFRESH_TOKEN_EXPIRES_IN',
);
const applicationAccessToken = this.signApplicationToken({
workspaceId,
applicationId,
userWorkspaceId,
userId,
tokenType: JwtTokenTypeEnum.APPLICATION_ACCESS,
expiresIn: accessTokenExpiresIn,
});
const applicationRefreshToken = this.signApplicationToken({
workspaceId,
applicationId,
userWorkspaceId,
userId,
tokenType: JwtTokenTypeEnum.APPLICATION_REFRESH,
expiresIn: refreshTokenExpiresIn,
});
return { applicationAccessToken, applicationRefreshToken };
}
@@ -188,7 +194,7 @@ export class ApplicationTokenService {
userWorkspaceId,
userId,
tokenType,
expiresInSeconds,
expiresIn,
}: {
workspaceId: string;
applicationId: string;
@@ -197,9 +203,8 @@ export class ApplicationTokenService {
tokenType:
| JwtTokenTypeEnum.APPLICATION_ACCESS
| JwtTokenTypeEnum.APPLICATION_REFRESH;
expiresInSeconds: number;
expiresIn: string;
}): AuthToken {
const expiresIn = `${expiresInSeconds}s`;
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
const jwtPayload:
@@ -0,0 +1,31 @@
// RFC 6749 redirect URI validation: must be absolute, HTTPS (except localhost), no fragments
export const validateRedirectUri = (
uri: string,
): { valid: true; parsed: URL } | { valid: false; reason: string } => {
let parsed: URL;
try {
parsed = new URL(uri);
} catch {
return { valid: false, reason: `Invalid redirect URI: ${uri}` };
}
const isLocalhost =
parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1';
if (parsed.protocol !== 'https:' && !isLocalhost) {
return {
valid: false,
reason: `Redirect URIs must use HTTPS (except localhost): ${uri}`,
};
}
if (parsed.hash) {
return {
valid: false,
reason: `Redirect URIs must not contain fragments: ${uri}`,
};
}
return { valid: true, parsed };
};