diff --git a/docs/generated/postgres-schema/README.md b/docs/generated/postgres-schema/README.md
index d3634934f0a..589a004b8f6 100644
--- a/docs/generated/postgres-schema/README.md
+++ b/docs/generated/postgres-schema/README.md
@@ -52,7 +52,7 @@ Auto-generated from the PostgreSQL migrations in @n8n/db. Do not edit by hand.
| [public.chat_hub_sessions](public.chat_hub_sessions.md) | 13 | | BASE TABLE |
| [public.chat_hub_tools](public.chat_hub_tools.md) | 9 | | BASE TABLE |
| [public.credential_dependency](public.credential_dependency.md) | 5 | | BASE TABLE |
-| [public.credentials_entity](public.credentials_entity.md) | 13 | | BASE TABLE |
+| [public.credentials_entity](public.credentials_entity.md) | 14 | | BASE TABLE |
| [public.data_table](public.data_table.md) | 5 | | BASE TABLE |
| [public.data_table_column](public.data_table_column.md) | 7 | | BASE TABLE |
| [public.deployment_key](public.deployment_key.md) | 7 | | BASE TABLE |
@@ -888,6 +888,7 @@ erDiagram
boolean isManaged
boolean isResolvable
varchar_128_ name
+ timestamp_3__with_time_zone pendingAuthorizationExpiresAt
boolean resolvableAllowFallback
varchar_16_ resolverId FK
varchar_128_ type
diff --git a/docs/generated/postgres-schema/public.agent_credential_dependency.md b/docs/generated/postgres-schema/public.agent_credential_dependency.md
index 82c84b2229d..243130ebf69 100644
--- a/docs/generated/postgres-schema/public.agent_credential_dependency.md
+++ b/docs/generated/postgres-schema/public.agent_credential_dependency.md
@@ -64,6 +64,7 @@ erDiagram
boolean isManaged
boolean isResolvable
varchar_128_ name
+ timestamp_3__with_time_zone pendingAuthorizationExpiresAt
boolean resolvableAllowFallback
varchar_16_ resolverId FK
varchar_128_ type
diff --git a/docs/generated/postgres-schema/public.chat_hub_agents.md b/docs/generated/postgres-schema/public.chat_hub_agents.md
index ad685dfa75b..ac4d8248b9b 100644
--- a/docs/generated/postgres-schema/public.chat_hub_agents.md
+++ b/docs/generated/postgres-schema/public.chat_hub_agents.md
@@ -77,6 +77,7 @@ erDiagram
boolean isManaged
boolean isResolvable
varchar_128_ name
+ timestamp_3__with_time_zone pendingAuthorizationExpiresAt
boolean resolvableAllowFallback
varchar_16_ resolverId FK
varchar_128_ type
diff --git a/docs/generated/postgres-schema/public.chat_hub_sessions.md b/docs/generated/postgres-schema/public.chat_hub_sessions.md
index b2ca390d4ae..ad0c47d58dc 100644
--- a/docs/generated/postgres-schema/public.chat_hub_sessions.md
+++ b/docs/generated/postgres-schema/public.chat_hub_sessions.md
@@ -94,6 +94,7 @@ erDiagram
boolean isManaged
boolean isResolvable
varchar_128_ name
+ timestamp_3__with_time_zone pendingAuthorizationExpiresAt
boolean resolvableAllowFallback
varchar_16_ resolverId FK
varchar_128_ type
diff --git a/docs/generated/postgres-schema/public.credential_dependency.md b/docs/generated/postgres-schema/public.credential_dependency.md
index 93ddeb7fb0b..ea59f64d7ae 100644
--- a/docs/generated/postgres-schema/public.credential_dependency.md
+++ b/docs/generated/postgres-schema/public.credential_dependency.md
@@ -54,6 +54,7 @@ erDiagram
boolean isManaged
boolean isResolvable
varchar_128_ name
+ timestamp_3__with_time_zone pendingAuthorizationExpiresAt
boolean resolvableAllowFallback
varchar_16_ resolverId FK
varchar_128_ type
diff --git a/docs/generated/postgres-schema/public.credentials_entity.md b/docs/generated/postgres-schema/public.credentials_entity.md
index 9b214f6129f..ecfea55a258 100644
--- a/docs/generated/postgres-schema/public.credentials_entity.md
+++ b/docs/generated/postgres-schema/public.credentials_entity.md
@@ -12,6 +12,7 @@
| isManaged | boolean | false | false | | | |
| isResolvable | boolean | false | false | | | |
| name | varchar(128) | | false | | | |
+| pendingAuthorizationExpiresAt | timestamp(3) with time zone | | true | | | Set on a credential created for an OAuth authorization the user has not completed yet. Cleared when a token is written; rows past this time are deleted by the pending-authorization cleanup task |
| resolvableAllowFallback | boolean | false | false | | | |
| resolverId | varchar(16) | | true | | [public.dynamic_credential_resolver](public.dynamic_credential_resolver.md) | |
| type | varchar(128) | | false | | | |
@@ -71,6 +72,7 @@ erDiagram
boolean isManaged
boolean isResolvable
varchar_128_ name
+ timestamp_3__with_time_zone pendingAuthorizationExpiresAt
boolean resolvableAllowFallback
varchar_16_ resolverId FK
varchar_128_ type
diff --git a/docs/generated/postgres-schema/public.dynamic_credential_entry.md b/docs/generated/postgres-schema/public.dynamic_credential_entry.md
index 2a3c8b18766..d3c8855cfad 100644
--- a/docs/generated/postgres-schema/public.dynamic_credential_entry.md
+++ b/docs/generated/postgres-schema/public.dynamic_credential_entry.md
@@ -58,6 +58,7 @@ erDiagram
boolean isManaged
boolean isResolvable
varchar_128_ name
+ timestamp_3__with_time_zone pendingAuthorizationExpiresAt
boolean resolvableAllowFallback
varchar_16_ resolverId FK
varchar_128_ type
diff --git a/docs/generated/postgres-schema/public.dynamic_credential_resolver.md b/docs/generated/postgres-schema/public.dynamic_credential_resolver.md
index 317943ed78f..9a39af73834 100644
--- a/docs/generated/postgres-schema/public.dynamic_credential_resolver.md
+++ b/docs/generated/postgres-schema/public.dynamic_credential_resolver.md
@@ -56,6 +56,7 @@ erDiagram
boolean isManaged
boolean isResolvable
varchar_128_ name
+ timestamp_3__with_time_zone pendingAuthorizationExpiresAt
boolean resolvableAllowFallback
varchar_16_ resolverId FK
varchar_128_ type
diff --git a/docs/generated/postgres-schema/public.dynamic_credential_user_entry.md b/docs/generated/postgres-schema/public.dynamic_credential_user_entry.md
index 1ede80b0a2f..1e60f991e12 100644
--- a/docs/generated/postgres-schema/public.dynamic_credential_user_entry.md
+++ b/docs/generated/postgres-schema/public.dynamic_credential_user_entry.md
@@ -60,6 +60,7 @@ erDiagram
boolean isManaged
boolean isResolvable
varchar_128_ name
+ timestamp_3__with_time_zone pendingAuthorizationExpiresAt
boolean resolvableAllowFallback
varchar_16_ resolverId FK
varchar_128_ type
diff --git a/docs/generated/postgres-schema/public.instance_ai_mcp_registry_connections.md b/docs/generated/postgres-schema/public.instance_ai_mcp_registry_connections.md
index 2a64170a64e..95de1d5f7d0 100644
--- a/docs/generated/postgres-schema/public.instance_ai_mcp_registry_connections.md
+++ b/docs/generated/postgres-schema/public.instance_ai_mcp_registry_connections.md
@@ -61,6 +61,7 @@ erDiagram
boolean isManaged
boolean isResolvable
varchar_128_ name
+ timestamp_3__with_time_zone pendingAuthorizationExpiresAt
boolean resolvableAllowFallback
varchar_16_ resolverId FK
varchar_128_ type
diff --git a/docs/generated/postgres-schema/public.instance_credential_assignment.md b/docs/generated/postgres-schema/public.instance_credential_assignment.md
index 2c64d3d8b05..a49f71a1ee1 100644
--- a/docs/generated/postgres-schema/public.instance_credential_assignment.md
+++ b/docs/generated/postgres-schema/public.instance_credential_assignment.md
@@ -49,6 +49,7 @@ erDiagram
boolean isManaged
boolean isResolvable
varchar_128_ name
+ timestamp_3__with_time_zone pendingAuthorizationExpiresAt
boolean resolvableAllowFallback
varchar_16_ resolverId FK
varchar_128_ type
diff --git a/docs/generated/postgres-schema/public.shared_credentials.md b/docs/generated/postgres-schema/public.shared_credentials.md
index 3945d39b2fe..45dacff610b 100644
--- a/docs/generated/postgres-schema/public.shared_credentials.md
+++ b/docs/generated/postgres-schema/public.shared_credentials.md
@@ -53,6 +53,7 @@ erDiagram
boolean isManaged
boolean isResolvable
varchar_128_ name
+ timestamp_3__with_time_zone pendingAuthorizationExpiresAt
boolean resolvableAllowFallback
varchar_16_ resolverId FK
varchar_128_ type
diff --git a/docs/generated/sqlite-schema/README.md b/docs/generated/sqlite-schema/README.md
index bea77f1c993..64e6c5a1efa 100644
--- a/docs/generated/sqlite-schema/README.md
+++ b/docs/generated/sqlite-schema/README.md
@@ -52,7 +52,7 @@ Auto-generated from the SQLite migrations in @n8n/db. Do not edit by hand.
| [chat_hub_sessions](chat_hub_sessions.md) | 13 | | table |
| [chat_hub_tools](chat_hub_tools.md) | 9 | | table |
| [credential_dependency](credential_dependency.md) | 5 | | table |
-| [credentials_entity](credentials_entity.md) | 13 | | table |
+| [credentials_entity](credentials_entity.md) | 14 | | table |
| [data_table](data_table.md) | 5 | | table |
| [data_table_column](data_table_column.md) | 7 | | table |
| [deployment_key](deployment_key.md) | 7 | | table |
@@ -874,6 +874,7 @@ erDiagram
boolean isManaged
boolean isResolvable
varchar_128_ name
+ DATETIME pendingAuthorizationExpiresAt
boolean resolvableAllowFallback
varchar_16_ resolverId FK
varchar_32_ type
diff --git a/docs/generated/sqlite-schema/agent_credential_dependency.md b/docs/generated/sqlite-schema/agent_credential_dependency.md
index d688b1dd975..0d7b62f90bd 100644
--- a/docs/generated/sqlite-schema/agent_credential_dependency.md
+++ b/docs/generated/sqlite-schema/agent_credential_dependency.md
@@ -74,6 +74,7 @@ erDiagram
boolean isManaged
boolean isResolvable
varchar_128_ name
+ DATETIME pendingAuthorizationExpiresAt
boolean resolvableAllowFallback
varchar_16_ resolverId FK
varchar_32_ type
diff --git a/docs/generated/sqlite-schema/chat_hub_agents.md b/docs/generated/sqlite-schema/chat_hub_agents.md
index f962d2f8a75..2bf7f08270a 100644
--- a/docs/generated/sqlite-schema/chat_hub_agents.md
+++ b/docs/generated/sqlite-schema/chat_hub_agents.md
@@ -79,6 +79,7 @@ erDiagram
boolean isManaged
boolean isResolvable
varchar_128_ name
+ DATETIME pendingAuthorizationExpiresAt
boolean resolvableAllowFallback
varchar_16_ resolverId FK
varchar_32_ type
diff --git a/docs/generated/sqlite-schema/chat_hub_sessions.md b/docs/generated/sqlite-schema/chat_hub_sessions.md
index 8b50aede735..8b1da94185f 100644
--- a/docs/generated/sqlite-schema/chat_hub_sessions.md
+++ b/docs/generated/sqlite-schema/chat_hub_sessions.md
@@ -98,6 +98,7 @@ erDiagram
boolean isManaged
boolean isResolvable
varchar_128_ name
+ DATETIME pendingAuthorizationExpiresAt
boolean resolvableAllowFallback
varchar_16_ resolverId FK
varchar_32_ type
diff --git a/docs/generated/sqlite-schema/credential_dependency.md b/docs/generated/sqlite-schema/credential_dependency.md
index a067bd8455c..06ad2c8bb56 100644
--- a/docs/generated/sqlite-schema/credential_dependency.md
+++ b/docs/generated/sqlite-schema/credential_dependency.md
@@ -59,6 +59,7 @@ erDiagram
boolean isManaged
boolean isResolvable
varchar_128_ name
+ DATETIME pendingAuthorizationExpiresAt
boolean resolvableAllowFallback
varchar_16_ resolverId FK
varchar_32_ type
diff --git a/docs/generated/sqlite-schema/credentials_entity.md b/docs/generated/sqlite-schema/credentials_entity.md
index 2b76288d015..b53435ffb62 100644
--- a/docs/generated/sqlite-schema/credentials_entity.md
+++ b/docs/generated/sqlite-schema/credentials_entity.md
@@ -6,7 +6,7 @@
Table Definition
```sql
-CREATE TABLE "credentials_entity" ("id" varchar(36) PRIMARY KEY NOT NULL, "name" varchar(128) NOT NULL, "data" text NOT NULL, "type" varchar(32) NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "isManaged" boolean NOT NULL DEFAULT (0), "isGlobal" boolean NOT NULL DEFAULT (0), "isResolvable" boolean NOT NULL DEFAULT (false), "resolvableAllowFallback" boolean NOT NULL DEFAULT (false), "resolverId" varchar(16), "usageScope" VARCHAR(16) NOT NULL DEFAULT 'project' CONSTRAINT "CHK_credentials_entity_usageScope" CHECK ("usageScope" IN ('project', 'instance')), "description" TEXT, CONSTRAINT "credentials_entity_resolverId_foreign" FOREIGN KEY ("resolverId") REFERENCES "dynamic_credential_resolver" ("id") ON DELETE SET NULL)
+CREATE TABLE "credentials_entity" ("id" varchar(36) PRIMARY KEY NOT NULL, "name" varchar(128) NOT NULL, "data" text NOT NULL, "type" varchar(32) NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "isManaged" boolean NOT NULL DEFAULT (0), "isGlobal" boolean NOT NULL DEFAULT (0), "isResolvable" boolean NOT NULL DEFAULT (false), "resolvableAllowFallback" boolean NOT NULL DEFAULT (false), "resolverId" varchar(16), "usageScope" VARCHAR(16) NOT NULL DEFAULT 'project' CONSTRAINT "CHK_credentials_entity_usageScope" CHECK ("usageScope" IN ('project', 'instance')), "description" TEXT, "pendingAuthorizationExpiresAt" DATETIME, CONSTRAINT "credentials_entity_resolverId_foreign" FOREIGN KEY ("resolverId") REFERENCES "dynamic_credential_resolver" ("id") ON DELETE SET NULL)
```
@@ -23,6 +23,7 @@ CREATE TABLE "credentials_entity" ("id" varchar(36) PRIMARY KEY NOT NULL, "name"
| isManaged | boolean | 0 | false | | | |
| isResolvable | boolean | false | false | | | |
| name | varchar(128) | | false | | | |
+| pendingAuthorizationExpiresAt | DATETIME | | true | | | |
| resolvableAllowFallback | boolean | false | false | | | |
| resolverId | varchar(16) | | true | | [dynamic_credential_resolver](dynamic_credential_resolver.md) | |
| type | varchar(32) | | false | | | |
@@ -71,6 +72,7 @@ erDiagram
boolean isManaged
boolean isResolvable
varchar_128_ name
+ DATETIME pendingAuthorizationExpiresAt
boolean resolvableAllowFallback
varchar_16_ resolverId FK
varchar_32_ type
diff --git a/docs/generated/sqlite-schema/dynamic_credential_entry.md b/docs/generated/sqlite-schema/dynamic_credential_entry.md
index db49144aec8..4d0efc4b98f 100644
--- a/docs/generated/sqlite-schema/dynamic_credential_entry.md
+++ b/docs/generated/sqlite-schema/dynamic_credential_entry.md
@@ -66,6 +66,7 @@ erDiagram
boolean isManaged
boolean isResolvable
varchar_128_ name
+ DATETIME pendingAuthorizationExpiresAt
boolean resolvableAllowFallback
varchar_16_ resolverId FK
varchar_32_ type
diff --git a/docs/generated/sqlite-schema/dynamic_credential_resolver.md b/docs/generated/sqlite-schema/dynamic_credential_resolver.md
index 67f79a39051..cb329e5da53 100644
--- a/docs/generated/sqlite-schema/dynamic_credential_resolver.md
+++ b/docs/generated/sqlite-schema/dynamic_credential_resolver.md
@@ -62,6 +62,7 @@ erDiagram
boolean isManaged
boolean isResolvable
varchar_128_ name
+ DATETIME pendingAuthorizationExpiresAt
boolean resolvableAllowFallback
varchar_16_ resolverId FK
varchar_32_ type
diff --git a/docs/generated/sqlite-schema/dynamic_credential_user_entry.md b/docs/generated/sqlite-schema/dynamic_credential_user_entry.md
index ab44a715d2f..5d4ba64c7fb 100644
--- a/docs/generated/sqlite-schema/dynamic_credential_user_entry.md
+++ b/docs/generated/sqlite-schema/dynamic_credential_user_entry.md
@@ -68,6 +68,7 @@ erDiagram
boolean isManaged
boolean isResolvable
varchar_128_ name
+ DATETIME pendingAuthorizationExpiresAt
boolean resolvableAllowFallback
varchar_16_ resolverId FK
varchar_32_ type
diff --git a/docs/generated/sqlite-schema/instance_ai_mcp_registry_connections.md b/docs/generated/sqlite-schema/instance_ai_mcp_registry_connections.md
index 7a283a3c1bc..d266f4755f0 100644
--- a/docs/generated/sqlite-schema/instance_ai_mcp_registry_connections.md
+++ b/docs/generated/sqlite-schema/instance_ai_mcp_registry_connections.md
@@ -67,6 +67,7 @@ erDiagram
boolean isManaged
boolean isResolvable
varchar_128_ name
+ DATETIME pendingAuthorizationExpiresAt
boolean resolvableAllowFallback
varchar_16_ resolverId FK
varchar_32_ type
diff --git a/docs/generated/sqlite-schema/instance_credential_assignment.md b/docs/generated/sqlite-schema/instance_credential_assignment.md
index 286de0187a4..26f73c1f157 100644
--- a/docs/generated/sqlite-schema/instance_credential_assignment.md
+++ b/docs/generated/sqlite-schema/instance_credential_assignment.md
@@ -57,6 +57,7 @@ erDiagram
boolean isManaged
boolean isResolvable
varchar_128_ name
+ DATETIME pendingAuthorizationExpiresAt
boolean resolvableAllowFallback
varchar_16_ resolverId FK
varchar_32_ type
diff --git a/docs/generated/sqlite-schema/shared_credentials.md b/docs/generated/sqlite-schema/shared_credentials.md
index 61538a02190..68a008e5728 100644
--- a/docs/generated/sqlite-schema/shared_credentials.md
+++ b/docs/generated/sqlite-schema/shared_credentials.md
@@ -61,6 +61,7 @@ erDiagram
boolean isManaged
boolean isResolvable
varchar_128_ name
+ DATETIME pendingAuthorizationExpiresAt
boolean resolvableAllowFallback
varchar_16_ resolverId FK
varchar_32_ type
diff --git a/packages/@n8n/api-types/src/dto/credentials/create-credential.dto.ts b/packages/@n8n/api-types/src/dto/credentials/create-credential.dto.ts
index 99a76f253c8..ad3721466fe 100644
--- a/packages/@n8n/api-types/src/dto/credentials/create-credential.dto.ts
+++ b/packages/@n8n/api-types/src/dto/credentials/create-credential.dto.ts
@@ -13,4 +13,10 @@ export class CreateCredentialDto extends Z.class({
isGlobal: z.boolean().optional(),
isResolvable: z.boolean().optional(),
usageScope: z.enum(['project', 'instance']).optional(),
+ /**
+ * The credential is created for an OAuth popup the user has not finished yet.
+ * The server keeps it out of lists until a token is written and deletes it if
+ * that never happens.
+ */
+ pendingAuthorization: z.boolean().optional(),
}) {}
diff --git a/packages/@n8n/db/src/entities/credentials-entity.ts b/packages/@n8n/db/src/entities/credentials-entity.ts
index 4b6e486d2ee..97abb6fd83e 100644
--- a/packages/@n8n/db/src/entities/credentials-entity.ts
+++ b/packages/@n8n/db/src/entities/credentials-entity.ts
@@ -2,7 +2,7 @@ import { CREDENTIAL_DESCRIPTION_MAX_LENGTH } from '@n8n/api-types';
import { Column, Entity, Index, OneToMany } from '@n8n/typeorm';
import { IsObject, IsOptional, IsString, Length, MaxLength } from 'class-validator';
-import { WithTimestampsAndStringId } from './abstract-entity';
+import { DateTimeColumn, WithTimestampsAndStringId } from './abstract-entity';
import type { SharedCredentials } from './shared-credentials';
import type { ICredentialsDb } from './types-db';
@@ -76,6 +76,15 @@ export class CredentialsEntity extends WithTimestampsAndStringId implements ICre
@Column({ type: 'varchar', length: 16, default: 'project' })
usageScope: CredentialUsageScope;
+ /**
+ * Set on a credential created for an OAuth authorization the user has not
+ * completed. The row must exist so the callback has something to write to, but
+ * list queries skip it until a token clears this. Rows past the deadline are
+ * deleted by the pending-authorization cleanup task.
+ */
+ @DateTimeColumn({ nullable: true })
+ pendingAuthorizationExpiresAt: Date | null;
+
toJSON() {
const { shared, ...rest } = this;
return rest;
diff --git a/packages/@n8n/db/src/migrations/common/1790319787160-AddPendingAuthorizationToCredentials.ts b/packages/@n8n/db/src/migrations/common/1790319787160-AddPendingAuthorizationToCredentials.ts
new file mode 100644
index 00000000000..c3f608813b9
--- /dev/null
+++ b/packages/@n8n/db/src/migrations/common/1790319787160-AddPendingAuthorizationToCredentials.ts
@@ -0,0 +1,27 @@
+import type { MigrationContext, ReversibleMigration } from '../migration-types';
+
+const COLUMN_COMMENT =
+ 'Set on a credential created for an OAuth authorization the user has not completed yet. Cleared when a token is written; rows past this time are deleted by the pending-authorization cleanup task';
+
+export class AddPendingAuthorizationToCredentials1790319787160 implements ReversibleMigration {
+ // Raw ALTER TABLE, not addColumns: credentials_entity has cascading FKs and the
+ // SQLite table recreation would fire them.
+ async up({ escape, runQuery, isPostgres }: MigrationContext) {
+ const tableName = escape.tableName('credentials_entity');
+ const columnName = escape.columnName('pendingAuthorizationExpiresAt');
+ const columnType = isPostgres ? 'TIMESTAMPTZ(3)' : 'DATETIME';
+
+ await runQuery(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${columnType}`);
+
+ if (isPostgres) {
+ await runQuery(`COMMENT ON COLUMN ${tableName}.${columnName} IS '${COLUMN_COMMENT}'`);
+ }
+ }
+
+ async down({ escape, runQuery }: MigrationContext) {
+ const tableName = escape.tableName('credentials_entity');
+ const columnName = escape.columnName('pendingAuthorizationExpiresAt');
+
+ await runQuery(`ALTER TABLE ${tableName} DROP COLUMN ${columnName}`);
+ }
+}
diff --git a/packages/@n8n/db/src/repositories/__tests__/credentials.repository.test.ts b/packages/@n8n/db/src/repositories/__tests__/credentials.repository.test.ts
index 9eba9788249..70a2a19c6b8 100644
--- a/packages/@n8n/db/src/repositories/__tests__/credentials.repository.test.ts
+++ b/packages/@n8n/db/src/repositories/__tests__/credentials.repository.test.ts
@@ -2,7 +2,7 @@ import { credentialContentSubject, type PolicySubject } from '@n8n/decorators';
import { mintPolicyCleared } from '@n8n/decorators/policy-internal';
import { Container } from '@n8n/di';
import type { EntityManager, SelectQueryBuilder } from '@n8n/typeorm';
-import { In, Like, Not, QueryFailedError } from '@n8n/typeorm';
+import { In, IsNull, Like, Not, QueryFailedError } from '@n8n/typeorm';
import { mock } from 'vitest-mock-extended';
import { CredentialsEntity, SharedCredentials } from '../../entities';
@@ -340,8 +340,18 @@ describe('CredentialsRepository', () => {
const callArg = entityManager.find.mock.calls[0]?.[1];
expect(callArg?.where).toEqual([
- { type: Like('%githubApi%'), shared: { projectId: 'p1' }, usageScope: 'project' },
- { type: Like('%githubApi%'), usageScope: 'project', isGlobal: true },
+ {
+ type: Like('%githubApi%'),
+ shared: { projectId: 'p1' },
+ usageScope: 'project',
+ pendingAuthorizationExpiresAt: IsNull(),
+ },
+ {
+ type: Like('%githubApi%'),
+ usageScope: 'project',
+ isGlobal: true,
+ pendingAuthorizationExpiresAt: IsNull(),
+ },
]);
expect(entityManager.count).toHaveBeenCalledWith(CredentialsEntity, {
where: callArg?.where,
diff --git a/packages/@n8n/db/src/repositories/credentials.repository.ts b/packages/@n8n/db/src/repositories/credentials.repository.ts
index 7bb7412a2c1..64ba7ade07f 100644
--- a/packages/@n8n/db/src/repositories/credentials.repository.ts
+++ b/packages/@n8n/db/src/repositories/credentials.repository.ts
@@ -2,7 +2,7 @@ import { assertClearedFor, credentialContentSubject } from '@n8n/decorators';
import { Container, Service } from '@n8n/di';
import type { Scope } from '@n8n/permissions';
import type { FindManyOptions, FindOptionsWhere, SelectQueryBuilder } from '@n8n/typeorm';
-import { DataSource, In, Like, Not, QueryFailedError } from '@n8n/typeorm';
+import { DataSource, In, IsNull, LessThan, Like, Not, QueryFailedError } from '@n8n/typeorm';
import type { QueryDeepPartialEntity } from '@n8n/typeorm/query-builder/QueryPartialEntity';
import { UserError } from 'n8n-workflow';
@@ -102,7 +102,10 @@ export class CredentialsRepository extends BaseRepository {
async findStartingWith(credentialName: string) {
return await this.find({
select: ['name'],
- where: { name: Like(`${credentialName}%`), usageScope: 'project' },
+ where: this.excludePendingAuthorization({
+ name: Like(`${credentialName}%`),
+ usageScope: 'project',
+ }),
});
}
@@ -328,10 +331,30 @@ export class CredentialsRepository extends BaseRepository {
private onlyProjectCredentials(
findManyOptions: FindManyOptions,
): FindManyOptions {
- findManyOptions.where = { ...findManyOptions.where, usageScope: 'project' };
+ findManyOptions.where = this.excludePendingAuthorization({
+ ...findManyOptions.where,
+ usageScope: 'project',
+ });
return findManyOptions;
}
+ /**
+ * Narrows a list filter to credentials the user has finished authorizing. A
+ * credential created for an in-flight OAuth popup exists only so the callback
+ * can write to it; lookups by id still find it, lists must not.
+ */
+ excludePendingAuthorization(
+ where: FindOptionsWhere,
+ ): FindOptionsWhere {
+ return { ...where, pendingAuthorizationExpiresAt: IsNull() };
+ }
+
+ /** Deletes credentials whose OAuth authorization was never completed in time. */
+ async deleteExpiredPendingAuthorizations(now: Date): Promise {
+ const result = await this.delete({ pendingAuthorizationExpiresAt: LessThan(now) });
+ return result.affected ?? 0;
+ }
+
private toFindManyOptions(listQueryOptions?: CredentialsListQueryOptions) {
const findManyOptions: FindManyOptions = {};
@@ -468,7 +491,11 @@ export class CredentialsRepository extends BaseRepository {
options: { includeData?: boolean } = {},
): Promise {
const findManyOptions = this.toFindManyOptions({ includeData: options.includeData ?? false });
- findManyOptions.where = { ...findManyOptions.where, isGlobal: true, usageScope: 'project' };
+ findManyOptions.where = this.excludePendingAuthorization({
+ ...findManyOptions.where,
+ isGlobal: true,
+ usageScope: 'project',
+ });
return await this.find(findManyOptions);
}
@@ -577,6 +604,7 @@ export class CredentialsRepository extends BaseRepository {
): SelectQueryBuilder {
const qb = this.createQueryBuilder('credential');
qb.andWhere('credential.usageScope = :usageScope', { usageScope: 'project' });
+ qb.andWhere('credential.pendingAuthorizationExpiresAt IS NULL');
if (options.filters?.dependency) {
addCredentialDependencyExistsFilter(qb, options.filters.dependency);
diff --git a/packages/cli/src/commands/import/credentials.ts b/packages/cli/src/commands/import/credentials.ts
index f807e79bfb1..95ab30eeb17 100644
--- a/packages/cli/src/commands/import/credentials.ts
+++ b/packages/cli/src/commands/import/credentials.ts
@@ -65,7 +65,7 @@ const flagsSchema = z.object({
type ImportableCredentialProperty = Exclude<
Extract,
- 'shared' | 'toJSON' | 'generateId' | 'setUpdateDate'
+ 'shared' | 'toJSON' | 'generateId' | 'setUpdateDate' | 'pendingAuthorizationExpiresAt'
>;
const isCredentialData = (data: unknown): data is ICredentialDataDecryptedObject =>
@@ -203,6 +203,8 @@ export class ImportCredentialsCommand extends BaseCommand {
const em = trx ?? this.credentialsRepository.manager;
return await em.find(CredentialsEntity, {
- where: { isGlobal: true, usageScope: 'project' },
+ where: this.credentialsRepository.excludePendingAuthorization({
+ isGlobal: true,
+ usageScope: 'project',
+ }),
relations: { shared: true },
});
}
@@ -172,7 +175,7 @@ export class CredentialsFinderService {
}
const credentials = await this.credentialsRepository.find({
- where,
+ where: this.credentialsRepository.excludePendingAuthorization(where),
relations: { shared: true },
});
diff --git a/packages/cli/src/credentials/credentials.service.ts b/packages/cli/src/credentials/credentials.service.ts
index 12d30d1c968..28298f9cf91 100644
--- a/packages/cli/src/credentials/credentials.service.ts
+++ b/packages/cli/src/credentials/credentials.service.ts
@@ -1,5 +1,6 @@
import type { CreateCredentialDto, CredentialConnectionStatus } from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
+import { Time } from '@n8n/constants';
import {
Project,
TransactionRunner,
@@ -153,6 +154,13 @@ type CreateCredentialOptions = CreateCredentialDto & {
isManaged: boolean;
};
+/**
+ * How long a credential created for an OAuth popup may stay unauthorized before
+ * the cleanup task deletes it. Well above the editor's own flow timeout, so only
+ * flows the editor could not finish (reload, closed tab) reach the sweep.
+ */
+export const PENDING_AUTHORIZATION_GRACE_MS = 1 * Time.hours.toMilliseconds;
+
type InstanceCredentialWriteOptions = {
/** Set when the caller already ran the external hooks before its transaction. */
skipExternalHooks?: boolean;
@@ -2090,6 +2098,15 @@ export class CredentialsService {
user: User,
options: { id?: string } = {},
) {
+ // The pending state ends when a token lands on the credential row. End-user
+ // credentials store tokens per user and instance credentials take another
+ // write path, so neither would ever leave it.
+ if (opts.pendingAuthorization && (opts.isResolvable || opts.usageScope === 'instance')) {
+ throw new BadRequestError(
+ 'Pending authorization is only supported for project-scoped, non-resolvable credentials',
+ );
+ }
+
if (opts.usageScope === 'instance') {
if (options.id !== undefined) {
throw new BadRequestError('A supplied ID requires project usage scope');
@@ -2149,6 +2166,9 @@ export class CredentialsService {
...encryptedCredential,
isManaged: opts.isManaged,
isResolvable: opts.isResolvable ?? false,
+ pendingAuthorizationExpiresAt: opts.pendingAuthorization
+ ? new Date(Date.now() + PENDING_AUTHORIZATION_GRACE_MS)
+ : null,
});
const persist =
diff --git a/packages/cli/src/credentials/pending-authorization-cleanup.task.ts b/packages/cli/src/credentials/pending-authorization-cleanup.task.ts
new file mode 100644
index 00000000000..5ff6688b9af
--- /dev/null
+++ b/packages/cli/src/credentials/pending-authorization-cleanup.task.ts
@@ -0,0 +1,40 @@
+import { Logger } from '@n8n/backend-common';
+import { Time } from '@n8n/constants';
+import { CredentialsRepository } from '@n8n/db';
+import { SystemTask } from '@n8n/decorators';
+import type { SystemTaskEffects, SystemTaskPlacement, SystemTaskSchedule } from '@n8n/decorators';
+
+/**
+ * Deletes credentials created for an OAuth popup whose authorization never
+ * arrived. The editor deletes these itself when its flow ends; this catches the
+ * flows it could not end, such as a page reload or a closed tab.
+ */
+@SystemTask()
+export class PendingAuthorizationCleanupTask implements SystemTask {
+ name = 'pending-authorization-cleanup';
+
+ /** Rows are hidden while they wait, so they only need to go within a few grace periods. */
+ schedule: SystemTaskSchedule = {
+ kind: 'interval',
+ intervalSeconds: 1 * Time.hours.toSeconds,
+ };
+
+ /** A row is either past its deadline or not, so a repeated run deletes nothing new. */
+ effects: SystemTaskEffects = 'idempotent';
+
+ placement: SystemTaskPlacement = { scope: 'cluster', durable: false, runOnTakeover: true };
+
+ constructor(
+ private readonly logger: Logger,
+ private readonly credentialsRepository: CredentialsRepository,
+ ) {
+ this.logger = this.logger.scoped('quick-connect');
+ }
+
+ async run(): Promise {
+ const deleted = await this.credentialsRepository.deleteExpiredPendingAuthorizations(new Date());
+ if (deleted > 0) {
+ this.logger.debug(`Deleted ${deleted} credentials with expired pending authorization`);
+ }
+ }
+}
diff --git a/packages/cli/src/modules/mcp/__tests__/webhook-utils.test.ts b/packages/cli/src/modules/mcp/__tests__/webhook-utils.test.ts
index 968390353d7..b2599472b45 100644
--- a/packages/cli/src/modules/mcp/__tests__/webhook-utils.test.ts
+++ b/packages/cli/src/modules/mcp/__tests__/webhook-utils.test.ts
@@ -38,6 +38,7 @@ const mockCredentialsService = (
usageScope: 'project',
resolverId: null,
resolvableAllowFallback: false,
+ pendingAuthorizationExpiresAt: null,
id,
// Methods present on entities via WithTimestampsAndStringId mixin
generateId() {},
diff --git a/packages/cli/src/modules/n8n-packages/entities/credential/credential.serializer.ts b/packages/cli/src/modules/n8n-packages/entities/credential/credential.serializer.ts
index 0676098845d..7311843a540 100644
--- a/packages/cli/src/modules/n8n-packages/entities/credential/credential.serializer.ts
+++ b/packages/cli/src/modules/n8n-packages/entities/credential/credential.serializer.ts
@@ -23,6 +23,7 @@ type CredentialPackageKeyHandling = {
resolvableAllowFallback: 'exclude';
resolverId: 'exclude';
usageScope: 'exclude';
+ pendingAuthorizationExpiresAt: 'exclude';
};
const serializePayload = definePackageSerializationPayload<
diff --git a/packages/cli/src/modules/source-control.ee/source-control-import.service.ee.ts b/packages/cli/src/modules/source-control.ee/source-control-import.service.ee.ts
index d4be61f3a50..3c34435aece 100644
--- a/packages/cli/src/modules/source-control.ee/source-control-import.service.ee.ts
+++ b/packages/cli/src/modules/source-control.ee/source-control-import.service.ee.ts
@@ -429,8 +429,10 @@ export class SourceControlImportService {
role: true,
},
},
- where:
+ // A credential the user never finished authorizing must not reach the push dialog or git
+ where: this.credentialsRepository.excludePendingAuthorization(
this.sourceControlScopedService.getCredentialsInAdminProjectsFromContextFilter(context),
+ ),
});
// Batched to bound the transient decryption allocations (plaintext + parsed object)
diff --git a/packages/cli/src/oauth/oauth.service.ts b/packages/cli/src/oauth/oauth.service.ts
index bf23692438d..d80793d57e5 100644
--- a/packages/cli/src/oauth/oauth.service.ts
+++ b/packages/cli/src/oauth/oauth.service.ts
@@ -417,8 +417,12 @@ export class OauthService {
await credentials.updateData(toUpdate, toDelete);
// Ciphertext only. `name` and `type` would be written back unchanged, and a payload
// that cannot carry `type` keeps this off the sealed `credentialSave` path.
- const update: Pick = {
+ // Only a token ends the pending state: dynamic client registration writes
+ // client data through here before the user has seen the consent screen.
+ const update: Pick &
+ Partial> = {
data: credentials.getDataToSave().data,
+ ...(toUpdate.oauthTokenData !== undefined && { pendingAuthorizationExpiresAt: null }),
updatedAt: new Date(),
};
await this.credentialsRepository.update(credential.id, update);
diff --git a/packages/cli/src/scheduling/system-tasks/__tests__/main-system-tasks.test.ts b/packages/cli/src/scheduling/system-tasks/__tests__/main-system-tasks.test.ts
index 131423df3dc..d026299f6bc 100644
--- a/packages/cli/src/scheduling/system-tasks/__tests__/main-system-tasks.test.ts
+++ b/packages/cli/src/scheduling/system-tasks/__tests__/main-system-tasks.test.ts
@@ -1,6 +1,7 @@
import type { GlobalConfig } from '@n8n/config';
import { mock } from 'vitest-mock-extended';
+import { PendingAuthorizationCleanupTask } from '@/credentials/pending-authorization-cleanup.task';
import { LicenseRenewalTask } from '@/license/license-renewal.task';
import { ActivityPruningTask } from '@/services/pruning/activity-pruning.task';
import { ExecutionPruningSoftDeleteTask } from '@/services/pruning/execution-pruning-soft-delete.task';
@@ -30,6 +31,7 @@ it('should return every main task when all features are on', async () => {
LicenseRenewalTask,
WorkflowHistoryCompactionOptimizeTask,
WorkflowHistoryCompactionTrimTask,
+ PendingAuthorizationCleanupTask,
ExecutionPruningSoftDeleteTask,
TelemetryPulseTask,
WorkflowPublicationOutboxCleanupTask,
diff --git a/packages/cli/src/scheduling/system-tasks/main-system-tasks.ts b/packages/cli/src/scheduling/system-tasks/main-system-tasks.ts
index e54d28713cf..21dfa94c682 100644
--- a/packages/cli/src/scheduling/system-tasks/main-system-tasks.ts
+++ b/packages/cli/src/scheduling/system-tasks/main-system-tasks.ts
@@ -15,12 +15,16 @@ export async function mainSystemTasks(globalConfig: GlobalConfig): Promise {
beforeEach(() => {
vi.clearAllMocks();
+ credentialsRepository.excludePendingAuthorization.mockImplementation((where) => where);
// Setup manager mock for global credentials fetching
@@ -448,6 +449,14 @@ describe('CredentialsFinderService', () => {
where: { isGlobal: true, usageScope: 'project' },
relations: { shared: true },
});
+ expect(credentialsRepository.excludePendingAuthorization).toHaveBeenCalledWith({
+ isGlobal: false,
+ usageScope: 'project',
+ });
+ expect(credentialsRepository.excludePendingAuthorization).toHaveBeenCalledWith({
+ isGlobal: true,
+ usageScope: 'project',
+ });
expect(roleService.rolesWithScope).not.toHaveBeenCalled();
expect(result).toEqual([...credentials]);
});
diff --git a/packages/cli/test/integration/commands/credentials.cmd.test.ts b/packages/cli/test/integration/commands/credentials.cmd.test.ts
index fe535d30e6a..7c7cff8d6a8 100644
--- a/packages/cli/test/integration/commands/credentials.cmd.test.ts
+++ b/packages/cli/test/integration/commands/credentials.cmd.test.ts
@@ -795,6 +795,34 @@ test.each([
]);
});
+test('import:credentials should drop a pending authorization deadline carried in the file', async () => {
+ await createOwner();
+ const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'n8n-credential-import-'));
+ const inputPath = path.join(temporaryDirectory, 'credentials.json');
+ fs.writeFileSync(
+ inputPath,
+ JSON.stringify([
+ {
+ id: '123',
+ name: 'pending-cred',
+ type: 'aws',
+ data: { region: 'us-east-1' },
+ pendingAuthorizationExpiresAt: new Date().toISOString(),
+ },
+ ]),
+ );
+
+ try {
+ await command.run([`--input=${inputPath}`]);
+ } finally {
+ fs.rmSync(temporaryDirectory, { recursive: true, force: true });
+ }
+
+ await expect(getAllCredentials()).resolves.toEqual([
+ expect.objectContaining({ id: '123', pendingAuthorizationExpiresAt: null }),
+ ]);
+});
+
test('`import:credential --projectId ... --userId ...` fails explaining that only one of the options can be used at a time', async () => {
await expect(
command.run([
diff --git a/packages/cli/test/integration/credentials/credentials.api.test.ts b/packages/cli/test/integration/credentials/credentials.api.test.ts
index 969a86aea1a..45b81a8baee 100644
--- a/packages/cli/test/integration/credentials/credentials.api.test.ts
+++ b/packages/cli/test/integration/credentials/credentials.api.test.ts
@@ -23,7 +23,12 @@ import {
} from 'n8n-workflow';
import { mock } from 'vitest-mock-extended';
-import { CredentialsService } from '@/credentials/credentials.service';
+import {
+ CredentialsService,
+ PENDING_AUTHORIZATION_GRACE_MS,
+} from '@/credentials/credentials.service';
+import { PendingAuthorizationCleanupTask } from '@/credentials/pending-authorization-cleanup.task';
+import { OauthService } from '@/oauth/oauth.service';
import { createCredentialsFromCredentialsEntity } from '@/credentials-helper';
import { CredentialsTester } from '@/services/credentials-tester.service';
@@ -2103,6 +2108,141 @@ describe('GET /credentials/:id', () => {
});
});
+describe('pending authorization', () => {
+ const createPending = async () => {
+ const response = await authMemberAgent
+ .post('/credentials')
+ .send({ ...randomCredentialPayload(), pendingAuthorization: true });
+ expect(response.statusCode).toBe(200);
+ return response.body.data.id as string;
+ };
+
+ const listedIds = async () => {
+ const response = await authMemberAgent.get('/credentials');
+ expect(response.statusCode).toBe(200);
+ return response.body.data.map((c: { id: string }) => c.id);
+ };
+
+ test('should set the deadline on creation', async () => {
+ const id = await createPending();
+
+ const credential = await getCredentialById(id);
+ a.ok(credential?.pendingAuthorizationExpiresAt);
+ expect(credential.pendingAuthorizationExpiresAt.getTime() - Date.now()).toBeCloseTo(
+ PENDING_AUTHORIZATION_GRACE_MS,
+ -4,
+ );
+ });
+
+ test('should leave it out of lists but keep it reachable by id', async () => {
+ const id = await createPending();
+ const visible = await saveCredential(randomCredentialPayload(), {
+ user: member,
+ role: 'credential:owner',
+ });
+
+ expect(await listedIds()).toEqual([visible.id]);
+
+ const forProject = await authMemberAgent
+ .get('/credentials/for-workflow')
+ .query({ projectId: memberPersonalProject.id });
+ expect(forProject.statusCode).toBe(200);
+ expect(forProject.body.data.map((c: { id: string }) => c.id)).toEqual([visible.id]);
+
+ const byId = await authMemberAgent.get(`/credentials/${id}`);
+ expect(byId.statusCode).toBe(200);
+ expect(byId.body.data.id).toBe(id);
+ });
+
+ test('should list it once a token is written', async () => {
+ const id = await createPending();
+ const credential = await getCredentialById(id);
+ a.ok(credential);
+
+ await Container.get(OauthService).encryptAndSaveData(credential, {
+ oauthTokenData: { access_token: 'token' },
+ });
+
+ expect((await getCredentialById(id))?.pendingAuthorizationExpiresAt).toBeNull();
+ expect(await listedIds()).toEqual([id]);
+ });
+
+ test('should stay pending when only client registration data is written', async () => {
+ const id = await createPending();
+ const credential = await getCredentialById(id);
+ a.ok(credential);
+
+ await Container.get(OauthService).encryptAndSaveData(credential, {
+ clientId: 'registered-client',
+ clientSecret: 'registered-secret',
+ });
+
+ expect((await getCredentialById(id))?.pendingAuthorizationExpiresAt).not.toBeNull();
+ expect(await listedIds()).toEqual([]);
+ });
+
+ test('should reject the flag on end-user and instance credentials', async () => {
+ const resolvable = await authOwnerAgent
+ .post('/credentials')
+ .send({ ...randomCredentialPayload(), pendingAuthorization: true, isResolvable: true });
+ expect(resolvable.statusCode).toBe(400);
+
+ const instance = await authOwnerAgent
+ .post('/credentials')
+ .send({ ...randomCredentialPayload(), pendingAuthorization: true, usageScope: 'instance' });
+ expect(instance.statusCode).toBe(400);
+ });
+
+ test('should leave a pending global credential out of lists', async () => {
+ const pendingGlobal = await authOwnerAgent
+ .post('/credentials')
+ .send({ ...randomCredentialPayload(), pendingAuthorization: true, isGlobal: true });
+ expect(pendingGlobal.statusCode).toBe(200);
+
+ const list = await authMemberAgent.get('/credentials').query({ includeGlobal: true });
+ expect(list.statusCode).toBe(200);
+ expect(list.body.data).toEqual([]);
+
+ const forProject = await authMemberAgent
+ .get('/credentials/for-workflow')
+ .query({ projectId: memberPersonalProject.id });
+ expect(forProject.statusCode).toBe(200);
+ expect(forProject.body.data).toEqual([]);
+ });
+
+ test('should not count toward the next credential name', async () => {
+ const name = randomCredentialPayload().name;
+ await authMemberAgent.post('/credentials').send({
+ ...randomCredentialPayload(),
+ name,
+ pendingAuthorization: true,
+ });
+
+ const response = await authMemberAgent.get(`/credentials/new?name=${name}`);
+
+ expect(response.statusCode).toBe(200);
+ expect(response.body.data.name).toBe(name);
+ });
+
+ test('cleanup task should delete only credentials past their deadline', async () => {
+ const expiredId = await createPending();
+ const pendingId = await createPending();
+ const regular = await saveCredential(randomCredentialPayload(), {
+ user: member,
+ role: 'credential:owner',
+ });
+ await Container.get(CredentialsRepository).update(expiredId, {
+ pendingAuthorizationExpiresAt: new Date(Date.now() - 1000),
+ });
+
+ await Container.get(PendingAuthorizationCleanupTask).run();
+
+ expect(await getCredentialById(expiredId)).toBeNull();
+ expect(await getCredentialById(pendingId)).not.toBeNull();
+ expect(await getCredentialById(regular.id)).not.toBeNull();
+ });
+});
+
describe('POST /credentials/test', () => {
const mockCredentialsTester = mock();
Container.set(CredentialsTester, mockCredentialsTester);
diff --git a/packages/cli/test/integration/environments/source-control-import.service.test.ts b/packages/cli/test/integration/environments/source-control-import.service.test.ts
index 174ba020098..c502afcfb35 100644
--- a/packages/cli/test/integration/environments/source-control-import.service.test.ts
+++ b/packages/cli/test/integration/environments/source-control-import.service.test.ts
@@ -713,6 +713,23 @@ describe('SourceControlImportService', () => {
);
});
+ it('should leave out credentials whose authorization is still pending', async () => {
+ const pending = await createCredentials(
+ { name: 'pending', data: '', type: 'test' },
+ teamProjectA,
+ );
+ await credentialsRepository.update(pending.id, {
+ pendingAuthorizationExpiresAt: new Date(Date.now() + 60_000),
+ });
+
+ const versions = await service.getLocalCredentialsFromDb(
+ await sourceControlContextFactory.createContext(instanceOwner),
+ );
+
+ expect(versions.map((v) => v.id)).not.toContain(pending.id);
+ expect(versions).toHaveLength(teamACredentials.length + teamBCredentials.length);
+ });
+
it('should only get all available credentials from the team project, for a project admin', async () => {
const versions = await service.getLocalCredentialsFromDb(
await sourceControlContextFactory.createContext(projectAdmin),
diff --git a/packages/frontend/editor-ui/src/features/credentials/__tests__/credentials.store.test.ts b/packages/frontend/editor-ui/src/features/credentials/__tests__/credentials.store.test.ts
index 75201360868..7b0a5b76859 100644
--- a/packages/frontend/editor-ui/src/features/credentials/__tests__/credentials.store.test.ts
+++ b/packages/frontend/editor-ui/src/features/credentials/__tests__/credentials.store.test.ts
@@ -471,6 +471,26 @@ describe('credentials.store', () => {
});
describe('createNewCredential', () => {
+ it('passes pendingAuthorization through to the API', async () => {
+ const store = useCredentialsStore();
+ vi.spyOn(credentialsApi, 'createNewCredential').mockResolvedValue(
+ mock({ id: 'pending-1' }),
+ );
+
+ await store.createNewCredential(
+ { id: '', name: 'Pending account', type: 'slackOAuth2Api', data: {} },
+ 'project-123',
+ undefined,
+ { skipStoreUpdate: true, pendingAuthorization: true },
+ );
+
+ expect(credentialsApi.createNewCredential).toHaveBeenCalledWith(
+ mockRootStore.restApiContext,
+ expect.objectContaining({ pendingAuthorization: true }),
+ );
+ expect(store.getCredentialById('pending-1')).toBeUndefined();
+ });
+
it.each(['Use for production reports', null])(
'passes description %j as credential metadata',
async (description) => {
diff --git a/packages/frontend/editor-ui/src/features/credentials/composables/__tests__/useCredentialOAuth.test.ts b/packages/frontend/editor-ui/src/features/credentials/composables/__tests__/useCredentialOAuth.test.ts
index ff9392d397d..4d0374293a8 100644
--- a/packages/frontend/editor-ui/src/features/credentials/composables/__tests__/useCredentialOAuth.test.ts
+++ b/packages/frontend/editor-ui/src/features/credentials/composables/__tests__/useCredentialOAuth.test.ts
@@ -920,7 +920,7 @@ describe('useCredentialOAuth', () => {
}),
undefined,
undefined,
- { skipStoreUpdate: true },
+ { skipStoreUpdate: true, pendingAuthorization: true },
);
});
@@ -943,7 +943,7 @@ describe('useCredentialOAuth', () => {
},
'workflow-project',
undefined,
- { skipStoreUpdate: true },
+ { skipStoreUpdate: true, pendingAuthorization: true },
);
expect(credentialsStore.fetchUsableCredentials).toHaveBeenCalledWith({
workflowId: 'setup-workflow',
@@ -966,7 +966,7 @@ describe('useCredentialOAuth', () => {
expect.objectContaining({ description: 'Use for production alerts' }),
undefined,
undefined,
- { skipStoreUpdate: true },
+ { skipStoreUpdate: true, pendingAuthorization: true },
);
});
@@ -1014,7 +1014,7 @@ describe('useCredentialOAuth', () => {
}),
undefined,
undefined,
- { skipStoreUpdate: true },
+ { skipStoreUpdate: true, pendingAuthorization: true },
);
});
diff --git a/packages/frontend/editor-ui/src/features/credentials/composables/useCredentialOAuth.ts b/packages/frontend/editor-ui/src/features/credentials/composables/useCredentialOAuth.ts
index 7fea678735d..9324789d162 100644
--- a/packages/frontend/editor-ui/src/features/credentials/composables/useCredentialOAuth.ts
+++ b/packages/frontend/editor-ui/src/features/credentials/composables/useCredentialOAuth.ts
@@ -467,7 +467,9 @@ export function useCredentialOAuth() {
},
options.projectId ?? projectsStore.currentProject?.id,
undefined,
- { skipStoreUpdate: true },
+ // The server hides the credential from lists and deletes it if the
+ // popup never completes, so a reload mid-flow leaves nothing behind.
+ { skipStoreUpdate: true, pendingAuthorization: true },
));
if (!existingCredential)
diff --git a/packages/frontend/editor-ui/src/features/credentials/credentials.store.ts b/packages/frontend/editor-ui/src/features/credentials/credentials.store.ts
index 743503cc55b..eccbdd70678 100644
--- a/packages/frontend/editor-ui/src/features/credentials/credentials.store.ts
+++ b/packages/frontend/editor-ui/src/features/credentials/credentials.store.ts
@@ -444,7 +444,7 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, () => {
data: CredentialPayload,
projectId?: string,
uiContext?: string,
- options?: { skipStoreUpdate?: boolean },
+ options?: { skipStoreUpdate?: boolean; pendingAuthorization?: boolean },
): Promise => {
const settingsStore = useSettingsStore();
const credential = await credentialsApi.createNewCredential(rootStore.restApiContext, {
@@ -459,6 +459,7 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, () => {
isGlobal: data.isGlobal,
isResolvable: data.isResolvable,
usageScope: data.usageScope,
+ pendingAuthorization: options?.pendingAuthorization,
});
if (data?.homeProject && !credential.homeProject) {