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:
@@ -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;
|
||||
}
|
||||
|
||||
+3
-3
@@ -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;
|
||||
|
||||
+2
-2
@@ -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;
|
||||
}
|
||||
+2
-2
@@ -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;
|
||||
}
|
||||
|
||||
+2
-2
@@ -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
-2
@@ -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;
|
||||
|
||||
|
||||
+2
-2
@@ -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;
|
||||
|
||||
+2
-2
@@ -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,
|
||||
// };
|
||||
// }
|
||||
// }
|
||||
+6
-6
@@ -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),
|
||||
|
||||
+15
-7
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+62
-25
@@ -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(
|
||||
|
||||
+7
-4
@@ -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);
|
||||
});
|
||||
|
||||
+32
-27
@@ -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:
|
||||
|
||||
+31
@@ -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 };
|
||||
};
|
||||
Reference in New Issue
Block a user