Record and enforce per-user OAuth application authorizations (#23678)
Sits on `main` now that #23642 has merged. 18 files changed. ## Why Application tokens are stateless JWTs. When a user completes an OAuth `authorization_code` exchange, the server issues an access/refresh pair carrying `userId` as a claim and stores nothing. So today: - there is no record that a person ever authorized an app, hence nothing to list on a settings screen - there is no way for that person to take an app's access away. The only revocation that exists is uninstalling the app, which is workspace-wide and admin-only - `/oauth/revoke` accepted a refresh token, logged it and did nothing, because there was no state to change `client_credentials` is unaffected: no user is involved and it returns an access token with no refresh token. ## What **`core."applicationAuthorization"`**, one row per (user, application), unique on that pair so re-authorizing updates in place. Written at the `authorization_code` exchange, before the token pair is issued, so a refresh token is never handed out without the grant that makes it redeemable. A dedicated table rather than a new `AppTokenType`: this is a grant keyed on identity, not a token keyed on a secret, and `appToken` is already overloaded. FKs to user, workspace, application and userWorkspace all cascade, which covers hard deletes. Membership removal soft-deletes the `userWorkspace` row, so that cascade does not fire and the grant outlives the membership. The refresh path therefore rechecks membership on every renewal rather than trusting the row's existence. **Enforcement.** `refresh_token` checks the row when the token carries a user, and returns `invalid_grant` if it is revoked. Revoking does not kill live access tokens, so access ends within one access-token window (`APPLICATION_ACCESS_TOKEN_EXPIRES_IN`, 30 minutes) rather than instantly. The alternative is a DB read on every API request, which is not worth it for a 30 minute tail; the UI should say so. **RFC 7009 revocation now revokes.** Revoking a refresh token revokes the authorization behind it. It also now checks the token was issued to the client asking, which it never did before. That check did not matter while revocation was a no-op; it does now. **Introspection** reports a refresh token inactive once its authorization is revoked. Access tokens keep reporting active until they expire, because they genuinely still work. **API:** `currentUserApplicationAuthorizations` and `revokeApplicationAuthorization`, both behind `UserAuthGuard`. The mutation scopes by `userId` inside the `UPDATE` rather than read-then-write, so one user cannot revoke another's authorization by guessing an id. ## Backwards compatibility Refresh tokens already in the wild have no row. Rejecting them would sign every live integration out on deploy, so the first refresh backfills the grant that was always implied. A revoked authorization keeps its row, so this never resurrects access someone turned off, and the backfill is insert-only so it cannot overwrite a real consent. If the user has since left the workspace, the refresh fails instead. Those tokens carry no scope claim and no record of when consent was given, so `scopes` and `lastAuthorizedAt` are nullable and left null on a backfilled row. Null means "the original consent is not on record" rather than a guess assembled from what the application declares today; a real re-authorization fills both in. Revoking such a token lays the row down before marking it, so the revocation sticks instead of being undone by the next refresh. ## Not in this PR The settings UI, following how #23643 shipped the sessions API and #23645 the devices screen. Introspection still reports a refresh token active once the membership is gone. That matches access tokens, which genuinely keep working in that case, so closing it belongs with the wider question of validating membership on every application-token request. ## Testing - 29 unit tests across the authorization service and the three OAuth grant paths - 9 integration tests on `/oauth/token`, `/oauth/revoke` and the GraphQL API: scopes as granted are recorded, revoking blocks the next refresh, re-authorizing reinstates, a pre-record token backfills without inventing a consent, a revoked pre-record token stays revoked, the authorization is listed to the user who granted it, revoking from that list stops the refresh token being redeemed, a repeated revocation reports no-op, and another user can neither see nor revoke it - the cross-user isolation and revoke-from-list tests are mutation-checked: dropping the `userId` scoping from `revokeAuthorizationById` fails only the isolation test, and disabling the `revokedAt` check in `oauth.service.ts` fails the revoke-from-list test plus two pre-existing ones - full `twenty-server` suite green - instance command applied against a fresh `database:reset`, table/index/FK shape verified against `information_schema` Closes part of https://github.com/twentyhq/core-team-issues/issues/2747 --------- Co-authored-by: prastoin <45004772+prastoin@users.noreply.github.com>
This commit is contained in:
@@ -2510,6 +2510,18 @@ type UsageAnalytics {
|
||||
userDailyUsage: UsageUserDaily
|
||||
}
|
||||
|
||||
type ApplicationAuthorization {
|
||||
id: UUID!
|
||||
applicationId: UUID!
|
||||
workspaceId: UUID!
|
||||
applicationName: String!
|
||||
applicationUniversalIdentifier: String
|
||||
scopes: [String!]
|
||||
lastAuthorizedAt: DateTime
|
||||
lastUsedAt: DateTime!
|
||||
createdAt: DateTime!
|
||||
}
|
||||
|
||||
type DevelopmentApplication {
|
||||
id: String!
|
||||
universalIdentifier: String!
|
||||
@@ -3306,6 +3318,7 @@ type Query {
|
||||
getAddressDetails(placeId: String!, token: String!): PlaceDetailsResult!
|
||||
getUsageAnalytics(input: UsageAnalyticsInput): UsageAnalytics!
|
||||
findManyPublicDomains: [PublicDomain!]!
|
||||
currentUserApplicationAuthorizations: [ApplicationAuthorization!]!
|
||||
}
|
||||
|
||||
input GetApiKeyInput {
|
||||
@@ -3651,6 +3664,7 @@ type Mutation {
|
||||
createDevelopmentApplication(universalIdentifier: String!, name: String!): DevelopmentApplication!
|
||||
syncApplication(manifest: JSON!, dryRun: Boolean): WorkspaceMigration!
|
||||
uploadApplicationFile(file: Upload!, applicationUniversalIdentifier: String!, fileFolder: FileFolder!, filePath: String!): File!
|
||||
revokeApplicationAuthorization(applicationAuthorizationId: UUID!): Boolean!
|
||||
generateApplicationToken(applicationId: UUID!): ApplicationTokenPair!
|
||||
renewApplicationToken(applicationRefreshToken: String!): ApplicationTokenPair!
|
||||
}
|
||||
|
||||
@@ -2198,6 +2198,19 @@ export interface UsageAnalytics {
|
||||
__typename: 'UsageAnalytics'
|
||||
}
|
||||
|
||||
export interface ApplicationAuthorization {
|
||||
id: Scalars['UUID']
|
||||
applicationId: Scalars['UUID']
|
||||
workspaceId: Scalars['UUID']
|
||||
applicationName: Scalars['String']
|
||||
applicationUniversalIdentifier?: Scalars['String']
|
||||
scopes?: Scalars['String'][]
|
||||
lastAuthorizedAt?: Scalars['DateTime']
|
||||
lastUsedAt: Scalars['DateTime']
|
||||
createdAt: Scalars['DateTime']
|
||||
__typename: 'ApplicationAuthorization'
|
||||
}
|
||||
|
||||
export interface DevelopmentApplication {
|
||||
id: Scalars['String']
|
||||
universalIdentifier: Scalars['String']
|
||||
@@ -2922,6 +2935,7 @@ export interface Query {
|
||||
getAddressDetails: PlaceDetailsResult
|
||||
getUsageAnalytics: UsageAnalytics
|
||||
findManyPublicDomains: PublicDomain[]
|
||||
currentUserApplicationAuthorizations: ApplicationAuthorization[]
|
||||
__typename: 'Query'
|
||||
}
|
||||
|
||||
@@ -3167,6 +3181,7 @@ export interface Mutation {
|
||||
createDevelopmentApplication: DevelopmentApplication
|
||||
syncApplication: WorkspaceMigration
|
||||
uploadApplicationFile: File
|
||||
revokeApplicationAuthorization: Scalars['Boolean']
|
||||
generateApplicationToken: ApplicationTokenPair
|
||||
renewApplicationToken: ApplicationTokenPair
|
||||
__typename: 'Mutation'
|
||||
@@ -5488,6 +5503,20 @@ export interface UsageAnalyticsGenqlSelection{
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface ApplicationAuthorizationGenqlSelection{
|
||||
id?: boolean | number
|
||||
applicationId?: boolean | number
|
||||
workspaceId?: boolean | number
|
||||
applicationName?: boolean | number
|
||||
applicationUniversalIdentifier?: boolean | number
|
||||
scopes?: boolean | number
|
||||
lastAuthorizedAt?: boolean | number
|
||||
lastUsedAt?: boolean | number
|
||||
createdAt?: boolean | number
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface DevelopmentApplicationGenqlSelection{
|
||||
id?: boolean | number
|
||||
universalIdentifier?: boolean | number
|
||||
@@ -6246,6 +6275,7 @@ export interface QueryGenqlSelection{
|
||||
getAddressDetails?: (PlaceDetailsResultGenqlSelection & { __args: {placeId: Scalars['String'], token: Scalars['String']} })
|
||||
getUsageAnalytics?: (UsageAnalyticsGenqlSelection & { __args?: {input?: (UsageAnalyticsInput | null)} })
|
||||
findManyPublicDomains?: PublicDomainGenqlSelection
|
||||
currentUserApplicationAuthorizations?: ApplicationAuthorizationGenqlSelection
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
@@ -6520,6 +6550,7 @@ export interface MutationGenqlSelection{
|
||||
createDevelopmentApplication?: (DevelopmentApplicationGenqlSelection & { __args: {universalIdentifier: Scalars['String'], name: Scalars['String']} })
|
||||
syncApplication?: (WorkspaceMigrationGenqlSelection & { __args: {manifest: Scalars['JSON'], dryRun?: (Scalars['Boolean'] | null)} })
|
||||
uploadApplicationFile?: (FileGenqlSelection & { __args: {file: Scalars['Upload'], applicationUniversalIdentifier: Scalars['String'], fileFolder: FileFolder, filePath: Scalars['String']} })
|
||||
revokeApplicationAuthorization?: { __args: {applicationAuthorizationId: Scalars['UUID']} }
|
||||
generateApplicationToken?: (ApplicationTokenPairGenqlSelection & { __args: {applicationId: Scalars['UUID']} })
|
||||
renewApplicationToken?: (ApplicationTokenPairGenqlSelection & { __args: {applicationRefreshToken: Scalars['String']} })
|
||||
__typename?: boolean | number
|
||||
@@ -8562,6 +8593,14 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
|
||||
|
||||
|
||||
|
||||
const ApplicationAuthorization_possibleTypes: string[] = ['ApplicationAuthorization']
|
||||
export const isApplicationAuthorization = (obj?: { __typename?: any } | null): obj is ApplicationAuthorization => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isApplicationAuthorization"')
|
||||
return ApplicationAuthorization_possibleTypes.includes(obj.__typename)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const DevelopmentApplication_possibleTypes: string[] = ['DevelopmentApplication']
|
||||
export const isDevelopmentApplication = (obj?: { __typename?: any } | null): obj is DevelopmentApplication => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isDevelopmentApplication"')
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -321,6 +321,19 @@ export type Application = {
|
||||
yarnLockFileId?: Maybe<Scalars['UUID']['output']>;
|
||||
};
|
||||
|
||||
export type ApplicationAuthorization = {
|
||||
__typename?: 'ApplicationAuthorization';
|
||||
applicationId: Scalars['UUID']['output'];
|
||||
applicationName: Scalars['String']['output'];
|
||||
applicationUniversalIdentifier?: Maybe<Scalars['String']['output']>;
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
id: Scalars['UUID']['output'];
|
||||
lastAuthorizedAt?: Maybe<Scalars['DateTime']['output']>;
|
||||
lastUsedAt: Scalars['DateTime']['output'];
|
||||
scopes?: Maybe<Array<Scalars['String']['output']>>;
|
||||
workspaceId: Scalars['UUID']['output'];
|
||||
};
|
||||
|
||||
export type ApplicationConnectionProvider = {
|
||||
__typename?: 'ApplicationConnectionProvider';
|
||||
applicationId: Scalars['String']['output'];
|
||||
@@ -2723,6 +2736,7 @@ export type Mutation = {
|
||||
retryChatMessage: SendChatMessageResult;
|
||||
revokeAllOtherUserSessions: Scalars['Int']['output'];
|
||||
revokeApiKey?: Maybe<ApiKey>;
|
||||
revokeApplicationAuthorization: Scalars['Boolean']['output'];
|
||||
revokeUserSession: Scalars['Boolean']['output'];
|
||||
rotateApplicationRegistrationClientSecret: RotateClientSecret;
|
||||
runAgent: RunAgentResult;
|
||||
@@ -3501,6 +3515,11 @@ export type MutationRevokeApiKeyArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationRevokeApplicationAuthorizationArgs = {
|
||||
applicationAuthorizationId: Scalars['UUID']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type MutationRevokeUserSessionArgs = {
|
||||
userSessionId: Scalars['UUID']['input'];
|
||||
};
|
||||
@@ -4513,6 +4532,7 @@ export type Query = {
|
||||
commandMenuItem?: Maybe<CommandMenuItem>;
|
||||
commandMenuItems: Array<CommandMenuItem>;
|
||||
currentUser: User;
|
||||
currentUserApplicationAuthorizations: Array<ApplicationAuthorization>;
|
||||
currentUserSessions: Array<UserSession>;
|
||||
currentWorkspace: Workspace;
|
||||
enterpriseCheckoutSession?: Maybe<Scalars['String']['output']>;
|
||||
|
||||
@@ -43,6 +43,10 @@ const WORKSPACE_SCOPED_EXEMPTIONS = new Set<string>([
|
||||
// Resolved by id alone at auth/request-routing time and inside file-storage
|
||||
// transactions; very few of the ~50 call sites carry a workspaceId.
|
||||
'ApplicationEntity',
|
||||
// Read by user across every workspace they belong to (the "apps you
|
||||
// authorized" screen) and from the OAuth token endpoint, which has no
|
||||
// request workspace to scope by.
|
||||
'ApplicationAuthorizationEntity',
|
||||
// 20+ call sites across calendar/messaging modules; staged for a dedicated PR.
|
||||
'CalendarChannelEntity',
|
||||
'MessageChannelEntity',
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import { type QueryRunner } from 'typeorm';
|
||||
|
||||
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
|
||||
import { type FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
|
||||
|
||||
@RegisteredInstanceCommand('2.27.0', 1785681272278)
|
||||
export class CreateApplicationAuthorizationCoreTableFastInstanceCommand
|
||||
implements FastInstanceCommand
|
||||
{
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE IF NOT EXISTS "core"."applicationAuthorization" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"userId" uuid NOT NULL,
|
||||
"workspaceId" uuid NOT NULL,
|
||||
"applicationId" uuid NOT NULL,
|
||||
"userWorkspaceId" uuid NOT NULL,
|
||||
"scopes" text array,
|
||||
"lastAuthorizedAt" TIMESTAMP WITH TIME ZONE,
|
||||
"lastUsedAt" TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
"revokedAt" TIMESTAMP WITH TIME ZONE,
|
||||
"createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
"updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "PK_applicationAuthorization_id" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "FK_APPLICATION_AUTHORIZATION_USER_ID" FOREIGN KEY ("userId")
|
||||
REFERENCES "core"."user"("id") ON DELETE CASCADE,
|
||||
CONSTRAINT "FK_APPLICATION_AUTHORIZATION_WORKSPACE_ID" FOREIGN KEY ("workspaceId")
|
||||
REFERENCES "core"."workspace"("id") ON DELETE CASCADE,
|
||||
CONSTRAINT "FK_APPLICATION_AUTHORIZATION_APPLICATION_ID" FOREIGN KEY ("applicationId")
|
||||
REFERENCES "core"."application"("id") ON DELETE CASCADE,
|
||||
CONSTRAINT "FK_APPLICATION_AUTHORIZATION_USER_WORKSPACE_ID" FOREIGN KEY ("userWorkspaceId")
|
||||
REFERENCES "core"."userWorkspace"("id") ON DELETE CASCADE
|
||||
)`,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_APPLICATION_AUTHORIZATION_USER_APPLICATION_UNIQUE" ON "core"."applicationAuthorization" ("userId", "applicationId")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_APPLICATION_AUTHORIZATION_WORKSPACE_ID" ON "core"."applicationAuthorization" ("workspaceId")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_APPLICATION_AUTHORIZATION_APPLICATION_ID" ON "core"."applicationAuthorization" ("applicationId")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_APPLICATION_AUTHORIZATION_USER_WORKSPACE_ID" ON "core"."applicationAuthorization" ("userWorkspaceId")`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP TABLE IF EXISTS "core"."applicationAuthorization"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+2
@@ -133,6 +133,7 @@ import { AddIsHiddenToAgentMessageFastInstanceCommand } from './2-25/2-25-instan
|
||||
import { AddConnectedAccountHandleProviderIndexFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-26/2-26-instance-command-fast-1785420705255-add-connected-account-handle-provider-index';
|
||||
import { AddOpenRecordInToObjectMetadataFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-27/2-27-instance-command-fast-1785504900000-add-open-record-in-to-object-metadata';
|
||||
import { CreateUserSessionCoreTableFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-27/2-27-instance-command-fast-1785518325511-create-user-session-core-table';
|
||||
import { CreateApplicationAuthorizationCoreTableFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-27/2-27-instance-command-fast-1785681272278-create-application-authorization-core-table';
|
||||
|
||||
export const INSTANCE_COMMANDS = [
|
||||
AddViewFieldGroupIdIndexOnViewFieldFastInstanceCommand,
|
||||
@@ -268,4 +269,5 @@ export const INSTANCE_COMMANDS = [
|
||||
AddConnectedAccountHandleProviderIndexFastInstanceCommand,
|
||||
AddOpenRecordInToObjectMetadataFastInstanceCommand,
|
||||
CreateUserSessionCoreTableFastInstanceCommand,
|
||||
CreateApplicationAuthorizationCoreTableFastInstanceCommand,
|
||||
];
|
||||
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { IsNull, type Repository, type UpdateResult } from 'typeorm';
|
||||
|
||||
import { ApplicationAuthorizationEntity } from 'src/engine/core-modules/application/application-authorization/application-authorization.entity';
|
||||
import { ApplicationAuthorizationService } from 'src/engine/core-modules/application/application-authorization/services/application-authorization.service';
|
||||
|
||||
describe('ApplicationAuthorizationService', () => {
|
||||
let service: ApplicationAuthorizationService;
|
||||
let repository: jest.Mocked<Repository<ApplicationAuthorizationEntity>>;
|
||||
|
||||
const queryBuilder = {
|
||||
innerJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
getMany: jest.fn().mockResolvedValue([]),
|
||||
insert: jest.fn().mockReturnThis(),
|
||||
values: jest.fn().mockReturnThis(),
|
||||
orIgnore: jest.fn().mockReturnThis(),
|
||||
execute: jest.fn().mockResolvedValue({ identifiers: [] }),
|
||||
};
|
||||
|
||||
const userId = 'user-1';
|
||||
const otherUserId = 'user-2';
|
||||
const workspaceId = 'workspace-1';
|
||||
const userWorkspaceId = 'user-workspace-1';
|
||||
const applicationId = 'application-1';
|
||||
const authorizationId = 'authorization-1';
|
||||
|
||||
const buildUpdateResult = (affected: number): UpdateResult => ({
|
||||
affected,
|
||||
raw: [],
|
||||
generatedMaps: [],
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
queryBuilder.innerJoinAndSelect.mockReturnThis();
|
||||
queryBuilder.where.mockReturnThis();
|
||||
queryBuilder.andWhere.mockReturnThis();
|
||||
queryBuilder.orderBy.mockReturnThis();
|
||||
queryBuilder.getMany.mockResolvedValue([]);
|
||||
queryBuilder.insert.mockReturnThis();
|
||||
queryBuilder.values.mockReturnThis();
|
||||
queryBuilder.orIgnore.mockReturnThis();
|
||||
queryBuilder.execute.mockResolvedValue({ identifiers: [] });
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ApplicationAuthorizationService,
|
||||
{
|
||||
provide: getRepositoryToken(ApplicationAuthorizationEntity),
|
||||
useValue: {
|
||||
upsert: jest.fn(),
|
||||
findOneBy: jest.fn(),
|
||||
update: jest.fn(),
|
||||
createQueryBuilder: jest.fn(() => queryBuilder),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get(ApplicationAuthorizationService);
|
||||
repository = module.get(
|
||||
getRepositoryToken(ApplicationAuthorizationEntity),
|
||||
) as jest.Mocked<Repository<ApplicationAuthorizationEntity>>;
|
||||
});
|
||||
|
||||
describe('recordAuthorization', () => {
|
||||
it('should upsert on the user and application pair so re-authorizing does not duplicate the row', async () => {
|
||||
await service.recordAuthorization({
|
||||
userId,
|
||||
workspaceId,
|
||||
userWorkspaceId,
|
||||
applicationId,
|
||||
scopes: ['api', 'profile'],
|
||||
});
|
||||
|
||||
expect(repository.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId,
|
||||
workspaceId,
|
||||
userWorkspaceId,
|
||||
applicationId,
|
||||
scopes: ['api', 'profile'],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
conflictPaths: ['userId', 'applicationId'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should clear revokedAt when the user authorizes again', async () => {
|
||||
await service.recordAuthorization({
|
||||
userId,
|
||||
workspaceId,
|
||||
userWorkspaceId,
|
||||
applicationId,
|
||||
scopes: [],
|
||||
});
|
||||
|
||||
expect(repository.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ revokedAt: null }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('backfillAuthorizationFromRefreshToken', () => {
|
||||
const backfill = () =>
|
||||
service.backfillAuthorizationFromRefreshToken({
|
||||
userId,
|
||||
workspaceId,
|
||||
userWorkspaceId,
|
||||
applicationId,
|
||||
});
|
||||
|
||||
it('should leave the consent unrecorded rather than guessing it', async () => {
|
||||
await backfill();
|
||||
|
||||
expect(queryBuilder.values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ scopes: null, lastAuthorizedAt: null }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should never overwrite a row written by a real consent', async () => {
|
||||
await backfill();
|
||||
|
||||
expect(queryBuilder.orIgnore).toHaveBeenCalled();
|
||||
expect(repository.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByUserAndApplication', () => {
|
||||
it('should not filter on revokedAt so callers can tell a revoked grant from a missing one', async () => {
|
||||
repository.findOneBy.mockResolvedValue(null);
|
||||
|
||||
await service.findByUserAndApplication({ userId, applicationId });
|
||||
|
||||
expect(repository.findOneBy).toHaveBeenCalledWith({
|
||||
userId,
|
||||
applicationId,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('findActiveAuthorizationsForUser', () => {
|
||||
it('should only return unrevoked authorizations whose application still exists', async () => {
|
||||
await service.findActiveAuthorizationsForUser(userId);
|
||||
|
||||
expect(queryBuilder.innerJoinAndSelect).toHaveBeenCalledWith(
|
||||
'applicationAuthorization.application',
|
||||
'application',
|
||||
);
|
||||
expect(queryBuilder.where).toHaveBeenCalledWith(
|
||||
'applicationAuthorization.userId = :userId',
|
||||
{ userId },
|
||||
);
|
||||
expect(queryBuilder.andWhere).toHaveBeenCalledWith(
|
||||
'applicationAuthorization.revokedAt IS NULL',
|
||||
);
|
||||
});
|
||||
|
||||
it('should put the most recently used authorization first', async () => {
|
||||
await service.findActiveAuthorizationsForUser(userId);
|
||||
|
||||
expect(queryBuilder.orderBy).toHaveBeenCalledWith(
|
||||
'applicationAuthorization.lastUsedAt',
|
||||
'DESC',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('revokeAuthorizationById', () => {
|
||||
it('should scope the update by userId so an id from another user matches nothing', async () => {
|
||||
repository.update.mockResolvedValue(buildUpdateResult(0));
|
||||
|
||||
const revoked = await service.revokeAuthorizationById({
|
||||
authorizationId,
|
||||
userId: otherUserId,
|
||||
});
|
||||
|
||||
expect(repository.update).toHaveBeenCalledWith(
|
||||
{ id: authorizationId, userId: otherUserId, revokedAt: IsNull() },
|
||||
expect.objectContaining({ revokedAt: expect.any(Date) }),
|
||||
);
|
||||
expect(revoked).toBe(false);
|
||||
});
|
||||
|
||||
it('should report true when the row was still active', async () => {
|
||||
repository.update.mockResolvedValue(buildUpdateResult(1));
|
||||
|
||||
expect(
|
||||
await service.revokeAuthorizationById({ authorizationId, userId }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should report false when the row was already revoked', async () => {
|
||||
repository.update.mockResolvedValue(buildUpdateResult(0));
|
||||
|
||||
expect(
|
||||
await service.revokeAuthorizationById({ authorizationId, userId }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('revokeAuthorizationForApplication', () => {
|
||||
it('should revoke the live row for that user and application', async () => {
|
||||
repository.update.mockResolvedValue(buildUpdateResult(1));
|
||||
|
||||
const revoked = await service.revokeAuthorizationForApplication({
|
||||
userId,
|
||||
applicationId,
|
||||
});
|
||||
|
||||
expect(repository.update).toHaveBeenCalledWith(
|
||||
{ userId, applicationId, revokedAt: IsNull() },
|
||||
expect.objectContaining({ revokedAt: expect.any(Date) }),
|
||||
);
|
||||
expect(revoked).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
Relation,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
// One row per user who has completed an OAuth authorization_code exchange for
|
||||
// an application. Application tokens are stateless JWTs carrying the user as a
|
||||
// claim, so without this row the server has no record that the authorization
|
||||
// happened and nothing to list or revoke. Only authorization_code issues a
|
||||
// refresh token; client_credentials returns an access token alone and involves
|
||||
// no user, so it has no row here.
|
||||
@Entity({ name: 'applicationAuthorization', schema: 'core' })
|
||||
// Re-authorizing the same application updates this row rather than adding a
|
||||
// second one, so a user never accumulates duplicate entries for one app.
|
||||
@Index(
|
||||
'IDX_APPLICATION_AUTHORIZATION_USER_APPLICATION_UNIQUE',
|
||||
['userId', 'applicationId'],
|
||||
{ unique: true },
|
||||
)
|
||||
export class ApplicationAuthorizationEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@ManyToOne(() => UserEntity, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({
|
||||
name: 'userId',
|
||||
foreignKeyConstraintName: 'FK_APPLICATION_AUTHORIZATION_USER_ID',
|
||||
})
|
||||
user: Relation<UserEntity>;
|
||||
|
||||
// No index of its own: it leads the unique index declared on the class, which
|
||||
// already serves both the per-user listing and the cascade delete.
|
||||
@Column({ type: 'uuid' })
|
||||
userId: string;
|
||||
|
||||
@ManyToOne(() => WorkspaceEntity, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({
|
||||
name: 'workspaceId',
|
||||
foreignKeyConstraintName: 'FK_APPLICATION_AUTHORIZATION_WORKSPACE_ID',
|
||||
})
|
||||
workspace: Relation<WorkspaceEntity>;
|
||||
|
||||
@Index('IDX_APPLICATION_AUTHORIZATION_WORKSPACE_ID')
|
||||
@Column({ type: 'uuid' })
|
||||
workspaceId: string;
|
||||
|
||||
// Uninstalling deletes the application row, which already invalidates every
|
||||
// token issued for it. Cascading here stops the grants outliving the install
|
||||
// they describe.
|
||||
@ManyToOne(() => ApplicationEntity, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({
|
||||
name: 'applicationId',
|
||||
foreignKeyConstraintName: 'FK_APPLICATION_AUTHORIZATION_APPLICATION_ID',
|
||||
})
|
||||
application: Relation<ApplicationEntity>;
|
||||
|
||||
@Index('IDX_APPLICATION_AUTHORIZATION_APPLICATION_ID')
|
||||
@Column({ type: 'uuid' })
|
||||
applicationId: string;
|
||||
|
||||
// Cascades only on a hard delete. Removing a member soft-deletes the
|
||||
// membership instead, which leaves this row intact, so the refresh path
|
||||
// rechecks the membership rather than trusting the grant to have gone.
|
||||
@ManyToOne(() => UserWorkspaceEntity, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({
|
||||
name: 'userWorkspaceId',
|
||||
foreignKeyConstraintName: 'FK_APPLICATION_AUTHORIZATION_USER_WORKSPACE_ID',
|
||||
})
|
||||
userWorkspace: Relation<UserWorkspaceEntity>;
|
||||
|
||||
@Index('IDX_APPLICATION_AUTHORIZATION_USER_WORKSPACE_ID')
|
||||
@Column({ type: 'uuid' })
|
||||
userWorkspaceId: string;
|
||||
|
||||
// Scopes as granted at the last exchange, which is what the user consented to
|
||||
// and therefore what the revocation screen should show them. Null on a row
|
||||
// reconstructed from a refresh token that predates this table: those tokens
|
||||
// carry no scope claim, and what the application declares today is not
|
||||
// evidence of what the user agreed to back then.
|
||||
@Column({ type: 'text', array: true, nullable: true })
|
||||
scopes: string[] | null;
|
||||
|
||||
// Null for the same reason, and on the same rows.
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
lastAuthorizedAt: Date | null;
|
||||
|
||||
// Touched on refresh. Refreshes happen at most once per access-token TTL, so
|
||||
// this needs no write throttling of its own.
|
||||
@Column({ type: 'timestamptz' })
|
||||
lastUsedAt: Date;
|
||||
|
||||
// Kept forever once set: a revoked row is what tells a still-signed refresh
|
||||
// token apart from one issued before authorizations were recorded.
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
revokedAt: Date | null;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { ApplicationAuthorizationEntity } from 'src/engine/core-modules/application/application-authorization/application-authorization.entity';
|
||||
import { ApplicationAuthorizationResolver } from 'src/engine/core-modules/application/application-authorization/application-authorization.resolver';
|
||||
import { ApplicationAuthorizationService } from 'src/engine/core-modules/application/application-authorization/services/application-authorization.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([ApplicationAuthorizationEntity])],
|
||||
providers: [
|
||||
ApplicationAuthorizationService,
|
||||
ApplicationAuthorizationResolver,
|
||||
],
|
||||
exports: [ApplicationAuthorizationService],
|
||||
})
|
||||
export class ApplicationAuthorizationModule {}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
|
||||
import { Args, Mutation, Query } from '@nestjs/graphql';
|
||||
|
||||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { type ApplicationAuthorizationEntity } from 'src/engine/core-modules/application/application-authorization/application-authorization.entity';
|
||||
import { ApplicationAuthorizationDTO } from 'src/engine/core-modules/application/application-authorization/dtos/application-authorization.dto';
|
||||
import { ApplicationAuthorizationService } from 'src/engine/core-modules/application/application-authorization/services/application-authorization.service';
|
||||
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
|
||||
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
|
||||
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
|
||||
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
|
||||
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
|
||||
|
||||
// User-scoped on purpose: these are the applications this person authorized,
|
||||
// which they may revoke for themselves without affecting anyone else. Removing
|
||||
// an integration for the whole workspace is uninstalling it, an admin action
|
||||
// that lives elsewhere.
|
||||
@UsePipes(ResolverValidationPipe)
|
||||
@UseFilters(AuthGraphqlApiExceptionFilter)
|
||||
@MetadataResolver()
|
||||
export class ApplicationAuthorizationResolver {
|
||||
constructor(
|
||||
private readonly applicationAuthorizationService: ApplicationAuthorizationService,
|
||||
) {}
|
||||
|
||||
@Query(() => [ApplicationAuthorizationDTO])
|
||||
@UseGuards(UserAuthGuard, NoPermissionGuard)
|
||||
async currentUserApplicationAuthorizations(
|
||||
@AuthUser() user: AuthContextUser,
|
||||
): Promise<ApplicationAuthorizationDTO[]> {
|
||||
const authorizations =
|
||||
await this.applicationAuthorizationService.findActiveAuthorizationsForUser(
|
||||
user.id,
|
||||
);
|
||||
|
||||
return authorizations.map((authorization) =>
|
||||
this.toApplicationAuthorizationDTO(authorization),
|
||||
);
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
@UseGuards(UserAuthGuard, NoPermissionGuard)
|
||||
async revokeApplicationAuthorization(
|
||||
@AuthUser() user: AuthContextUser,
|
||||
@Args('applicationAuthorizationId', { type: () => UUIDScalarType })
|
||||
applicationAuthorizationId: string,
|
||||
): Promise<boolean> {
|
||||
return await this.applicationAuthorizationService.revokeAuthorizationById({
|
||||
authorizationId: applicationAuthorizationId,
|
||||
userId: user.id,
|
||||
});
|
||||
}
|
||||
|
||||
private toApplicationAuthorizationDTO(
|
||||
authorization: ApplicationAuthorizationEntity,
|
||||
): ApplicationAuthorizationDTO {
|
||||
return {
|
||||
id: authorization.id,
|
||||
applicationId: authorization.applicationId,
|
||||
workspaceId: authorization.workspaceId,
|
||||
applicationName: authorization.application.name,
|
||||
applicationUniversalIdentifier:
|
||||
authorization.application.universalIdentifier,
|
||||
scopes: authorization.scopes,
|
||||
lastAuthorizedAt: authorization.lastAuthorizedAt,
|
||||
lastUsedAt: authorization.lastUsedAt,
|
||||
createdAt: authorization.createdAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
|
||||
@ObjectType('ApplicationAuthorization')
|
||||
export class ApplicationAuthorizationDTO {
|
||||
@Field(() => UUIDScalarType)
|
||||
id: string;
|
||||
|
||||
@Field(() => UUIDScalarType)
|
||||
applicationId: string;
|
||||
|
||||
@Field(() => UUIDScalarType)
|
||||
workspaceId: string;
|
||||
|
||||
@Field(() => String)
|
||||
applicationName: string;
|
||||
|
||||
// Two custom applications in a workspace can carry the same name, so the
|
||||
// name alone cannot tell the user which authorization they are revoking.
|
||||
@Field(() => String, { nullable: true })
|
||||
applicationUniversalIdentifier: string | null;
|
||||
|
||||
// Null when the grant was reconstructed from a refresh token predating the
|
||||
// authorization record, so the screen can say the original consent is
|
||||
// unknown instead of inventing one.
|
||||
@Field(() => [String], { nullable: true })
|
||||
scopes: string[] | null;
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
lastAuthorizedAt: Date | null;
|
||||
|
||||
@Field(() => Date)
|
||||
lastUsedAt: Date;
|
||||
|
||||
@Field(() => Date)
|
||||
createdAt: Date;
|
||||
}
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { IsNull, Repository } from 'typeorm';
|
||||
|
||||
import { ApplicationAuthorizationEntity } from 'src/engine/core-modules/application/application-authorization/application-authorization.entity';
|
||||
|
||||
@Injectable()
|
||||
export class ApplicationAuthorizationService {
|
||||
constructor(
|
||||
@InjectRepository(ApplicationAuthorizationEntity)
|
||||
private readonly applicationAuthorizationRepository: Repository<ApplicationAuthorizationEntity>,
|
||||
) {}
|
||||
|
||||
// Re-authorizing an application the user previously revoked reinstates the
|
||||
// same row: they have just consented again, so the revocation is spent.
|
||||
async recordAuthorization({
|
||||
userId,
|
||||
workspaceId,
|
||||
userWorkspaceId,
|
||||
applicationId,
|
||||
scopes,
|
||||
}: {
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
userWorkspaceId: string;
|
||||
applicationId: string;
|
||||
scopes: string[];
|
||||
}): Promise<void> {
|
||||
const now = new Date();
|
||||
|
||||
await this.applicationAuthorizationRepository.upsert(
|
||||
{
|
||||
userId,
|
||||
workspaceId,
|
||||
userWorkspaceId,
|
||||
applicationId,
|
||||
scopes,
|
||||
lastAuthorizedAt: now,
|
||||
lastUsedAt: now,
|
||||
revokedAt: null,
|
||||
},
|
||||
{
|
||||
conflictPaths: ['userId', 'applicationId'],
|
||||
skipUpdateIfNoValuesChanged: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Stands in for the consent event that happened before this table existed.
|
||||
// The refresh token proves the authorization took place but carries no scope
|
||||
// claim and no timestamp for it, so both are left null instead of being
|
||||
// guessed from what the application declares today. Insert-only, so it can
|
||||
// never overwrite a row written by a real consent.
|
||||
async backfillAuthorizationFromRefreshToken({
|
||||
userId,
|
||||
workspaceId,
|
||||
userWorkspaceId,
|
||||
applicationId,
|
||||
}: {
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
userWorkspaceId: string;
|
||||
applicationId: string;
|
||||
}): Promise<void> {
|
||||
await this.applicationAuthorizationRepository
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.values({
|
||||
userId,
|
||||
workspaceId,
|
||||
userWorkspaceId,
|
||||
applicationId,
|
||||
scopes: null,
|
||||
lastAuthorizedAt: null,
|
||||
lastUsedAt: new Date(),
|
||||
revokedAt: null,
|
||||
})
|
||||
.orIgnore()
|
||||
.execute();
|
||||
}
|
||||
|
||||
// Returns revoked rows too: the caller has to tell "never authorized" apart
|
||||
// from "authorized then revoked", which are opposite answers.
|
||||
async findByUserAndApplication({
|
||||
userId,
|
||||
applicationId,
|
||||
}: {
|
||||
userId: string;
|
||||
applicationId: string;
|
||||
}): Promise<ApplicationAuthorizationEntity | null> {
|
||||
return await this.applicationAuthorizationRepository.findOneBy({
|
||||
userId,
|
||||
applicationId,
|
||||
});
|
||||
}
|
||||
|
||||
// Inner join, so an application that has been soft-deleted takes its
|
||||
// authorizations off the list rather than surfacing them with nothing to
|
||||
// name them.
|
||||
async findActiveAuthorizationsForUser(
|
||||
userId: string,
|
||||
): Promise<ApplicationAuthorizationEntity[]> {
|
||||
return await this.applicationAuthorizationRepository
|
||||
.createQueryBuilder('applicationAuthorization')
|
||||
.innerJoinAndSelect('applicationAuthorization.application', 'application')
|
||||
.where('applicationAuthorization.userId = :userId', { userId })
|
||||
.andWhere('applicationAuthorization.revokedAt IS NULL')
|
||||
.orderBy('applicationAuthorization.lastUsedAt', 'DESC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async touchLastUsedAt(authorizationId: string): Promise<void> {
|
||||
await this.applicationAuthorizationRepository.update(
|
||||
{ id: authorizationId },
|
||||
{ lastUsedAt: new Date() },
|
||||
);
|
||||
}
|
||||
|
||||
// Scoped by userId in the UPDATE itself rather than read-then-write, so one
|
||||
// user can never revoke another user's authorization by guessing an id.
|
||||
async revokeAuthorizationById({
|
||||
authorizationId,
|
||||
userId,
|
||||
}: {
|
||||
authorizationId: string;
|
||||
userId: string;
|
||||
}): Promise<boolean> {
|
||||
return await this.revokeMatching({
|
||||
id: authorizationId,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
async revokeAuthorizationForApplication({
|
||||
userId,
|
||||
applicationId,
|
||||
}: {
|
||||
userId: string;
|
||||
applicationId: string;
|
||||
}): Promise<boolean> {
|
||||
return await this.revokeMatching({ userId, applicationId });
|
||||
}
|
||||
|
||||
// Returns whether this call was the one that revoked it, so a repeated
|
||||
// revocation reports false rather than moving revokedAt forward. The union
|
||||
// rules out an empty criteria object, which would revoke every row.
|
||||
private async revokeMatching(
|
||||
criteria:
|
||||
| { id: string; userId: string }
|
||||
| { userId: string; applicationId: string },
|
||||
): Promise<boolean> {
|
||||
const { affected } = await this.applicationAuthorizationRepository.update(
|
||||
{ ...criteria, revokedAt: IsNull() },
|
||||
{ revokedAt: new Date() },
|
||||
);
|
||||
|
||||
return isDefined(affected) && affected > 0;
|
||||
}
|
||||
}
|
||||
+489
@@ -0,0 +1,489 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import { type ApplicationAuthorizationEntity } from 'src/engine/core-modules/application/application-authorization/application-authorization.entity';
|
||||
import { ApplicationAuthorizationService } from 'src/engine/core-modules/application/application-authorization/services/application-authorization.service';
|
||||
import { ApplicationInstallService } from 'src/engine/core-modules/application/application-install/application-install.service';
|
||||
import { OAuthService } from 'src/engine/core-modules/application/application-oauth/oauth.service';
|
||||
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
|
||||
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
|
||||
describe('OAuthService', () => {
|
||||
let service: OAuthService;
|
||||
|
||||
const clientId = 'client-1';
|
||||
const clientSecret = 'client-secret';
|
||||
const userId = 'user-1';
|
||||
const workspaceId = 'workspace-1';
|
||||
const userWorkspaceId = 'user-workspace-1';
|
||||
const applicationId = 'application-1';
|
||||
const applicationRegistrationId = 'application-registration-1';
|
||||
const redirectUri = 'https://app.example.com/callback';
|
||||
const authorizationId = 'authorization-1';
|
||||
|
||||
const applicationRegistration = {
|
||||
id: applicationRegistrationId,
|
||||
name: 'Example',
|
||||
universalIdentifier: 'example',
|
||||
latestAvailableVersion: '1.0.0',
|
||||
oAuthClientSecretHash: 'hash',
|
||||
oAuthScopes: ['api', 'profile'],
|
||||
};
|
||||
|
||||
const application = {
|
||||
id: applicationId,
|
||||
workspaceId,
|
||||
applicationRegistrationId,
|
||||
};
|
||||
|
||||
const refreshTokenPayload = {
|
||||
sub: applicationId,
|
||||
type: JwtTokenTypeEnum.APPLICATION_REFRESH,
|
||||
applicationId,
|
||||
workspaceId,
|
||||
userWorkspaceId,
|
||||
userId,
|
||||
};
|
||||
|
||||
const appTokenRepository = { findOne: jest.fn(), update: jest.fn() };
|
||||
const applicationRepository = { find: jest.fn(), findOne: jest.fn() };
|
||||
const userWorkspaceRepository = { findOne: jest.fn() };
|
||||
|
||||
const applicationTokenService = {
|
||||
generateApplicationTokenPair: jest.fn(),
|
||||
generateApplicationAccessToken: jest.fn(),
|
||||
validateApplicationRefreshToken: jest.fn(),
|
||||
validateApplicationAccessToken: jest.fn(),
|
||||
renewApplicationTokens: jest.fn(),
|
||||
decodeToken: jest.fn(),
|
||||
};
|
||||
|
||||
const applicationAuthorizationService = {
|
||||
recordAuthorization: jest.fn(),
|
||||
backfillAuthorizationFromRefreshToken: jest.fn(),
|
||||
findByUserAndApplication: jest.fn(),
|
||||
touchLastUsedAt: jest.fn(),
|
||||
revokeAuthorizationForApplication: jest.fn(),
|
||||
};
|
||||
|
||||
const applicationRegistrationService = {
|
||||
findOneByClientId: jest.fn(),
|
||||
verifyClientSecret: jest.fn(),
|
||||
};
|
||||
|
||||
const buildAuthorization = (
|
||||
overrides: Partial<ApplicationAuthorizationEntity> = {},
|
||||
): ApplicationAuthorizationEntity =>
|
||||
({
|
||||
id: authorizationId,
|
||||
userId,
|
||||
workspaceId,
|
||||
userWorkspaceId,
|
||||
applicationId,
|
||||
scopes: ['api'],
|
||||
revokedAt: null,
|
||||
...overrides,
|
||||
}) as ApplicationAuthorizationEntity;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
applicationRegistrationService.findOneByClientId.mockResolvedValue(
|
||||
applicationRegistration,
|
||||
);
|
||||
applicationRegistrationService.verifyClientSecret.mockResolvedValue(true);
|
||||
applicationRepository.findOne.mockResolvedValue(application);
|
||||
userWorkspaceRepository.findOne.mockResolvedValue({ id: userWorkspaceId });
|
||||
applicationTokenService.generateApplicationTokenPair.mockResolvedValue({
|
||||
applicationAccessToken: { token: 'access-token', expiresAt: new Date() },
|
||||
applicationRefreshToken: {
|
||||
token: 'refresh-token',
|
||||
expiresAt: new Date(),
|
||||
},
|
||||
});
|
||||
applicationTokenService.renewApplicationTokens.mockResolvedValue({
|
||||
applicationAccessToken: { token: 'access-token', expiresAt: new Date() },
|
||||
applicationRefreshToken: {
|
||||
token: 'refresh-token',
|
||||
expiresAt: new Date(),
|
||||
},
|
||||
});
|
||||
applicationTokenService.validateApplicationRefreshToken.mockResolvedValue(
|
||||
refreshTokenPayload,
|
||||
);
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
OAuthService,
|
||||
{
|
||||
provide: getRepositoryToken(AppTokenEntity),
|
||||
useValue: appTokenRepository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(ApplicationEntity),
|
||||
useValue: applicationRepository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(UserWorkspaceEntity),
|
||||
useValue: userWorkspaceRepository,
|
||||
},
|
||||
{
|
||||
provide: ApplicationTokenService,
|
||||
useValue: applicationTokenService,
|
||||
},
|
||||
{
|
||||
provide: ApplicationAuthorizationService,
|
||||
useValue: applicationAuthorizationService,
|
||||
},
|
||||
{
|
||||
provide: ApplicationRegistrationService,
|
||||
useValue: applicationRegistrationService,
|
||||
},
|
||||
{ provide: ApplicationService, useValue: { create: jest.fn() } },
|
||||
{
|
||||
provide: ApplicationInstallService,
|
||||
useValue: { installApplication: jest.fn() },
|
||||
},
|
||||
{
|
||||
provide: TwentyConfigService,
|
||||
useValue: { get: jest.fn(() => '30m') },
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get(OAuthService);
|
||||
});
|
||||
|
||||
describe('exchangeAuthorizationCode', () => {
|
||||
const exchange = () =>
|
||||
service.exchangeAuthorizationCode({
|
||||
authorizationCode: 'code',
|
||||
clientId,
|
||||
clientSecret,
|
||||
redirectUri,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
appTokenRepository.findOne.mockResolvedValue({
|
||||
id: 'app-token-1',
|
||||
userId,
|
||||
workspaceId,
|
||||
revokedAt: null,
|
||||
expiresAt: new Date(Date.now() + 60 * 1000),
|
||||
context: { clientId, redirectUri, scope: 'api profile' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should record the authorization with the granted scopes', async () => {
|
||||
await exchange();
|
||||
|
||||
expect(
|
||||
applicationAuthorizationService.recordAuthorization,
|
||||
).toHaveBeenCalledWith({
|
||||
userId,
|
||||
workspaceId,
|
||||
userWorkspaceId,
|
||||
applicationId,
|
||||
scopes: ['api', 'profile'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should record the authorization before issuing the token pair', async () => {
|
||||
await exchange();
|
||||
|
||||
expect(
|
||||
applicationAuthorizationService.recordAuthorization.mock
|
||||
.invocationCallOrder[0],
|
||||
).toBeLessThan(
|
||||
applicationTokenService.generateApplicationTokenPair.mock
|
||||
.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it('should record no scopes when the authorization carried an empty scope', async () => {
|
||||
appTokenRepository.findOne.mockResolvedValue({
|
||||
id: 'app-token-1',
|
||||
userId,
|
||||
workspaceId,
|
||||
revokedAt: null,
|
||||
expiresAt: new Date(Date.now() + 60 * 1000),
|
||||
context: { clientId, redirectUri, scope: '' },
|
||||
});
|
||||
|
||||
await exchange();
|
||||
|
||||
expect(
|
||||
applicationAuthorizationService.recordAuthorization,
|
||||
).toHaveBeenCalledWith(expect.objectContaining({ scopes: [] }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshTokenGrant', () => {
|
||||
const refresh = () =>
|
||||
service.refreshTokenGrant({
|
||||
refreshToken: 'refresh-token',
|
||||
clientId,
|
||||
clientSecret,
|
||||
});
|
||||
|
||||
it('should refuse to renew once the user revoked the application', async () => {
|
||||
applicationAuthorizationService.findByUserAndApplication.mockResolvedValue(
|
||||
buildAuthorization({ revokedAt: new Date() }),
|
||||
);
|
||||
|
||||
const result = await refresh();
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({ error: 'invalid_grant' }),
|
||||
);
|
||||
expect(
|
||||
applicationTokenService.renewApplicationTokens,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should renew and touch the authorization when it is still live', async () => {
|
||||
applicationAuthorizationService.findByUserAndApplication.mockResolvedValue(
|
||||
buildAuthorization(),
|
||||
);
|
||||
|
||||
const result = await refresh();
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({ access_token: 'access-token' }),
|
||||
);
|
||||
expect(
|
||||
applicationAuthorizationService.touchLastUsedAt,
|
||||
).toHaveBeenCalledWith(authorizationId);
|
||||
});
|
||||
|
||||
it('should backfill the missing grant for a refresh token issued before authorizations were recorded', async () => {
|
||||
applicationAuthorizationService.findByUserAndApplication.mockResolvedValue(
|
||||
null,
|
||||
);
|
||||
|
||||
const result = await refresh();
|
||||
|
||||
expect(
|
||||
applicationAuthorizationService.backfillAuthorizationFromRefreshToken,
|
||||
).toHaveBeenCalledWith({
|
||||
userId,
|
||||
workspaceId,
|
||||
userWorkspaceId,
|
||||
applicationId,
|
||||
});
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({ access_token: 'access-token' }),
|
||||
);
|
||||
});
|
||||
|
||||
// The token carries no scope claim, so what the registration declares now
|
||||
// is not evidence of what this user agreed to.
|
||||
it('should not pass the registration scopes off as the backfilled consent', async () => {
|
||||
applicationAuthorizationService.findByUserAndApplication.mockResolvedValue(
|
||||
null,
|
||||
);
|
||||
|
||||
await refresh();
|
||||
|
||||
expect(
|
||||
applicationAuthorizationService.backfillAuthorizationFromRefreshToken,
|
||||
).toHaveBeenCalledWith(expect.not.objectContaining({ scopes: [] }));
|
||||
expect(
|
||||
applicationAuthorizationService.recordAuthorization,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should refuse to renew when the user is no longer a member of the workspace', async () => {
|
||||
applicationAuthorizationService.findByUserAndApplication.mockResolvedValue(
|
||||
null,
|
||||
);
|
||||
userWorkspaceRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
const result = await refresh();
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({ error: 'invalid_grant' }),
|
||||
);
|
||||
expect(
|
||||
applicationAuthorizationService.backfillAuthorizationFromRefreshToken,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Removing a member soft-deletes the membership, so the grant outlives it.
|
||||
it('should refuse to renew an existing grant once the membership is gone', async () => {
|
||||
applicationAuthorizationService.findByUserAndApplication.mockResolvedValue(
|
||||
buildAuthorization(),
|
||||
);
|
||||
userWorkspaceRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
const result = await refresh();
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({ error: 'invalid_grant' }),
|
||||
);
|
||||
expect(
|
||||
applicationTokenService.renewApplicationTokens,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(
|
||||
applicationAuthorizationService.touchLastUsedAt,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not look for an authorization when the token carries no user', async () => {
|
||||
applicationTokenService.validateApplicationRefreshToken.mockResolvedValue(
|
||||
{
|
||||
...refreshTokenPayload,
|
||||
userId: undefined,
|
||||
userWorkspaceId: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
const result = await refresh();
|
||||
|
||||
expect(
|
||||
applicationAuthorizationService.findByUserAndApplication,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({ access_token: 'access-token' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('revokeToken', () => {
|
||||
it('should revoke the underlying authorization', async () => {
|
||||
const result = await service.revokeToken({
|
||||
token: 'refresh-token',
|
||||
clientId,
|
||||
clientSecret,
|
||||
});
|
||||
|
||||
expect(
|
||||
applicationAuthorizationService.revokeAuthorizationForApplication,
|
||||
).toHaveBeenCalledWith({ userId, applicationId });
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
// Without the row, the refresh path would find nothing and backfill a
|
||||
// fresh active grant, so revoking a token that predates the record would
|
||||
// silently do nothing.
|
||||
it('should lay down the row before revoking so a pre-record token stays revoked', async () => {
|
||||
await service.revokeToken({
|
||||
token: 'refresh-token',
|
||||
clientId,
|
||||
clientSecret,
|
||||
});
|
||||
|
||||
expect(
|
||||
applicationAuthorizationService.backfillAuthorizationFromRefreshToken
|
||||
.mock.invocationCallOrder[0],
|
||||
).toBeLessThan(
|
||||
applicationAuthorizationService.revokeAuthorizationForApplication.mock
|
||||
.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it('should still revoke when the membership is gone', async () => {
|
||||
userWorkspaceRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await service.revokeToken({
|
||||
token: 'refresh-token',
|
||||
clientId,
|
||||
clientSecret,
|
||||
});
|
||||
|
||||
expect(
|
||||
applicationAuthorizationService.backfillAuthorizationFromRefreshToken,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(
|
||||
applicationAuthorizationService.revokeAuthorizationForApplication,
|
||||
).toHaveBeenCalledWith({ userId, applicationId });
|
||||
});
|
||||
|
||||
it('should stay a no-op for a token that carries no user', async () => {
|
||||
applicationTokenService.validateApplicationRefreshToken.mockResolvedValue(
|
||||
{
|
||||
...refreshTokenPayload,
|
||||
userId: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
const result = await service.revokeToken({
|
||||
token: 'refresh-token',
|
||||
clientId,
|
||||
clientSecret,
|
||||
});
|
||||
|
||||
expect(
|
||||
applicationAuthorizationService.revokeAuthorizationForApplication,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('should refuse to revoke an authorization the asking client was not issued', async () => {
|
||||
applicationRepository.findOne.mockResolvedValue({
|
||||
...application,
|
||||
applicationRegistrationId: 'another-registration',
|
||||
});
|
||||
|
||||
const result = await service.revokeToken({
|
||||
token: 'refresh-token',
|
||||
clientId,
|
||||
clientSecret,
|
||||
});
|
||||
|
||||
expect(
|
||||
applicationAuthorizationService.revokeAuthorizationForApplication,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('should stay a no-op when the caller did not identify itself as a client', async () => {
|
||||
const result = await service.revokeToken({ token: 'refresh-token' });
|
||||
|
||||
expect(
|
||||
applicationAuthorizationService.revokeAuthorizationForApplication,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('introspectToken', () => {
|
||||
beforeEach(() => {
|
||||
applicationTokenService.decodeToken.mockReturnValue(refreshTokenPayload);
|
||||
});
|
||||
|
||||
it('should report a refresh token inactive once its authorization is revoked', async () => {
|
||||
applicationAuthorizationService.findByUserAndApplication.mockResolvedValue(
|
||||
buildAuthorization({ revokedAt: new Date() }),
|
||||
);
|
||||
|
||||
expect(
|
||||
await service.introspectToken({
|
||||
token: 'refresh-token',
|
||||
clientId,
|
||||
clientSecret,
|
||||
}),
|
||||
).toEqual({ active: false });
|
||||
});
|
||||
|
||||
it('should report a refresh token active while its authorization stands', async () => {
|
||||
applicationAuthorizationService.findByUserAndApplication.mockResolvedValue(
|
||||
buildAuthorization(),
|
||||
);
|
||||
|
||||
expect(
|
||||
await service.introspectToken({
|
||||
token: 'refresh-token',
|
||||
clientId,
|
||||
clientSecret,
|
||||
}),
|
||||
).toEqual(expect.objectContaining({ active: true }));
|
||||
});
|
||||
});
|
||||
});
|
||||
+2
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import { ApplicationAuthorizationModule } from 'src/engine/core-modules/application/application-authorization/application-authorization.module';
|
||||
import { ApplicationInstallModule } from 'src/engine/core-modules/application/application-install/application-install.module';
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
@@ -30,6 +31,7 @@ import { DomainServerConfigModule } from 'src/engine/core-modules/domain/domain-
|
||||
UserWorkspaceEntity,
|
||||
]),
|
||||
ApplicationRegistrationModule,
|
||||
ApplicationAuthorizationModule,
|
||||
ApplicationCoreModule,
|
||||
ApplicationInstallModule,
|
||||
TokenModule,
|
||||
|
||||
+182
-7
@@ -5,12 +5,13 @@ import crypto from 'crypto';
|
||||
|
||||
import ms from 'ms';
|
||||
import { Repository } from 'typeorm';
|
||||
import { base64UrlEncode } from 'twenty-shared/utils';
|
||||
import { base64UrlEncode, isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
AppTokenEntity,
|
||||
AppTokenType,
|
||||
} from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import { ApplicationAuthorizationService } from 'src/engine/core-modules/application/application-authorization/services/application-authorization.service';
|
||||
import { ApplicationInstallService } from 'src/engine/core-modules/application/application-install/application-install.service';
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
|
||||
@@ -34,6 +35,7 @@ export class OAuthService {
|
||||
@InjectRepository(UserWorkspaceEntity)
|
||||
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
|
||||
private readonly applicationTokenService: ApplicationTokenService,
|
||||
private readonly applicationAuthorizationService: ApplicationAuthorizationService,
|
||||
private readonly applicationRegistrationService: ApplicationRegistrationService,
|
||||
private readonly applicationService: ApplicationService,
|
||||
private readonly applicationInstallService: ApplicationInstallService,
|
||||
@@ -219,6 +221,20 @@ export class OAuthService {
|
||||
);
|
||||
}
|
||||
|
||||
const grantedScope =
|
||||
authCodeToken.context?.scope ??
|
||||
applicationRegistration.oAuthScopes.join(' ');
|
||||
|
||||
// Recorded before the tokens exist, so a refresh token is never handed out
|
||||
// without the grant that makes it redeemable and revocable.
|
||||
await this.applicationAuthorizationService.recordAuthorization({
|
||||
userId: authCodeToken.userId,
|
||||
workspaceId: authCodeToken.workspaceId,
|
||||
userWorkspaceId: userWorkspace.id,
|
||||
applicationId: application.id,
|
||||
scopes: this.parseScopes(grantedScope),
|
||||
});
|
||||
|
||||
const { applicationAccessToken, applicationRefreshToken } =
|
||||
await this.applicationTokenService.generateApplicationTokenPair({
|
||||
workspaceId: authCodeToken.workspaceId,
|
||||
@@ -227,10 +243,6 @@ export class OAuthService {
|
||||
userWorkspaceId: userWorkspace.id,
|
||||
});
|
||||
|
||||
const grantedScope =
|
||||
authCodeToken.context?.scope ??
|
||||
applicationRegistration.oAuthScopes.join(' ');
|
||||
|
||||
this.logger.log(
|
||||
`Authorization code exchanged: client=${clientId} workspace=${authCodeToken.workspaceId} user=${authCodeToken.userId}`,
|
||||
);
|
||||
@@ -360,6 +372,18 @@ export class OAuthService {
|
||||
);
|
||||
}
|
||||
|
||||
if (isDefined(payload.userId)) {
|
||||
const authorizationError = await this.consumeUserAuthorization({
|
||||
userId: payload.userId,
|
||||
workspaceId: payload.workspaceId,
|
||||
applicationId: application.id,
|
||||
});
|
||||
|
||||
if (authorizationError) {
|
||||
return authorizationError;
|
||||
}
|
||||
}
|
||||
|
||||
const { applicationAccessToken, applicationRefreshToken } =
|
||||
await this.applicationTokenService.renewApplicationTokens(payload);
|
||||
|
||||
@@ -393,6 +417,8 @@ export class OAuthService {
|
||||
}): Promise<{ success: boolean }> {
|
||||
const { token, clientId, clientSecret } = params;
|
||||
|
||||
let applicationRegistration: ApplicationRegistrationEntity | undefined;
|
||||
|
||||
if (clientId) {
|
||||
const clientValidation = await this.validateClient(clientId);
|
||||
|
||||
@@ -410,16 +436,35 @@ export class OAuthService {
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
applicationRegistration = clientValidation;
|
||||
}
|
||||
|
||||
// Since our tokens are stateless JWTs, we can't truly revoke them.
|
||||
// We validate the token to log that revocation was requested.
|
||||
try {
|
||||
const payload =
|
||||
await this.applicationTokenService.validateApplicationRefreshToken(
|
||||
token,
|
||||
);
|
||||
|
||||
// RFC 7009 §2.1: revoking a refresh token invalidates the authorization
|
||||
// behind it, and only the client the token was issued to may ask for
|
||||
// that. Access tokens stay stateless and live out their few minutes.
|
||||
if (isDefined(applicationRegistration) && isDefined(payload.userId)) {
|
||||
const application = await this.applicationRepository.findOne({
|
||||
where: { id: payload.applicationId },
|
||||
});
|
||||
|
||||
if (
|
||||
application?.applicationRegistrationId === applicationRegistration.id
|
||||
) {
|
||||
await this.revokeUserAuthorization({
|
||||
userId: payload.userId,
|
||||
workspaceId: payload.workspaceId,
|
||||
applicationId: payload.applicationId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Token revocation requested for application ${payload.applicationId}`,
|
||||
);
|
||||
@@ -477,6 +522,16 @@ export class OAuthService {
|
||||
return { active: false };
|
||||
}
|
||||
|
||||
if (
|
||||
isDefined(decoded.userId) &&
|
||||
(await this.isAuthorizationRevoked({
|
||||
userId: decoded.userId,
|
||||
applicationId: decoded.applicationId,
|
||||
}))
|
||||
) {
|
||||
return { active: false };
|
||||
}
|
||||
|
||||
return {
|
||||
active: true,
|
||||
sub: decoded.sub,
|
||||
@@ -552,6 +607,126 @@ export class OAuthService {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Refresh tokens issued before authorizations were recorded have no row to
|
||||
// check against. Rejecting them would sign every live integration out the
|
||||
// moment this ships, so the first refresh backfills the grant that was always
|
||||
// implied. A revoked authorization keeps its row, so this never resurrects
|
||||
// access the user turned off.
|
||||
private async consumeUserAuthorization({
|
||||
userId,
|
||||
workspaceId,
|
||||
applicationId,
|
||||
}: {
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
applicationId: string;
|
||||
}): Promise<OAuthErrorResponse | null> {
|
||||
const authorization =
|
||||
await this.applicationAuthorizationService.findByUserAndApplication({
|
||||
userId,
|
||||
applicationId,
|
||||
});
|
||||
|
||||
if (isDefined(authorization?.revokedAt)) {
|
||||
return this.errorResponse(
|
||||
'invalid_grant',
|
||||
'The user revoked this application access',
|
||||
);
|
||||
}
|
||||
|
||||
// Rechecked on every refresh, not just when backfilling: removing a member
|
||||
// soft-deletes the membership, so an existing grant outlives it and nothing
|
||||
// else in this path would notice.
|
||||
const userWorkspace = await this.userWorkspaceRepository.findOne({
|
||||
where: { userId, workspaceId },
|
||||
});
|
||||
|
||||
if (!userWorkspace) {
|
||||
return this.errorResponse(
|
||||
'invalid_grant',
|
||||
'User no longer has access to this workspace',
|
||||
);
|
||||
}
|
||||
|
||||
if (!isDefined(authorization)) {
|
||||
await this.applicationAuthorizationService.backfillAuthorizationFromRefreshToken(
|
||||
{
|
||||
userId,
|
||||
workspaceId,
|
||||
userWorkspaceId: userWorkspace.id,
|
||||
applicationId,
|
||||
},
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
await this.applicationAuthorizationService.touchLastUsedAt(
|
||||
authorization.id,
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// A token predating the authorization record has no row to mark revoked, and
|
||||
// the refresh path would then happily backfill a fresh active one. Lay the
|
||||
// row down first so the revocation has something to stick to. If the
|
||||
// membership is gone the refresh already fails on that, so there is nothing
|
||||
// worth recording.
|
||||
private async revokeUserAuthorization({
|
||||
userId,
|
||||
workspaceId,
|
||||
applicationId,
|
||||
}: {
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
applicationId: string;
|
||||
}): Promise<void> {
|
||||
const userWorkspace = await this.userWorkspaceRepository.findOne({
|
||||
where: { userId, workspaceId },
|
||||
});
|
||||
|
||||
if (isDefined(userWorkspace)) {
|
||||
await this.applicationAuthorizationService.backfillAuthorizationFromRefreshToken(
|
||||
{
|
||||
userId,
|
||||
workspaceId,
|
||||
userWorkspaceId: userWorkspace.id,
|
||||
applicationId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
await this.applicationAuthorizationService.revokeAuthorizationForApplication(
|
||||
{
|
||||
userId,
|
||||
applicationId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private async isAuthorizationRevoked({
|
||||
userId,
|
||||
applicationId,
|
||||
}: {
|
||||
userId: string;
|
||||
applicationId: string;
|
||||
}): Promise<boolean> {
|
||||
const authorization =
|
||||
await this.applicationAuthorizationService.findByUserAndApplication({
|
||||
userId,
|
||||
applicationId,
|
||||
});
|
||||
|
||||
return isDefined(authorization?.revokedAt);
|
||||
}
|
||||
|
||||
// RFC 6749 §3.3: scope is a space-delimited list, so an empty value has to
|
||||
// collapse to no scopes rather than to one blank one.
|
||||
private parseScopes(scope: string): string[] {
|
||||
return scope.split(' ').filter((entry) => entry.length > 0);
|
||||
}
|
||||
|
||||
private async findOrInstallApplication(
|
||||
applicationRegistration: ApplicationRegistrationEntity,
|
||||
workspaceId: string,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { WorkspaceQueryRunnerModule } from 'src/engine/api/graphql/workspace-que
|
||||
import { ActorModule } from 'src/engine/core-modules/actor/actor.module';
|
||||
import { AdminPanelModule } from 'src/engine/core-modules/admin-panel/admin-panel.module';
|
||||
import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
|
||||
import { ApplicationAuthorizationModule } from 'src/engine/core-modules/application/application-authorization/application-authorization.module';
|
||||
import { ApplicationDevelopmentModule } from 'src/engine/core-modules/application/application-development/application-development.module';
|
||||
import { ApplicationInstallModule } from 'src/engine/core-modules/application/application-install/application-install.module';
|
||||
import { MarketplaceModule } from 'src/engine/core-modules/application/application-marketplace/marketplace.module';
|
||||
@@ -102,6 +103,7 @@ import { FileModule } from './file/file.module';
|
||||
WellKnownModule,
|
||||
ApplicationRegistrationModule,
|
||||
ApplicationOAuthModule,
|
||||
ApplicationAuthorizationModule,
|
||||
ApplicationModule,
|
||||
ApplicationInstallModule,
|
||||
ApplicationUpgradeModule,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
import bcrypt from 'bcrypt';
|
||||
import gql from 'graphql-tag';
|
||||
import request from 'supertest';
|
||||
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
|
||||
import { base64UrlEncode } from 'twenty-shared/utils';
|
||||
import { type DataSource } from 'typeorm';
|
||||
|
||||
@@ -895,4 +897,286 @@ describe('OAuth (integration)', () => {
|
||||
.expect(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Per-user authorizations', () => {
|
||||
const exchangeForTokens = async (
|
||||
scope = 'read write',
|
||||
): Promise<{
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}> => {
|
||||
const code = crypto.randomBytes(42).toString('hex');
|
||||
const hashedCode = crypto.createHash('sha256').update(code).digest('hex');
|
||||
|
||||
const tokenId = await insertAppToken(ds, {
|
||||
value: hashedCode,
|
||||
type: AppTokenType.AuthorizationCode,
|
||||
userId: TEST_USER_ID,
|
||||
workspaceId: TEST_WORKSPACE_ID,
|
||||
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
|
||||
context: {
|
||||
redirectUri: 'https://example.com/callback',
|
||||
clientId: testRegistration.oAuthClientId,
|
||||
scope,
|
||||
},
|
||||
});
|
||||
|
||||
createdEntityIds.tokens.push(tokenId);
|
||||
|
||||
const res = await postToken({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
client_id: testRegistration.oAuthClientId,
|
||||
client_secret: testClientSecret,
|
||||
redirect_uri: 'https://example.com/callback',
|
||||
}).expect(200);
|
||||
|
||||
return {
|
||||
accessToken: res.body.access_token,
|
||||
refreshToken: res.body.refresh_token,
|
||||
};
|
||||
};
|
||||
|
||||
const findAuthorization = async () => {
|
||||
const [authorization] = await ds.query(
|
||||
`SELECT "scopes", "lastAuthorizedAt", "revokedAt"
|
||||
FROM core."applicationAuthorization"
|
||||
WHERE "userId" = $1 AND "applicationId" = $2`,
|
||||
[TEST_USER_ID, testApplication.id],
|
||||
);
|
||||
|
||||
return authorization;
|
||||
};
|
||||
|
||||
const deleteAuthorization = () =>
|
||||
ds.query(
|
||||
`DELETE FROM core."applicationAuthorization"
|
||||
WHERE "userId" = $1 AND "applicationId" = $2`,
|
||||
[TEST_USER_ID, testApplication.id],
|
||||
);
|
||||
|
||||
const revokeRefreshToken = (refreshToken: string) =>
|
||||
request(baseUrl)
|
||||
.post('/oauth/revoke')
|
||||
.send({
|
||||
token: refreshToken,
|
||||
client_id: testRegistration.oAuthClientId,
|
||||
client_secret: testClientSecret,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
// 'read' alone, not the registration's full scope list, so the assertion
|
||||
// fails if the granted scope is ignored in favour of the declared one.
|
||||
it('should record the authorization with the scopes the user granted', async () => {
|
||||
await exchangeForTokens('read');
|
||||
|
||||
const authorization = await findAuthorization();
|
||||
|
||||
expect(authorization).toBeDefined();
|
||||
expect(authorization.scopes).toEqual(['read']);
|
||||
expect(authorization.revokedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('should stop the refresh token being redeemed once the authorization is revoked', async () => {
|
||||
const { refreshToken } = await exchangeForTokens();
|
||||
|
||||
await revokeRefreshToken(refreshToken);
|
||||
|
||||
expect((await findAuthorization()).revokedAt).not.toBeNull();
|
||||
|
||||
const res = await postToken({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
client_id: testRegistration.oAuthClientId,
|
||||
client_secret: testClientSecret,
|
||||
}).expect(400);
|
||||
|
||||
expect(res.body.error).toBe('invalid_grant');
|
||||
});
|
||||
|
||||
it('should let the user authorize again after revoking', async () => {
|
||||
const { refreshToken: revokedRefreshToken } = await exchangeForTokens();
|
||||
|
||||
await revokeRefreshToken(revokedRefreshToken);
|
||||
|
||||
const { refreshToken } = await exchangeForTokens();
|
||||
|
||||
expect((await findAuthorization()).revokedAt).toBeNull();
|
||||
|
||||
await postToken({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
client_id: testRegistration.oAuthClientId,
|
||||
client_secret: testClientSecret,
|
||||
}).expect(200);
|
||||
});
|
||||
|
||||
// Deleting the row leaves a refresh token in the state every token minted
|
||||
// before this table existed is in.
|
||||
it('should backfill a refresh token that predates the authorization record without inventing a consent', async () => {
|
||||
const { refreshToken } = await exchangeForTokens('read write');
|
||||
|
||||
await deleteAuthorization();
|
||||
|
||||
await postToken({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
client_id: testRegistration.oAuthClientId,
|
||||
client_secret: testClientSecret,
|
||||
}).expect(200);
|
||||
|
||||
const authorization = await findAuthorization();
|
||||
|
||||
expect(authorization).toBeDefined();
|
||||
expect(authorization.scopes).toBeNull();
|
||||
expect(authorization.lastAuthorizedAt).toBeNull();
|
||||
expect(authorization.revokedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('should keep a refresh token predating the authorization record revoked', async () => {
|
||||
const { refreshToken } = await exchangeForTokens();
|
||||
|
||||
await deleteAuthorization();
|
||||
|
||||
await revokeRefreshToken(refreshToken);
|
||||
|
||||
const res = await postToken({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
client_id: testRegistration.oAuthClientId,
|
||||
client_secret: testClientSecret,
|
||||
}).expect(400);
|
||||
|
||||
expect(res.body.error).toBe('invalid_grant');
|
||||
});
|
||||
|
||||
const LIST_AUTHORIZATIONS_OPERATION = {
|
||||
query: gql`
|
||||
query CurrentUserApplicationAuthorizations {
|
||||
currentUserApplicationAuthorizations {
|
||||
id
|
||||
applicationId
|
||||
applicationName
|
||||
scopes
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
const revokeAuthorizationOperation = (
|
||||
applicationAuthorizationId: string,
|
||||
) => ({
|
||||
query: gql`
|
||||
mutation RevokeApplicationAuthorization(
|
||||
$applicationAuthorizationId: UUID!
|
||||
) {
|
||||
revokeApplicationAuthorization(
|
||||
applicationAuthorizationId: $applicationAuthorizationId
|
||||
)
|
||||
}
|
||||
`,
|
||||
variables: { applicationAuthorizationId },
|
||||
});
|
||||
|
||||
const findListedAuthorization = async (
|
||||
token = APPLE_JANE_ADMIN_ACCESS_TOKEN,
|
||||
) => {
|
||||
const res = await makeMetadataAPIRequest(
|
||||
LIST_AUTHORIZATIONS_OPERATION,
|
||||
token,
|
||||
);
|
||||
|
||||
expect(res.body.errors).toBeUndefined();
|
||||
|
||||
return res.body.data.currentUserApplicationAuthorizations.find(
|
||||
(authorization: { applicationId: string }) =>
|
||||
authorization.applicationId === testApplication.id,
|
||||
);
|
||||
};
|
||||
|
||||
const revokeListedAuthorization = (
|
||||
applicationAuthorizationId: string,
|
||||
token: string,
|
||||
) =>
|
||||
makeMetadataAPIRequest(
|
||||
revokeAuthorizationOperation(applicationAuthorizationId),
|
||||
token,
|
||||
);
|
||||
|
||||
it('should list the authorization to the user who granted it', async () => {
|
||||
await exchangeForTokens('read');
|
||||
|
||||
const authorization = await findListedAuthorization();
|
||||
|
||||
expect(authorization).toBeDefined();
|
||||
expect(authorization.applicationName).toBe(testRegistration.name);
|
||||
expect(authorization.scopes).toEqual(['read']);
|
||||
});
|
||||
|
||||
it('should stop the refresh token being redeemed when revoked from the list', async () => {
|
||||
const { refreshToken } = await exchangeForTokens();
|
||||
|
||||
const { id } = await findListedAuthorization();
|
||||
|
||||
const revokeResponse = await revokeListedAuthorization(
|
||||
id,
|
||||
APPLE_JANE_ADMIN_ACCESS_TOKEN,
|
||||
);
|
||||
|
||||
expect(revokeResponse.body.errors).toBeUndefined();
|
||||
expect(revokeResponse.body.data.revokeApplicationAuthorization).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
const res = await postToken({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
client_id: testRegistration.oAuthClientId,
|
||||
client_secret: testClientSecret,
|
||||
}).expect(400);
|
||||
|
||||
expect(res.body.error).toBe('invalid_grant');
|
||||
expect(await findListedAuthorization()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should report a repeated revocation as a no-op', async () => {
|
||||
await exchangeForTokens();
|
||||
|
||||
const { id } = await findListedAuthorization();
|
||||
|
||||
const firstRevoke = await revokeListedAuthorization(
|
||||
id,
|
||||
APPLE_JANE_ADMIN_ACCESS_TOKEN,
|
||||
);
|
||||
|
||||
expect(firstRevoke.body.data.revokeApplicationAuthorization).toBe(true);
|
||||
|
||||
const secondRevoke = await revokeListedAuthorization(
|
||||
id,
|
||||
APPLE_JANE_ADMIN_ACCESS_TOKEN,
|
||||
);
|
||||
|
||||
expect(secondRevoke.body.data.revokeApplicationAuthorization).toBe(false);
|
||||
});
|
||||
|
||||
it('should not let another user see or revoke the authorization', async () => {
|
||||
await exchangeForTokens();
|
||||
|
||||
const { id } = await findListedAuthorization();
|
||||
|
||||
expect(
|
||||
await findListedAuthorization(APPLE_JONY_MEMBER_ACCESS_TOKEN),
|
||||
).toBeUndefined();
|
||||
|
||||
const revokeResponse = await revokeListedAuthorization(
|
||||
id,
|
||||
APPLE_JONY_MEMBER_ACCESS_TOKEN,
|
||||
);
|
||||
|
||||
expect(revokeResponse.body.data.revokeApplicationAuthorization).toBe(
|
||||
false,
|
||||
);
|
||||
expect((await findAuthorization()).revokedAt).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user